evolclaw 3.1.10 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/init.js CHANGED
@@ -4,7 +4,9 @@ import readline from 'readline';
4
4
  import { resolvePaths, ensureDataDirs } from '../paths.js';
5
5
  import { commandExists } from '../utils/cross-platform.js';
6
6
  import { scanInstances } from '../utils/instance-registry.js';
7
- import { saveDefaultsSafe, loadAllAgents } from '../config-store.js';
7
+ import { saveDefaultsSafe, loadAllAgents, migrateProcessConfigIfNeeded } from '../config-store.js';
8
+ import { loadEvolclawConfig, saveEvolclawConfig } from '../evolclaw-config.js';
9
+ import { generateControlAid } from '../aun/aid/control-aid.js';
8
10
  import { isCodexSdkAvailable } from '../agents/codex-runner.js';
9
11
  // ==================== Helpers ====================
10
12
  function ask(rl, question) {
@@ -36,10 +38,35 @@ function buildDefaults(chosen, available, projectsDefaultPath) {
36
38
  function writeDefaults(chosen, available, projectsDefaultPath) {
37
39
  saveDefaultsSafe(buildDefaults(chosen, available, projectsDefaultPath));
38
40
  }
41
+ /** 启动门禁判定:缺控制 AID 且处于交互式终端时,应进 init 向导补全。
42
+ * 非 TTY(restart-monitor/systemd/管道)即使缺 aid 也不进 init(无法交互),由 daemon 侧 warn 兜底。 */
43
+ export function needsControlAidInit(aid, isTty) {
44
+ return !aid && isTty;
45
+ }
46
+ /** 解析用户输入的 owner AID 列表:按空白/逗号分隔,去空、去重、按 isValid 分流。
47
+ * 空输入 → valid:[](视为跳过)。 */
48
+ export function parseOwnerAids(raw, isValid) {
49
+ const tokens = raw.split(/[\s,]+/).map(t => t.trim()).filter(Boolean);
50
+ const valid = [];
51
+ const invalid = [];
52
+ for (const t of tokens) {
53
+ if (isValid(t)) {
54
+ if (!valid.includes(t))
55
+ valid.push(t);
56
+ }
57
+ else {
58
+ invalid.push(t);
59
+ }
60
+ }
61
+ return { valid, invalid };
62
+ }
39
63
  // ==================== Main ====================
40
64
  export async function cmdInit(options) {
41
65
  const p = resolvePaths();
42
66
  ensureDataDirs();
67
+ // config.json → evolclaw.json:init 路径也可能先于 daemon 触发 AID 生成(走 getAidStore),
68
+ // 须在任何 getAidStore 之前迁移 encryptionSeed。
69
+ migrateProcessConfigIfNeeded();
43
70
  // ── 1. 单进程互斥 ──
44
71
  const aliveMains = scanInstances().mains.filter(m => m.alive);
45
72
  if (aliveMains.length > 0) {
@@ -66,110 +93,163 @@ export async function cmdInit(options) {
66
93
  // ── 3. 非交互式分支 ──
67
94
  if (options?.nonInteractive) {
68
95
  if (exists && !options.force) {
69
- console.log(`❌ 配置已存在: ${defaultsPath}(加 --force 可覆盖)`);
70
- return;
96
+ // 配置已存在且未 --force:不重写 defaults,但仍落到共享 tail(幂等补生成控制 AID)
97
+ console.log(`配置已存在: ${defaultsPath}(加 --force 可覆盖)`);
71
98
  }
72
- let chosen;
73
- if (options.baseagent) {
74
- if (!BASEAGENT_CANDIDATES.includes(options.baseagent)) {
75
- console.log(`❌ 无效 baseagent: ${options.baseagent}(可选: ${BASEAGENT_CANDIDATES.join('/')})`);
76
- return;
99
+ else {
100
+ let chosen;
101
+ if (options.baseagent) {
102
+ if (!BASEAGENT_CANDIDATES.includes(options.baseagent)) {
103
+ console.log(`❌ 无效 baseagent: ${options.baseagent}(可选: ${BASEAGENT_CANDIDATES.join('/')})`);
104
+ return; // 硬错误:不落 tail
105
+ }
106
+ if (!available.includes(options.baseagent)) {
107
+ console.log(`❌ ${options.baseagent} 当前环境不可用(可用: ${available.join('/')})`);
108
+ return; // 硬错误:不落 tail
109
+ }
110
+ chosen = options.baseagent;
77
111
  }
78
- if (!available.includes(options.baseagent)) {
79
- console.log(`❌ ${options.baseagent} 当前环境不可用(可用: ${available.join('/')})`);
80
- return;
112
+ else {
113
+ chosen = pickDefault(available);
81
114
  }
82
- chosen = options.baseagent;
83
- }
84
- else {
85
- chosen = pickDefault(available);
86
- }
87
- writeDefaults(chosen, available);
88
- console.log(`✓ 已${exists ? '覆盖' : '创建'}: ${defaultsPath}`);
89
- console.log(` active_baseagent: ${chosen}`);
90
- const { agents } = loadAllAgents();
91
- if (agents.length === 0) {
92
- console.log('\n提示:尚无 agent,运行以下命令创建:');
93
- console.log(' evolclaw agent new <aid>.agentid.pub');
115
+ writeDefaults(chosen, available);
116
+ console.log(`✓ 已${exists ? '覆盖' : '创建'}: ${defaultsPath}`);
117
+ console.log(` active_baseagent: ${chosen}`);
94
118
  }
95
- return;
119
+ // 落到共享 tail(不 return
96
120
  }
97
- // ── 4. 交互式分支 ──
98
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
99
- async function askBaseagent() {
100
- const defaultBa = pickDefault(available);
101
- if (available.length === 1) {
102
- console.log(` baseagent: ${defaultBa}`);
103
- return defaultBa;
104
- }
105
- let chosen = null;
106
- while (chosen === null) {
107
- const input = (await ask(rl, `默认 baseagent (${available.join('/')}) [${defaultBa}]: `)).trim() || defaultBa;
108
- if (!BASEAGENT_CANDIDATES.includes(input)) {
109
- console.log(` 无效选择,可选: ${BASEAGENT_CANDIDATES.join('/')}`);
110
- continue;
121
+ else {
122
+ // ── 4. 交互式分支(rl 生命周期封装在内部函数,tail 不引用 rl)──
123
+ await runInteractive();
124
+ }
125
+ // ── 共享 tail(单一出口):提示创建 agent + 生成控制 AID ──
126
+ await initTail();
127
+ // ── 内部函数 ──
128
+ async function runInteractive() {
129
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
130
+ async function askBaseagent() {
131
+ const defaultBa = pickDefault(available);
132
+ if (available.length === 1) {
133
+ console.log(` baseagent: ${defaultBa}`);
134
+ return defaultBa;
111
135
  }
112
- if (!available.includes(input)) {
113
- console.log(` ${input} 当前环境不可用(可用: ${available.join('/')})`);
114
- continue;
136
+ let chosen = null;
137
+ while (chosen === null) {
138
+ const input = (await ask(rl, `默认 baseagent (${available.join('/')}) [${defaultBa}]: `)).trim() || defaultBa;
139
+ if (!BASEAGENT_CANDIDATES.includes(input)) {
140
+ console.log(` 无效选择,可选: ${BASEAGENT_CANDIDATES.join('/')}`);
141
+ continue;
142
+ }
143
+ if (!available.includes(input)) {
144
+ console.log(` ${input} 当前环境不可用(可用: ${available.join('/')})`);
145
+ continue;
146
+ }
147
+ chosen = input;
115
148
  }
116
- chosen = input;
149
+ return chosen;
117
150
  }
118
- return chosen;
119
- }
120
- async function askProjectsDefaultPath() {
121
- const defaultDir = path.join(p.root, 'projects', 'default');
122
- const input = (await ask(rl, `项目默认目录 [${defaultDir}]: `)).trim();
123
- const resolved = input || defaultDir;
124
- if (!path.isAbsolute(resolved)) {
125
- console.log(' ⚠ 需要绝对路径,已跳过');
126
- return undefined;
127
- }
128
- if (!fs.existsSync(resolved)) {
129
- const create = (await ask(rl, ` 目录不存在,是否创建?[Y/n]: `)).trim().toLowerCase();
130
- if (create === '' || create === 'y' || create === 'yes') {
131
- fs.mkdirSync(resolved, { recursive: true });
132
- console.log(` ✓ 已创建 ${resolved}`);
133
- }
134
- else {
151
+ async function askProjectsDefaultPath() {
152
+ const defaultDir = path.join(p.root, 'projects', 'default');
153
+ const input = (await ask(rl, `项目默认目录 [${defaultDir}]: `)).trim();
154
+ const resolved = input || defaultDir;
155
+ if (!path.isAbsolute(resolved)) {
156
+ console.log(' ⚠ 需要绝对路径,已跳过');
135
157
  return undefined;
136
158
  }
159
+ if (!fs.existsSync(resolved)) {
160
+ const create = (await ask(rl, ` 目录不存在,是否创建?[Y/n]: `)).trim().toLowerCase();
161
+ if (create === '' || create === 'y' || create === 'yes') {
162
+ fs.mkdirSync(resolved, { recursive: true });
163
+ console.log(` ✓ 已创建 ${resolved}`);
164
+ }
165
+ else {
166
+ return undefined;
167
+ }
168
+ }
169
+ return resolved;
137
170
  }
138
- return resolved;
139
- }
140
- try {
141
- if (exists) {
142
- const ans = (await ask(rl, `配置文件已存在: ${defaultsPath}\n 是否覆盖?[y/N] `)).trim().toLowerCase();
143
- if (ans === 'y' || ans === 'yes') {
171
+ try {
172
+ if (exists) {
173
+ const ans = (await ask(rl, `配置文件已存在: ${defaultsPath}\n 是否覆盖?[y/N] `)).trim().toLowerCase();
174
+ if (ans === 'y' || ans === 'yes') {
175
+ const chosen = await askBaseagent();
176
+ const projectsDefaultPath = await askProjectsDefaultPath();
177
+ writeDefaults(chosen, available, projectsDefaultPath);
178
+ console.log(`\n✓ 已覆盖: ${defaultsPath}`);
179
+ console.log(` active_baseagent: ${chosen}\n`);
180
+ }
181
+ else {
182
+ console.log(' 已跳过(保留现有配置)\n');
183
+ }
184
+ }
185
+ else {
144
186
  const chosen = await askBaseagent();
145
187
  const projectsDefaultPath = await askProjectsDefaultPath();
146
188
  writeDefaults(chosen, available, projectsDefaultPath);
147
- console.log(`\n✓ 已覆盖: ${defaultsPath}`);
189
+ console.log(`\n✓ 已创建: ${defaultsPath}`);
148
190
  console.log(` active_baseagent: ${chosen}\n`);
149
191
  }
150
- else {
151
- console.log(' 已跳过(保留现有配置)\n');
192
+ }
193
+ finally {
194
+ try {
195
+ rl.close();
152
196
  }
197
+ catch { /* ignore */ }
153
198
  }
154
- else {
155
- const chosen = await askBaseagent();
156
- const projectsDefaultPath = await askProjectsDefaultPath();
157
- writeDefaults(chosen, available, projectsDefaultPath);
158
- console.log(`\n✓ 已创建: ${defaultsPath}`);
159
- console.log(` active_baseagent: ${chosen}\n`);
199
+ }
200
+ }
201
+ /** 补全控制 AID + owners(可单独调用,不走 baseagent 向导)。 */
202
+ export async function initTail() {
203
+ // 提示创建 agent(两分支汇合后执行一次)
204
+ const { agents } = loadAllAgents();
205
+ if (agents.length === 0) {
206
+ console.log('\n提示:尚无 agent,运行以下命令创建:');
207
+ console.log(' evolclaw agent new <aid>.agentid.pub');
208
+ }
209
+ // 控制 AID:daemon 进程身份。缺失则生成并写回 evolclaw.json(幂等:已存在则跳过)。
210
+ const evc = loadEvolclawConfig();
211
+ if (evc.aid) {
212
+ console.log(`✓ 控制 AID 已存在: ${evc.aid}`);
213
+ }
214
+ else {
215
+ try {
216
+ const { aid } = await generateControlAid();
217
+ saveEvolclawConfig({ ...evc, $schema_version: evc.$schema_version ?? 1, aid });
218
+ console.log(`✓ 已生成控制 AID: ${aid}`);
160
219
  }
161
- // ── 5. 提示创建 agent ──
162
- const { agents } = loadAllAgents();
163
- if (agents.length === 0) {
164
- console.log('提示:尚无 agent,运行以下命令创建:');
165
- console.log(' evolclaw agent new <aid>.agentid.pub');
220
+ catch (e) {
221
+ console.error(`⚠️ 控制 AID 生成失败(Gateway 不可达?联网后重跑 evolclaw init 补全): ${e?.message || e}`);
166
222
  }
167
223
  }
168
- finally {
224
+ // 配置进程级管理者(owners):仅交互式 + aid 已配置 + owners 未配置时询问
225
+ const evcForOwners = loadEvolclawConfig();
226
+ if (process.stdin.isTTY && evcForOwners.aid && (!evcForOwners.owners || evcForOwners.owners.length === 0)) {
227
+ const { isValidAid } = await import('../aun/aid/index.js');
228
+ const rlOwners = readline.createInterface({ input: process.stdin, output: process.stdout });
169
229
  try {
170
- rl.close();
230
+ const raw = (await ask(rlOwners, '\n请输入 EvolClaw 管理者 AID: ')).trim();
231
+ if (raw) {
232
+ const { valid, invalid } = parseOwnerAids(raw, isValidAid);
233
+ if (invalid.length > 0)
234
+ console.log(` ⚠ 跳过非法 AID: ${invalid.join(', ')}`);
235
+ if (valid.length > 0) {
236
+ saveEvolclawConfig({ ...loadEvolclawConfig(), owners: valid });
237
+ console.log(` ✓ 已配置管理者: ${valid.join(', ')}`);
238
+ }
239
+ else {
240
+ console.log(' 未输入合法 AID,已跳过 owners 配置');
241
+ }
242
+ }
243
+ else {
244
+ console.log(' 已跳过 owners 配置(可日后编辑 evolclaw.json 或重跑 evolclaw init)');
245
+ }
246
+ }
247
+ finally {
248
+ try {
249
+ rlOwners.close();
250
+ }
251
+ catch { /* ignore */ }
171
252
  }
172
- catch { /* ignore */ }
173
253
  }
174
254
  }
175
255
  /**
@@ -17,6 +17,7 @@ import fs from 'fs';
17
17
  import path from 'path';
18
18
  import { resolvePaths, agentConfig as agentConfigPath, agentDir, } from './paths.js';
19
19
  import { atomicReadJson, atomicWriteJson } from './utils/atomic-write.js';
20
+ import * as evolclawConfigModule from './evolclaw-config.js';
20
21
  import { checkAgentDir, isValidAid } from './aun/aid/validation.js';
21
22
  import { isValidChannelName } from './core/channel-loader.js';
22
23
  import { CONFIG_SCHEMA_VERSION } from './types.js';
@@ -122,14 +123,42 @@ export function saveDefaultsSafe(patch) {
122
123
  : { $schema_version: CONFIG_SCHEMA_VERSION, ...patch };
123
124
  atomicWriteJson(p, merged);
124
125
  }
125
- export function loadProcessConfig() {
126
- const raw = atomicReadJson(resolvePaths().processConfig);
126
+ // ── 进程配置迁移(旧 {root}/config.json ProcessConfig → evolclaw.json)──────
127
+ //
128
+ // ProcessConfig 类型 + loadProcessConfig/saveProcessConfig 已废弃并删除:
129
+ // 唯一有效字段 aun.encryptionSeed 已迁入 evolclaw.json(见 migrateProcessConfigIfNeeded),
130
+ // 读取源切到 loadEvolclawConfig(store.ts)。log / aun.gateway / aun.keystorePath 是死字段。
131
+ /**
132
+ * 一次性迁移:{root}/config.json(旧 ProcessConfig)→ {root}/evolclaw.json。
133
+ * - 仅搬运 aun.encryptionSeed(逐字节原样,含 null);log / aun.gateway / aun.keystorePath 是死字段,丢弃。
134
+ * - 合并写入(不覆盖 evolclaw.json 已有字段如 aid)。
135
+ * - 完成后归档 config.json → config.json.migrated(保留备份,不直接删)。
136
+ *
137
+ * ⚠️ encryptionSeed 是 AID 私钥的加密种子,迁移前后 getAidStore 拿到的 seed 必须逐字节一致,
138
+ * 否则所有已注册 AID 私钥解不开。这里只搬运不改值(含 null)。
139
+ */
140
+ export function migrateProcessConfigIfNeeded() {
141
+ const p = resolvePaths();
142
+ const oldPath = p.processConfig; // {root}/config.json
143
+ const raw = atomicReadJson(oldPath);
127
144
  if (raw === null)
128
- return {};
129
- return expandEnvRefs(raw);
130
- }
131
- export function saveProcessConfig(value) {
132
- atomicWriteJson(resolvePaths().processConfig, value);
145
+ return; // 不存在 → no-op
146
+ const { loadEvolclawConfig, saveEvolclawConfig } = evolclawConfigModule;
147
+ const evc = loadEvolclawConfig();
148
+ // 仅当旧文件确实带 aun.encryptionSeed 字段时才搬(hasOwnProperty,保 null 语义)
149
+ const didMigrateSeed = !!(raw.aun && Object.prototype.hasOwnProperty.call(raw.aun, 'encryptionSeed'));
150
+ if (didMigrateSeed) {
151
+ evc.aun = { ...(evc.aun ?? {}), encryptionSeed: raw.aun.encryptionSeed };
152
+ }
153
+ evc.$schema_version = evc.$schema_version ?? 1;
154
+ saveEvolclawConfig(evc);
155
+ // 归档旧文件(不删,留备份)
156
+ try {
157
+ fs.renameSync(oldPath, oldPath + '.migrated');
158
+ }
159
+ catch { /* ignore */ }
160
+ const what = didMigrateSeed ? 'aun.encryptionSeed 已搬运' : 'aun.encryptionSeed 不存在(无需搬运)';
161
+ logger.info(`[migrate] config.json → evolclaw.json (${what},config.json 已归档为 .migrated)`);
133
162
  }
134
163
  // ── 自动迁移 ───────────────────────────────────────────────────────────
135
164
  /**
@@ -492,6 +521,8 @@ export function mergeForAgent(agent, defaults) {
492
521
  debounce: agent.debounce ?? d.debounce,
493
522
  debug: deepMergeBlocks(d.debug, agent.debug),
494
523
  enable_rich_content: agent.enable_rich_content ?? d.enable_rich_content,
524
+ dispatch: agent.dispatch,
525
+ observable: agent.observable,
495
526
  };
496
527
  return merged;
497
528
  }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * FileCache —— 统一的文件缓存(daemon-only)。
3
+ *
4
+ * daemon 每条消息处理时读大量文件(manifest、fragment、md、working memory、
5
+ * 关系级 preferences 等)。本类统一缓存"文件 → 解析后内容",按策略门控变化检查。
6
+ *
7
+ * 设计与边界详见 docs/file-cache-design.md:
8
+ * - 不接管 EvolAgent 的 merged config(agent/defaults 由 EvolAgent 持有权威副本)。
9
+ * - daemon-only:CLI 短命进程仍直读最新盘值,不用本缓存。
10
+ * - 只缓存"文件 → 解析后内容",不缓存按 vars 渲染后的结果。
11
+ *
12
+ * 策略(reload/重启永远全量失效,无视策略;策略只决定"平时每次读怎么检查"):
13
+ * - on-reload:平时不检查,直接用缓存(kits 文件、persona)。靠 reload/重启刷新。
14
+ * - manual:同 on-reload,额外支持显式 invalidate(file) 单刷。
15
+ * - mtime:每次读 statSync 门控 mtime,变了自动重读(working.md、preferences.json)。
16
+ *
17
+ * 容量:项数可无界增长的组(如 relation-prefs,每 peer 一文件)设 LRU 硬上限
18
+ * (见 GROUP_CAPS),命中/写入移到末尾、超限驱逐同组最旧项;无 timer。
19
+ * 项数固定的组(kits、per-agent 身份层)不设限。
20
+ *
21
+ * 监控:内置命中/读盘/驱逐/失效计数(总计 + 按 group + 按 policy),stats() 导出
22
+ * 快照供 watch web 的 Cache 页展示(经 IPC 'cache-stats')。计数是整数自增,热路径无感。
23
+ */
24
+ import fs from 'fs';
25
+ const GROUP_NONE = '(none)'; // group 未指定时的归类键
26
+ function newCounters() {
27
+ return { gets: 0, hits: 0, misses: 0, statChecks: 0, reReads: 0, evictions: 0, invalidations: 0 };
28
+ }
29
+ /**
30
+ * 按组的容量上限(LRU)。只有"项数可无界增长"的组才设限——典型是关系级
31
+ * preferences,每个 peer 一个文件,daemon 长跑接触的 peer 越来越多。
32
+ * kits(项数随包固定)、per-agent 身份层(agent 数量有限)不设限,不在此表即无限。
33
+ * 命中/写入都把 key 移到 Map 末尾(Map 保留插入序 → 头部即最久未用),
34
+ * 超限时从头部驱逐同组最旧项。无 timer,确定性 bound。
35
+ */
36
+ const GROUP_CAPS = {
37
+ 'relation-prefs': 512,
38
+ };
39
+ export class FileCache {
40
+ cache = new Map();
41
+ // 监控计数器(总计 + 按 group + 按 policy)。group 未指定归入 GROUP_NONE。
42
+ since = Date.now();
43
+ totals = newCounters();
44
+ byGroup = new Map();
45
+ byPolicy = {
46
+ 'on-reload': newCounters(), 'manual': newCounters(), 'mtime': newCounters(),
47
+ };
48
+ /** 取(或建)某 group 的计数器。 */
49
+ groupCounters(group) {
50
+ const key = group ?? GROUP_NONE;
51
+ let c = this.byGroup.get(key);
52
+ if (!c) {
53
+ c = newCounters();
54
+ this.byGroup.set(key, c);
55
+ }
56
+ return c;
57
+ }
58
+ /** 同一事件按 总计/group/policy 三维各加一次。delta 缺省 1。 */
59
+ bump(group, policy, field, delta = 1) {
60
+ this.totals[field] += delta;
61
+ this.groupCounters(group)[field] += delta;
62
+ this.byPolicy[policy][field] += delta;
63
+ }
64
+ /**
65
+ * 读取并缓存文件。loader 把原始内容(UTF-8 字符串;文件不存在时为 null)转成目标值。
66
+ * 同一 file 的 policy/group 以首次注册为准。
67
+ * opts.read 可注入自定义读取器(如 atomicRead 保留崩溃恢复);缺省 readFileOrNull。
68
+ */
69
+ get(file, loader, opts) {
70
+ const existing = this.cache.get(file);
71
+ const read = opts.read ?? readFileOrNull;
72
+ this.bump(opts.group, opts.policy, 'gets');
73
+ if (opts.policy === 'mtime') {
74
+ const mtimeMs = statMtime(file);
75
+ this.bump(opts.group, opts.policy, 'statChecks');
76
+ if (existing && existing.mtimeMs === mtimeMs) {
77
+ this.bump(opts.group, opts.policy, 'hits');
78
+ this.touch(file, existing);
79
+ return existing.value;
80
+ }
81
+ // 已有条目但 mtime 变了 → 带外改后重读(区别于首次 miss)
82
+ if (existing)
83
+ this.bump(opts.group, opts.policy, 'reReads');
84
+ this.bump(opts.group, opts.policy, 'misses');
85
+ const raw = read(file);
86
+ const value = loader(raw);
87
+ this.store(file, { policy: 'mtime', group: opts.group, mtimeMs, value, bytes: approxBytes(raw) });
88
+ return value;
89
+ }
90
+ // on-reload / manual:命中即用,不检查文件
91
+ if (existing) {
92
+ this.bump(opts.group, opts.policy, 'hits');
93
+ this.touch(file, existing);
94
+ return existing.value;
95
+ }
96
+ this.bump(opts.group, opts.policy, 'misses');
97
+ const raw = read(file);
98
+ const value = loader(raw);
99
+ this.store(file, { policy: opts.policy, group: opts.group, value, bytes: approxBytes(raw) });
100
+ return value;
101
+ }
102
+ /** 命中时把 key 移到 Map 末尾,维持 LRU 顺序(仅对设了容量上限的组有意义)。 */
103
+ touch(file, entry) {
104
+ if (entry.group === undefined || GROUP_CAPS[entry.group] === undefined)
105
+ return;
106
+ this.cache.delete(file);
107
+ this.cache.set(file, entry);
108
+ }
109
+ /** 写入并对设限组做 LRU 驱逐(踢同组最久未用项)。 */
110
+ store(file, entry) {
111
+ this.cache.delete(file); // 确保重写时移到末尾
112
+ this.cache.set(file, entry);
113
+ const cap = entry.group !== undefined ? GROUP_CAPS[entry.group] : undefined;
114
+ if (cap === undefined)
115
+ return;
116
+ // Map 按插入序遍历,头部即最久未用;驱逐同组超出容量的最旧项。
117
+ let count = 0;
118
+ for (const e of this.cache.values())
119
+ if (e.group === entry.group)
120
+ count++;
121
+ if (count <= cap)
122
+ return;
123
+ for (const [k, e] of this.cache) {
124
+ if (count <= cap)
125
+ break;
126
+ if (e.group === entry.group) {
127
+ this.cache.delete(k);
128
+ count--;
129
+ this.bump(e.group, e.policy, 'evictions');
130
+ }
131
+ }
132
+ }
133
+ /** 读纯文本的便捷封装(文件不存在返回 null)。 */
134
+ getText(file, opts) {
135
+ return this.get(file, (raw) => raw, opts);
136
+ }
137
+ /** 单文件失效(manual 策略单刷 / 精确失效)。 */
138
+ invalidate(file) {
139
+ const entry = this.cache.get(file);
140
+ if (this.cache.delete(file) && entry)
141
+ this.bump(entry.group, entry.policy, 'invalidations');
142
+ }
143
+ /** 按组失效(reload 钩子失效一组,如 'kits' / 'agent-files:<aid>')。 */
144
+ invalidateGroup(group) {
145
+ for (const [file, entry] of this.cache) {
146
+ if (entry.group === group) {
147
+ this.cache.delete(file);
148
+ this.bump(entry.group, entry.policy, 'invalidations');
149
+ }
150
+ }
151
+ }
152
+ /** 全量失效(reload / 升级兜底)。 */
153
+ invalidateAll() {
154
+ for (const entry of this.cache.values())
155
+ this.bump(entry.group, entry.policy, 'invalidations');
156
+ this.cache.clear();
157
+ }
158
+ /** 当前缓存条目数(诊断用)。 */
159
+ size() {
160
+ return this.cache.size;
161
+ }
162
+ /**
163
+ * 运行时统计快照(监控用)。累计计数 O(1) 取出;占用(size/内存/容量)即时遍历
164
+ * 当前 entries 算出——条目数有限(kits 固定、relation-prefs 有 LRU 上限),便宜。
165
+ */
166
+ stats() {
167
+ const occupancy = {};
168
+ for (const entry of this.cache.values()) {
169
+ const key = entry.group ?? GROUP_NONE;
170
+ let occ = occupancy[key];
171
+ if (!occ) {
172
+ occ = { size: 0, bytes: 0, cap: GROUP_CAPS[key] ?? null };
173
+ occupancy[key] = occ;
174
+ }
175
+ occ.size++;
176
+ occ.bytes += entry.bytes;
177
+ }
178
+ const byGroup = {};
179
+ for (const [k, c] of this.byGroup)
180
+ byGroup[k] = { ...c };
181
+ return {
182
+ since: this.since,
183
+ size: this.cache.size,
184
+ totals: { ...this.totals },
185
+ byGroup,
186
+ byPolicy: {
187
+ 'on-reload': { ...this.byPolicy['on-reload'] },
188
+ 'manual': { ...this.byPolicy['manual'] },
189
+ 'mtime': { ...this.byPolicy['mtime'] },
190
+ },
191
+ occupancy,
192
+ };
193
+ }
194
+ }
195
+ function statMtime(file) {
196
+ try {
197
+ return fs.statSync(file).mtimeMs;
198
+ }
199
+ catch {
200
+ return null; // 文件不存在
201
+ }
202
+ }
203
+ function readFileOrNull(file) {
204
+ try {
205
+ return fs.readFileSync(file, 'utf-8');
206
+ }
207
+ catch {
208
+ return null;
209
+ }
210
+ }
211
+ /** 缓存值内存占用近似:用读入的原始字符串长度(字节级近似),null 记 0。 */
212
+ function approxBytes(raw) {
213
+ return raw === null ? 0 : raw.length;
214
+ }
215
+ /** daemon 单例。CLI 进程不应使用本实例。 */
216
+ export const fileCache = new FileCache();