Files
2026-07-13 13:39:12 +08:00

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();
}