/** * cache-stack — 共享配置与日志前缀 * * 单一配置文件 ~/.pi/agent/cache-stack.json,功能子模块共用。 * 节级覆盖:缺省节/字段回退默认值(深合并),数组整体替换。 * */ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; export const EXT_NAME = "cache-stack"; export const CONFIG_PATH = join(homedir(), ".pi", "agent", "cache-stack.json"); /** 常驻工具集:合并语义 = 默认集 ∪ 配置集;disabled 可显式阻止工具激活。 */ export const DEFAULT_ALWAYS_ACTIVE = [ "bash", "read", "write", "edit", "ls", "find", "grep", ]; export interface LazyToolsModelOverride { enabled?: boolean; alwaysActive?: string[]; /** Tools that are registered but must never be active or lazy-activated. */ disabled?: string[]; /** Tools whose active/inactive eligibility is owned by another extension. */ externallyManaged?: string[]; } export interface LazyToolsConfig extends LazyToolsModelOverride { enabled: boolean; alwaysActive: string[]; externallyManaged: string[]; /** Per-model overrides. Patterns match `provider/model-id`, or just model id when no slash is present. */ modelOverrides?: Record; } export interface EffectiveLazyToolsConfig { enabled: boolean; alwaysActive: string[]; disabled: string[]; externallyManaged: string[]; } export interface ModelIdentity { provider: string; id: string; } /** 工具输出限制默认覆盖的工具:bash 保留尾部,其余保留头部。 */ export const DEFAULT_TOOL_OUTPUT_TOOLS = ["bash", "read", "grep", "find", "ls"]; export interface ToolOutputConfig { /** 总开关;false 时工具结果原样通过。 */ enabled: boolean; /** 文本结果 UTF-8 字节上限。 */ maxBytes: number; /** 文本结果行数上限。 */ maxLines: number; /** 应用限制的工具名列表。 */ tools: string[]; } export interface CacheStackConfig { lazyTools: LazyToolsConfig; toolOutput: ToolOutputConfig; } export const DEFAULT_TOOL_OUTPUT: ToolOutputConfig = { enabled: true, maxBytes: 20 * 1024, maxLines: 2000, tools: [...DEFAULT_TOOL_OUTPUT_TOOLS], }; export const DEFAULT_CONFIG: CacheStackConfig = { lazyTools: { enabled: true, alwaysActive: DEFAULT_ALWAYS_ACTIVE, disabled: [], externallyManaged: [], modelOverrides: {}, }, toolOutput: { ...DEFAULT_TOOL_OUTPUT }, }; function isRecord(v: unknown): v is Record { return typeof v === "object" && v !== null && !Array.isArray(v); } /** * 深合并:override 覆盖 base 的标量/数组(整体替换),嵌套对象逐键合并。 * null/undefined 视为"未设置"(保持 base),因此顶层或节级 null 不会炸掉后续访问 * (合并前的平铺写法 {...DEFAULT, ...raw} 对 null spread 同样是安全的)。 * 导出供测试。 */ export function deepMerge(base: unknown, override: unknown): unknown { if (!isRecord(override)) return base; if (!isRecord(base)) return override; const out: Record = { ...base }; for (const [key, value] of Object.entries(override)) { if (value === undefined || value === null) continue; out[key] = isRecord(value) ? deepMerge(out[key], value) : value; } return out; } function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((item) => typeof item === "string"); } /** 仅接受有限安全正整数的字节/行数上限。 */ function isPositiveInteger(value: unknown): value is number { return typeof value === "number" && Number.isSafeInteger(value) && value > 0; } /** * 规范化工具名数组:去空白、去重、丢弃空名。空结果视为无效并回退默认 * (避免空 allowlist 让整个功能静默失效;关闭请用 enabled:false)。 */ function normalizeToolNames(value: unknown): string[] | undefined { if (!isStringArray(value)) return undefined; const names = [...new Set(value.map((name) => name.trim()).filter((name) => name.length > 0))]; return names.length > 0 ? names : undefined; } /** Policy tool-name arrays accept an intentional empty list while still trimming and deduplicating names. */ function normalizePolicyToolNames(value: unknown): string[] | undefined { if (!isStringArray(value)) return undefined; return [...new Set(value.map((name) => name.trim()).filter((name) => name.length > 0))]; } /** * toolOutput 各字段独立回退:非法布尔/数字/工具名数组不影响其他字段。 * 任一超限即在进入模型上下文前截断,字节与行数同时生效。 * 导出供测试。 */ export function normalizeToolOutputConfig(value: unknown): ToolOutputConfig { const source = isRecord(value) ? value : {}; return { enabled: typeof source.enabled === "boolean" ? source.enabled : DEFAULT_CONFIG.toolOutput.enabled, maxBytes: isPositiveInteger(source.maxBytes) ? source.maxBytes : DEFAULT_CONFIG.toolOutput.maxBytes, maxLines: isPositiveInteger(source.maxLines) ? source.maxLines : DEFAULT_CONFIG.toolOutput.maxLines, tools: normalizeToolNames(source.tools) ?? [...DEFAULT_CONFIG.toolOutput.tools], }; } function normalizeModelOverride(value: unknown): LazyToolsModelOverride | undefined { if (!isRecord(value)) return undefined; const override: LazyToolsModelOverride = {}; if (typeof value.enabled === "boolean") override.enabled = value.enabled; if (isStringArray(value.alwaysActive)) override.alwaysActive = [...value.alwaysActive]; if (isStringArray(value.disabled)) override.disabled = [...value.disabled]; const externallyManaged = normalizePolicyToolNames(value.externallyManaged); if (externallyManaged !== undefined) override.externallyManaged = externallyManaged; return override; } /** * Keep malformed user fields from changing the extension's fail-open behavior. * JSON parsing errors are handled by loadConfig; invalid field types fall back * to the corresponding defaults and invalid model rules are ignored. */ function normalizeLazyToolsConfig(value: unknown): LazyToolsConfig { const source = isRecord(value) ? value : {}; const modelOverrides: Record = {}; if (isRecord(source.modelOverrides)) { for (const [pattern, override] of Object.entries(source.modelOverrides)) { const normalized = normalizeModelOverride(override); if (normalized) modelOverrides[pattern] = normalized; } } return { enabled: typeof source.enabled === "boolean" ? source.enabled : DEFAULT_CONFIG.lazyTools.enabled, alwaysActive: isStringArray(source.alwaysActive) ? [...source.alwaysActive] : [...DEFAULT_CONFIG.lazyTools.alwaysActive], disabled: isStringArray(source.disabled) ? [...source.disabled] : [], externallyManaged: normalizePolicyToolNames(source.externallyManaged) ?? [...DEFAULT_CONFIG.lazyTools.externallyManaged], modelOverrides, }; } function globToRegExp(pattern: string): RegExp { let source = "^"; for (const char of pattern) { if (char === "*") source += ".*"; else if (char === "?") source += "."; else source += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } return new RegExp(`${source}$`, "i"); } function matchesModel(pattern: string, model: ModelIdentity): boolean { const target = pattern.includes("/") ? `${model.provider}/${model.id}` : model.id; return globToRegExp(pattern).test(target); } /** * Resolve the effective lazy policy for one model. Matching overrides are * applied in JSON declaration order, so later matches win per field. */ export function resolveLazyToolsConfig( config: LazyToolsConfig, model?: ModelIdentity, ): EffectiveLazyToolsConfig { const base = normalizeLazyToolsConfig(config); let resolved: EffectiveLazyToolsConfig = { enabled: base.enabled ?? DEFAULT_CONFIG.lazyTools.enabled, alwaysActive: base.alwaysActive ?? [...DEFAULT_CONFIG.lazyTools.alwaysActive], disabled: base.disabled ?? [], externallyManaged: base.externallyManaged ?? [], }; if (!model) return resolved; for (const [pattern, override] of Object.entries(base.modelOverrides ?? {})) { if (!matchesModel(pattern, model)) continue; resolved = deepMerge(resolved, override) as EffectiveLazyToolsConfig; } return resolved; } /** 读取配置;文件缺失/损坏时回退默认值(不抛异常,不阻塞扩展加载)。 */ export function loadConfig(): CacheStackConfig { try { if (existsSync(CONFIG_PATH)) { const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as unknown; const merged = deepMerge(DEFAULT_CONFIG, raw) as Record; return { lazyTools: normalizeLazyToolsConfig(merged.lazyTools), toolOutput: normalizeToolOutputConfig(merged.toolOutput), }; } } catch (err) { console.error(`[${EXT_NAME}] failed to load ${CONFIG_PATH}:`, err); } return { lazyTools: normalizeLazyToolsConfig(DEFAULT_CONFIG.lazyTools), toolOutput: normalizeToolOutputConfig(DEFAULT_CONFIG.toolOutput), }; }