274 lines
9.0 KiB
TypeScript
274 lines
9.0 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import os from "node:os";
|
|
import type { CavemanIntensity, CavemanRule } from "./types.ts";
|
|
|
|
type CavemanRuleCategory = NonNullable<CavemanRule["category"]>;
|
|
type CavemanRuleContext = CavemanRule["context"];
|
|
|
|
interface FileRule {
|
|
name: string;
|
|
pattern: string;
|
|
replacement?: string;
|
|
replacementMap?: Record<string, string>;
|
|
flags?: string;
|
|
context?: CavemanRuleContext;
|
|
category?: CavemanRuleCategory;
|
|
minIntensity?: CavemanIntensity;
|
|
description?: string;
|
|
}
|
|
|
|
interface RulePack {
|
|
language: string;
|
|
category: string;
|
|
rules: FileRule[];
|
|
}
|
|
|
|
export interface RulePackMetadata {
|
|
language: string;
|
|
categories: string[];
|
|
ruleCount: number;
|
|
}
|
|
|
|
const VALID_CONTEXTS = new Set(["all", "user", "system", "assistant"]);
|
|
const VALID_CATEGORIES = new Set(["filler", "context", "structural", "dedup", "terse", "ultra"]);
|
|
const VALID_INTENSITIES = new Set(["lite", "full", "ultra"]);
|
|
const cache = new Map<string, CavemanRule[]>();
|
|
let rulesDirCache: string | null = null;
|
|
|
|
function normalizeReplacementKey(value: string): string {
|
|
return value.trim().replace(/\s+/g, " ").toLowerCase();
|
|
}
|
|
|
|
function compileReplacement(rule: FileRule): CavemanRule["replacement"] {
|
|
if (!rule.replacementMap) return rule.replacement ?? "";
|
|
|
|
const normalizedMap = new Map(
|
|
Object.entries(rule.replacementMap).map(([key, value]) => [normalizeReplacementKey(key), value])
|
|
);
|
|
const fallback = rule.replacement;
|
|
return (match: string) => {
|
|
const normalized = normalizeReplacementKey(match);
|
|
if (normalizedMap.has(normalized)) return normalizedMap.get(normalized) ?? "";
|
|
return fallback ?? match;
|
|
};
|
|
}
|
|
|
|
function getRuleFlags(rule: FileRule): string {
|
|
return rule.flags ?? "gi";
|
|
}
|
|
|
|
function getModuleDir(): string {
|
|
const anchors = [process.cwd()];
|
|
const argv1 = process.argv[1];
|
|
if (typeof argv1 === "string" && argv1) anchors.push(path.dirname(argv1));
|
|
const rel = path.join("open-sse", "services", "compression");
|
|
for (const anchor of anchors) {
|
|
let dir = path.resolve(anchor);
|
|
for (let i = 0; i <= 8; i++) {
|
|
if (fs.existsSync(path.join(dir, rel))) return dir;
|
|
const parent = path.dirname(dir);
|
|
if (parent === dir) break;
|
|
dir = parent;
|
|
}
|
|
}
|
|
return path.join(os.homedir(), ".omniroute");
|
|
}
|
|
|
|
function getRulesDir(): string {
|
|
if (rulesDirCache) return rulesDirCache;
|
|
const root = getModuleDir();
|
|
const candidates = [
|
|
path.join(root, "open-sse", "services", "compression", "rules"),
|
|
path.join(root, "app", "open-sse", "services", "compression", "rules"),
|
|
];
|
|
rulesDirCache =
|
|
candidates.find((candidate, index) => {
|
|
return candidates.indexOf(candidate) === index && fs.existsSync(candidate);
|
|
}) ?? candidates[0];
|
|
return rulesDirCache;
|
|
}
|
|
|
|
function compileRule(rule: FileRule, source: string): CavemanRule {
|
|
try {
|
|
const flags = getRuleFlags(rule);
|
|
return {
|
|
name: rule.name,
|
|
pattern: new RegExp(rule.pattern, flags),
|
|
replacement: compileReplacement(rule),
|
|
context: rule.context ?? "all",
|
|
category: rule.category ?? "filler",
|
|
minIntensity: rule.minIntensity ?? "lite",
|
|
description: rule.description,
|
|
};
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
throw new Error(`Invalid Caveman rule pattern in ${source}:${rule.name}: ${message}`);
|
|
}
|
|
}
|
|
|
|
export function validateRulePack(pack: unknown): { valid: boolean; errors: string[] } {
|
|
const errors: string[] = [];
|
|
if (!pack || typeof pack !== "object") {
|
|
return { valid: false, errors: ["Rule pack must be an object"] };
|
|
}
|
|
|
|
const value = pack as Partial<RulePack>;
|
|
if (typeof value.language !== "string" || !value.language.trim()) {
|
|
errors.push("language must be a non-empty string");
|
|
}
|
|
if (typeof value.category !== "string" || !value.category.trim()) {
|
|
errors.push("category must be a non-empty string");
|
|
}
|
|
if (!Array.isArray(value.rules)) {
|
|
errors.push("rules must be an array");
|
|
} else {
|
|
value.rules.forEach((rule, index) => {
|
|
if (!rule || typeof rule !== "object") {
|
|
errors.push(`rules[${index}] must be an object`);
|
|
return;
|
|
}
|
|
const entry = rule as Partial<FileRule>;
|
|
const flags = typeof entry.flags === "string" ? entry.flags : "gi";
|
|
if (typeof entry.name !== "string" || !entry.name.trim()) {
|
|
errors.push(`rules[${index}].name must be a non-empty string`);
|
|
}
|
|
if (typeof entry.pattern !== "string" || !entry.pattern.trim()) {
|
|
errors.push(`rules[${index}].pattern must be a non-empty string`);
|
|
} else {
|
|
try {
|
|
new RegExp(entry.pattern, flags);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
errors.push(`rules[${index}].pattern is invalid: ${message}`);
|
|
}
|
|
}
|
|
if (entry.flags !== undefined && typeof entry.flags !== "string") {
|
|
errors.push(`rules[${index}].flags must be a string`);
|
|
}
|
|
if (entry.replacement !== undefined && typeof entry.replacement !== "string") {
|
|
errors.push(`rules[${index}].replacement must be a string`);
|
|
}
|
|
if (entry.replacementMap !== undefined) {
|
|
if (
|
|
!entry.replacementMap ||
|
|
typeof entry.replacementMap !== "object" ||
|
|
Array.isArray(entry.replacementMap)
|
|
) {
|
|
errors.push(`rules[${index}].replacementMap must be an object`);
|
|
} else {
|
|
Object.entries(entry.replacementMap).forEach(([key, replacement]) => {
|
|
if (!key.trim()) {
|
|
errors.push(`rules[${index}].replacementMap contains an empty key`);
|
|
}
|
|
if (typeof replacement !== "string") {
|
|
errors.push(`rules[${index}].replacementMap.${key} must be a string`);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
if (typeof entry.replacement !== "string" && entry.replacementMap === undefined) {
|
|
errors.push(`rules[${index}] must define replacement or replacementMap`);
|
|
}
|
|
if (entry.context !== undefined && !VALID_CONTEXTS.has(entry.context)) {
|
|
errors.push(`rules[${index}].context is invalid`);
|
|
}
|
|
if (entry.category !== undefined && !VALID_CATEGORIES.has(entry.category)) {
|
|
errors.push(`rules[${index}].category is invalid`);
|
|
}
|
|
if (entry.minIntensity !== undefined && !VALID_INTENSITIES.has(entry.minIntensity)) {
|
|
errors.push(`rules[${index}].minIntensity is invalid`);
|
|
}
|
|
});
|
|
}
|
|
return { valid: errors.length === 0, errors };
|
|
}
|
|
|
|
function readPack(language: string, category: string): RulePack | null {
|
|
const filename = path.join(getRulesDir(), language, `${category}.json`);
|
|
if (!fs.existsSync(filename)) return null;
|
|
const parsed = JSON.parse(fs.readFileSync(filename, "utf8")) as unknown;
|
|
const validation = validateRulePack(parsed);
|
|
if (!validation.valid) {
|
|
throw new Error(
|
|
`Invalid Caveman rule pack ${language}/${category}: ${validation.errors.join("; ")}`
|
|
);
|
|
}
|
|
return parsed as RulePack;
|
|
}
|
|
|
|
export function loadRulePack(
|
|
language: string,
|
|
category: string,
|
|
options: { refresh?: boolean } = {}
|
|
): CavemanRule[] {
|
|
const key = `${getRulesDir()}:${language}:${category}`;
|
|
if (cache.has(key) && !options.refresh) return cache.get(key) ?? [];
|
|
|
|
const pack = readPack(language, category);
|
|
if (!pack) {
|
|
cache.set(key, []);
|
|
return [];
|
|
}
|
|
|
|
const rules = pack.rules.map((rule) => compileRule(rule, `${language}/${category}`));
|
|
cache.set(key, rules);
|
|
return rules;
|
|
}
|
|
|
|
export function loadAllRulesForLanguage(
|
|
language: string,
|
|
options: { refresh?: boolean } = {}
|
|
): CavemanRule[] {
|
|
const key = `${getRulesDir()}:${language}:*`;
|
|
if (cache.has(key) && !options.refresh) return cache.get(key) ?? [];
|
|
|
|
const languageDir = path.join(getRulesDir(), language);
|
|
if (!fs.existsSync(languageDir)) {
|
|
cache.set(key, []);
|
|
return [];
|
|
}
|
|
|
|
const rules = fs
|
|
.readdirSync(languageDir)
|
|
.filter((entry) => entry.endsWith(".json"))
|
|
.sort()
|
|
.flatMap((entry) => loadRulePack(language, path.basename(entry, ".json"), options));
|
|
|
|
cache.set(key, rules);
|
|
return rules;
|
|
}
|
|
|
|
export function getAvailableLanguagePacks(): RulePackMetadata[] {
|
|
const root = getRulesDir();
|
|
if (!fs.existsSync(root)) return [];
|
|
|
|
return fs
|
|
.readdirSync(root)
|
|
.filter((entry) => fs.statSync(path.join(root, entry)).isDirectory())
|
|
.map((language) => {
|
|
const categories = fs
|
|
.readdirSync(path.join(root, language))
|
|
.filter((entry) => entry.endsWith(".json"))
|
|
.map((entry) => path.basename(entry, ".json"))
|
|
.sort();
|
|
const ruleCount = categories.reduce(
|
|
(count, category) => count + loadRulePack(language, category).length,
|
|
0
|
|
);
|
|
return { language, categories, ruleCount };
|
|
})
|
|
.sort((a, b) => a.language.localeCompare(b.language));
|
|
}
|
|
|
|
export function loadCavemanFileRules(
|
|
language: string,
|
|
options: { refresh?: boolean } = {}
|
|
): CavemanRule[] {
|
|
return loadAllRulesForLanguage(language, options);
|
|
}
|
|
|
|
export function listCavemanRulePacks(): RulePackMetadata[] {
|
|
return getAvailableLanguagePacks();
|
|
}
|