@yottameta/yotta-memory 0.17.4 → 0.18.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.
@@ -25,9 +25,9 @@ const net = require('net');
25
25
  const child_process = require('child_process');
26
26
  const { AsyncLocalStorage } = require('async_hooks');
27
27
 
28
- const VERSION = '0.17.4';
29
- const CLI_VALUE_OPTS = new Set(['--type', '--limit', '--days', '--out', '--owner', '--agent', '--agent-id', '--agent-key', '--agent-key-file', '--plugin-data', '--threshold', '--scope', '--host', '--port', '--dir', '--name', '--user', '--relationship', '--source', '--weight', '--budget', '--password', '--new-password', '--recovery-key', '--recovery-key-out', '--reason', '--merge', '--model', '--subject', '--embedding', '--focus', '--embedding-timeout', '--min-age', '--min-idle', '--max-utility', '--min-group', '--period', '--to', '--id', '--time', '--tools', '--mcp-config', '--skill-dir', '--year', '--evalset', '--k', '--seed', '--bootstrap', '--gate', '--against', '--template', '--path']);
30
- const CLI_FLAG_OPTS = new Set(['--project', '--all', '--unsafe', '--no-auth', '--stdio', '--onstart', '--from-current', '--restart', '--force', '--attach', '--allow-same-volume', '--verify', '--no-hint', '--encrypt', '--no-encrypt', '--password-stdin', '--json', '--manual', '--skip-schedule', '--useful', '--useless', '--undo', '--dry-run', '--apply', '--purge', '--dedup', '--batches', '--explain', '--semantic', '--runtime', '--ablate', '--timing', '--baseline', '--probe', '--quarantine', '--restore', '--yes', '--keep-memories', '--keep-identity']);
28
+ const VERSION = '0.18.0';
29
+ const CLI_VALUE_OPTS = new Set(['--type', '--limit', '--days', '--out', '--owner', '--agent', '--agent-id', '--agent-key', '--agent-key-file', '--plugin-data', '--threshold', '--scope', '--host', '--port', '--dir', '--name', '--user', '--relationship', '--source', '--weight', '--budget', '--password', '--new-password', '--recovery-key', '--recovery-key-out', '--reason', '--merge', '--model', '--subject', '--embedding', '--focus', '--embedding-timeout', '--min-age', '--min-idle', '--max-utility', '--min-group', '--period', '--to', '--id', '--time', '--tools', '--mcp-config', '--skill-dir', '--year', '--evalset', '--k', '--seed', '--bootstrap', '--gate', '--against', '--template', '--path', '--from']);
30
+ const CLI_FLAG_OPTS = new Set(['--project', '--all', '--unsafe', '--no-auth', '--stdio', '--onstart', '--from-current', '--restart', '--force', '--attach', '--allow-same-volume', '--verify', '--no-hint', '--encrypt', '--no-encrypt', '--password-stdin', '--json', '--manual', '--skip-schedule', '--useful', '--useless', '--undo', '--dry-run', '--apply', '--purge', '--dedup', '--batches', '--propose', '--audit', '--capacity', '--explain', '--semantic', '--runtime', '--ablate', '--timing', '--baseline', '--probe', '--quarantine', '--restore', '--no-usage', '--yes', '--keep-memories', '--keep-identity']);
31
31
 
32
32
  function helpOption(flag, arg, what, when, caution) {
33
33
  return { flag: flag, arg: arg || '', what: what, when: when || '', caution: caution || '' };
@@ -65,6 +65,7 @@ const HELP_MODEL = [
65
65
  helpOption('--embedding', '<命令>', '临时指定本地 embedding 插件命令', '这次检索想使用自定义本地向量命令时', '只在本地执行,不会把记忆上传远端'),
66
66
  helpOption('--embedding-timeout', '<毫秒>', '设置 embedding 插件超时', '插件较慢或需要快速失败时', ''),
67
67
  helpOption('--year', '<yyyy>', '只检索指定年份的记忆', '库很大、只想在某一年的记忆里找时', '可重复传多次;不传则检索全部年份'),
68
+ helpOption('--no-usage', '', '本次不记录命中打点', '只想临时关闭使用统计时', '只影响这一次调用,不改配置'),
68
69
  ] },
69
70
  { name: 'forget', usage: 'forget <记忆 id> [选项]', what: '删除一条记忆', when: '确认某条记忆不应继续保留时', options: [
70
71
  helpOption('--reason', '<原因>', '记录删除原因', '需要留下为什么删除的审计线索时', ''),
@@ -78,7 +79,8 @@ const HELP_MODEL = [
78
79
  { name: 'archive', usage: 'archive [选项]', what: '把低价值或过期记忆归档', when: '库变大,想让旧记忆退出主检索但保留可回溯时', options: [
79
80
  helpOption('--days', '<天数>', '按未使用天数判断归档', '想调整「多久没用才归档」时', ''),
80
81
  helpOption('--threshold', '<数值>', '按效用分阈值判断归档', '想调整「用得少不少」时', ''),
81
- helpOption('--dry-run', '', '只预览归档结果', '先看会动哪些记忆,再决定是否执行时', ''),
82
+ helpOption('--dry-run', '', '只预览归档结果', '先看会动哪些记忆,再决定是否执行时', '预演只读,不改任何文件'),
83
+ helpOption('--force', '', '关闭冷却期与标签常青豁免', '确认新条目或 evergreen / pinned 条目也要归档时', 'immutable 与 BOUND 仍然豁免;先确认清单'),
82
84
  ] },
83
85
  { name: 'backup', usage: 'backup <子命令> [选项]', what: '备份、恢复和演练记忆库', when: '配置自动备份、创建备份、恢复误删或验证备份可用时', subcommands: [
84
86
  helpSub('volumes', 'volumes [--json]', '列出可用备份卷', '想知道备份会写到哪里时', [helpOption('--json', '', '输出 JSON', '脚本处理备份卷列表时', '')]),
@@ -115,6 +117,7 @@ const HELP_MODEL = [
115
117
  helpOption('--json', '', '输出 JSON', '脚本或自动化读取体检结果时', ''),
116
118
  ] },
117
119
  { name: 'maintain', usage: 'maintain [选项]', what: '记忆自组织:归档、遗忘候选、去重和合并', when: '定期清理低价值记忆、合并重复条目时', options: [
120
+ helpOption('--capacity', '', '查看容量水位与淘汰 / 晋升建议', '库变大,想看哪些条目值得清理或提升时', '只读报告,不会自动归档或改权重'),
118
121
  helpOption('--dedup', '', '只做重复检查并给置信度', '想先看哪些条目重复时', ''),
119
122
  helpOption('--apply', '', '真正执行,而不是只预览', '确认预览结果后要落盘时', '会改动记忆库并创建事务快照;先跑 dry-run'),
120
123
  helpOption('--purge', '', '对遗忘候选执行真删', '确认候选确实不再需要时', '真删不可逆;通常先用归档而不是 purge'),
@@ -126,8 +129,10 @@ const HELP_MODEL = [
126
129
  helpOption('--merge', '<id列表>', '显式合并指定条目', '已经确认这些条目可以合并时', ''),
127
130
  helpOption('--json', '', '输出 JSON', '自动化处理维护结果时', ''),
128
131
  ] },
129
- { name: 'consolidate', usage: 'consolidate [--apply | --undo <batch> | --batches] [选项]', what: '把旧记忆压缩成周期摘要', when: '库很大,想把超龄、闲置、低效用条目压成摘要时', options: [
130
- helpOption('--apply', '', '真正执行压缩', '确认 dry-run 结果后要落盘时', '会改动记忆库并创建事务快照;先预览'),
132
+ { name: 'consolidate', usage: 'consolidate [--propose | --apply | --undo <batch> | --batches] [选项]', what: '把旧记忆压缩成周期摘要', when: '库很大,想把超龄、闲置、低效用条目压成摘要时', options: [
133
+ helpOption('--propose', '', '只输出结构化候选报告(默认行为)', '想先审阅会压缩哪些内容、保留与回滚口径时', '不写盘;等价于直接运行 consolidate'),
134
+ helpOption('--apply', '', '真正执行压缩', '确认 propose 报告后要落盘时', '会改动记忆库并创建事务快照;先预览'),
135
+ helpOption('--yes', '', '非交互确认执行', '脚本或自动化环境里已经审阅过 propose 报告时', '跳过交互确认;破坏性操作,务必先看报告'),
131
136
  helpOption('--undo', '<batch>', '回滚一个摘要批次', '压缩后发现结果不合适时', ''),
132
137
  helpOption('--batches', '', '列出历史批次', '想找回滚批次号时', ''),
133
138
  helpOption('--period', '<天数>', '设置摘要周期', '想改变摘要覆盖的时间跨度时', ''),
@@ -148,7 +153,10 @@ const HELP_MODEL = [
148
153
  helpOption('--useless', '', '记为没用', '这条记忆造成误导或噪音时', ''),
149
154
  helpOption('--undo', '', '撤销上一次反馈', '误点反馈时', ''),
150
155
  ] },
151
- { name: 'explain', usage: 'explain <记忆 id> [--json]', what: '解释一条记忆的效用分和归档判定', when: '想理解为什么它被排序、归档或遗忘时', options: [helpOption('--json', '', '输出 JSON', '脚本读取解释结果时', '')] },
156
+ { name: 'explain', usage: 'explain <记忆 id> [--json] [--no-usage]', what: '解释一条记忆的效用分和归档判定', when: '想理解为什么它被排序、归档或遗忘时', options: [
157
+ helpOption('--json', '', '输出 JSON', '脚本读取解释结果时', ''),
158
+ helpOption('--no-usage', '', '本次不记录命中打点', '只想临时关闭使用统计时', '只影响这一次调用,不改配置'),
159
+ ] },
152
160
  { name: 'bench', usage: 'bench [--evalset <文件>] [--k <条数>] [--seed <数值>] [--bootstrap <次数>] [--ablate] [--gate <指标>=<数值>] [--timing] [--json] [--out <文件>]', what: '可复算的检索基准评测', when: '改检索 / 索引逻辑前后要证明「没变差」,或把质量门禁接进 CI 时', options: [
153
161
  helpOption('--evalset', '<文件>', '指定评测集 JSON 文件', '想固定一组查询来复算指标时', '不指定时按库内条目做确定性抽样,生成基线评测集'),
154
162
  helpOption('--k', '<条数>', '设置结果窗口大小', '想按 Recall@k / nDCG@k 的 k 口径统计时', '默认 5'),
@@ -195,7 +203,9 @@ const HELP_MODEL = [
195
203
  helpOption('--owner', '<id>', '指定画像归属者', '查某个 owner 的私密画像时', '仍然受私密区权限约束'),
196
204
  helpOption('--json', '', '输出 JSON', '自动化读取画像时', ''),
197
205
  ] },
198
- { name: 'context', usage: 'context [选项]', what: '生成开工上下文包', when: '每个会话开工时恢复身份、边界、近期记忆和承诺', options: [
206
+ { name: 'context', usage: 'context [--audit [--from <文件|->]] [选项]', what: '生成开工上下文包', when: '每个会话开工时恢复身份、边界、近期记忆和承诺', options: [
207
+ helpOption('--audit', '', '审计被压缩掉的内容是否已经落盘', '宿主压缩上下文后,想确认有没有决策只存在于对话里时', '只读:只打印 remember 建议,不会自动补写'),
208
+ helpOption('--from', '<文件|->', '指定待审计内容(- 表示标准输入)', '把宿主压缩摘要或丢弃段落喂给审计时', '不传时审计当前上下文包的丢弃清单'),
199
209
  helpOption('--limit', '<条数>', '限制近期记忆条数', '上下文太长、想缩短时', ''),
200
210
  helpOption('--owner', '<id>', '指定 owner 范围', '需要看某个 owner 的上下文时', '仍然受私密区权限约束'),
201
211
  helpOption('--budget', '<字符数>', '设置动态记忆字符预算', '需要控制上下文包大小时', '0 表示不限制'),
@@ -204,6 +214,7 @@ const HELP_MODEL = [
204
214
  helpOption('--embedding', '<命令>', '临时指定本地 embedding 插件命令', '上下文检索想用本地向量命令时', '只在本地执行'),
205
215
  helpOption('--embedding-timeout', '<毫秒>', '设置 embedding 插件超时', '插件较慢或需要快速失败时', ''),
206
216
  helpOption('--year', '<yyyy>', '只装载指定年份的记忆', '只想让上下文包覆盖某一年份时', '可重复传多次;不传则装载全部年份'),
217
+ helpOption('--no-usage', '', '本次不记录命中打点', '只想临时关闭使用统计时', '只影响这一次调用,不改配置'),
207
218
  ] },
208
219
  { name: 'token', usage: 'token <子命令> [选项]', what: '管理远端 MCP 访问 token', when: '给远端 MCP 客户端发放或吊销访问凭据时', subcommands: [
209
220
  helpSub('new', 'new --agent <id> [--scope <范围>] [--json]', '生成一个访问 token', '远端客户端第一次接入时', [
@@ -304,7 +315,7 @@ const PRIVATE_LEAF = ['prefs', 'bounds', 'commits'];
304
315
  const ARCHIVE_DIR = '.archive';
305
316
  const INDEX_FILE = 'index.json';
306
317
  const PRIVATE_TYPES = ['PREF', 'BOUND', 'COMMIT'];
307
- const FIELD_ORDER = ['type', 'subject', 'statement', 'confidence', 'created', 'updated', 'tags', 'immutable', 'scope', 'owner', 'source', 'weight', 'access_count', 'last_accessed', 'feedback_net'];
318
+ const FIELD_ORDER = ['type', 'subject', 'statement', 'confidence', 'created', 'updated', 'tags', 'immutable', 'scope', 'owner', 'source', 'weight', 'access_count', 'last_accessed', 'feedback_net', 'hit_days', 'hit_queries'];
308
319
  const CONFIG_FILE = 'config.json';
309
320
  const SERVER_SUBDIR = '.server';
310
321
  const TOKENS_FILE = 'tokens.json';
@@ -1330,6 +1341,129 @@ function semanticMatch(entry, query, wantDetail) {
1330
1341
  function round3(n) { return Math.round(n * 1000) / 1000; }
1331
1342
  // ---- v0.8.0 自我进化:统一效用分(盖棺分)----
1332
1343
  function feedbackNetOf(meta) { return parseInt(meta.feedback_net || '0', 10) || 0; }
1344
+ // ---- v0.18.0 A3:命中打点(本地元数据;查询只存 8 位指纹,不落原文)----
1345
+ // hit_days 形如 s2026-09-26:2|g2026-09-25:1|u2026-09-24:1(s=search / g=get / u=use)。
1346
+ // hit_queries 形如 q3f9a1c7:2|q77bd021e:1(sha256(query) 前 8 位十六进制 + 次数)。
1347
+ const USAGE_SIGNAL_LABELS = { search: 's', get: 'g', use: 'u' };
1348
+ const USAGE_SIGNAL_ORDER = { s: 0, g: 1, u: 2 };
1349
+ const USAGE_CONFIG_DEFAULTS = { usage_retention_days: 90, usage_query_slots: 12 };
1350
+ function usageNumber(cfg, key) {
1351
+ const raw = cfg ? cfg[key] : undefined;
1352
+ if (raw === undefined || raw === null || raw === '') return USAGE_CONFIG_DEFAULTS[key];
1353
+ const n = parseFloat(raw);
1354
+ return (n >= 0) ? n : USAGE_CONFIG_DEFAULTS[key];
1355
+ }
1356
+ function usageSettings(opts) {
1357
+ const cfg = loadConfig();
1358
+ return {
1359
+ enabled: !(opts && opts.noUsage) && cfg.usage_enabled !== false,
1360
+ retentionDays: usageNumber(cfg, 'usage_retention_days'),
1361
+ querySlots: usageNumber(cfg, 'usage_query_slots'),
1362
+ };
1363
+ }
1364
+ function usageFingerprint(query) {
1365
+ return crypto.createHash('sha256').update(String(query), 'utf8').digest('hex').slice(0, 8);
1366
+ }
1367
+ function parseHitDays(value) {
1368
+ const map = {};
1369
+ const re = /([sgu])(\d{4}-\d{2}-\d{2}):(\d+)/g;
1370
+ let m;
1371
+ while ((m = re.exec(String(value || '')))) {
1372
+ const key = m[1] + m[2];
1373
+ map[key] = (map[key] || 0) + (parseInt(m[3], 10) || 0);
1374
+ }
1375
+ return map;
1376
+ }
1377
+ function serializeHitDays(map) {
1378
+ const keys = Object.keys(map).filter(function (k) { return /^[sgu]\d{4}-\d{2}-\d{2}$/.test(k) && map[k] > 0; });
1379
+ keys.sort(function (a, b) {
1380
+ const da = a.slice(1), db = b.slice(1);
1381
+ if (da !== db) return db.localeCompare(da);
1382
+ return (USAGE_SIGNAL_ORDER[a[0]] || 0) - (USAGE_SIGNAL_ORDER[b[0]] || 0);
1383
+ });
1384
+ return keys.map(function (k) { return k + ':' + map[k]; }).join('|');
1385
+ }
1386
+ function parseHitQueries(value) {
1387
+ const map = {};
1388
+ const re = /q([0-9a-f]{8}):(\d+)/g;
1389
+ let m;
1390
+ while ((m = re.exec(String(value || '')))) {
1391
+ map[m[1]] = (map[m[1]] || 0) + (parseInt(m[2], 10) || 0);
1392
+ }
1393
+ return map;
1394
+ }
1395
+ function serializeHitQueries(map, slots) {
1396
+ const fps = Object.keys(map).filter(function (fp) { return /^[0-9a-f]{8}$/.test(fp) && map[fp] > 0; });
1397
+ fps.sort(function (a, b) {
1398
+ if (map[b] !== map[a]) return map[b] - map[a];
1399
+ return a.localeCompare(b);
1400
+ });
1401
+ const limit = (slots >= 0) ? slots : USAGE_CONFIG_DEFAULTS.usage_query_slots;
1402
+ return fps.slice(0, limit).map(function (fp) { return 'q' + fp + ':' + map[fp]; }).join('|');
1403
+ }
1404
+ // 纯函数:算好本次命中要写入的 frontmatter 字段(不写盘,便于 recall/feedback 复用同一口径)。
1405
+ function usagePatchForMeta(meta, signal, query, settings, dateStr) {
1406
+ const label = USAGE_SIGNAL_LABELS[signal];
1407
+ if (!label) return {};
1408
+ const patch = {};
1409
+ const days = parseHitDays(meta.hit_days);
1410
+ const key = label + dateStr;
1411
+ days[key] = (days[key] || 0) + 1;
1412
+ const kept = {};
1413
+ for (const k of Object.keys(days)) {
1414
+ const age = daysBetween(k.slice(1), dateStr);
1415
+ if (age < 0 || age > settings.retentionDays) continue;
1416
+ kept[k] = days[k];
1417
+ }
1418
+ const daysStr = serializeHitDays(kept);
1419
+ if (daysStr) patch.hit_days = daysStr;
1420
+ if (signal === 'search' && query) {
1421
+ const queries = parseHitQueries(meta.hit_queries);
1422
+ const fp = usageFingerprint(query);
1423
+ queries[fp] = (queries[fp] || 0) + 1;
1424
+ patch.hit_queries = serializeHitQueries(queries, settings.querySlots);
1425
+ }
1426
+ return patch;
1427
+ }
1428
+ // 独立写点(explain / context use 信号):写失败只计数,不影响读取结果。
1429
+ function applyUsageHits(root, relFiles, signal, query, opts) {
1430
+ opts = opts || {};
1431
+ const out = { enabled: false, written: 0, skipped: 0, failed: 0 };
1432
+ const settings = usageSettings(opts);
1433
+ out.enabled = settings.enabled;
1434
+ if (!settings.enabled) return out;
1435
+ const selfAgent = (opts.selfAgent !== undefined) ? opts.selfAgent : resolveIdentity(opts).id;
1436
+ const dateStr = today();
1437
+ for (const rel of relFiles) {
1438
+ const fp = path.join(root, rel);
1439
+ if (!fs.existsSync(fp)) continue;
1440
+ const owner = ownerFromPrivatePath(root, fp);
1441
+ if (owner && owner !== selfAgent) { out.skipped++; continue; }
1442
+ try {
1443
+ const parsed = parseFrontmatter(readMemoryText(root, fp, owner));
1444
+ if (parsed.meta.immutable === 'true') { out.skipped++; continue; }
1445
+ const patch = usagePatchForMeta(parsed.meta, signal, query, settings, dateStr);
1446
+ if (!Object.keys(patch).length) continue;
1447
+ const meta = Object.assign({}, parsed.meta, patch);
1448
+ writeMemoryText(root, fp, frontmatterToText(meta, parsed.body), owner);
1449
+ upsertIndexEntry(root, readEntry(fp, root));
1450
+ out.written++;
1451
+ } catch (e) {
1452
+ out.failed++;
1453
+ }
1454
+ }
1455
+ return out;
1456
+ }
1457
+ // 报告侧派生:窗口内命中次数(不落盘,避免派生值与原始值漂移)。
1458
+ function usageTotals(entry, days) {
1459
+ const map = parseHitDays(entry && entry.hit_days);
1460
+ let total = 0;
1461
+ for (const k of Object.keys(map)) {
1462
+ const age = daysBetween(k.slice(1), today());
1463
+ if (age >= 0 && age <= days) total += map[k];
1464
+ }
1465
+ return total;
1466
+ }
1333
1467
  function utilityScore(meta) {
1334
1468
  const conf = Math.min(Math.max(parseFloat(meta.confidence || 1.0) || 1.0, 0), 1);
1335
1469
  const acc = parseInt(meta.access_count || '0', 10) || 0;
@@ -2299,6 +2433,8 @@ function readEntry(fp, root) {
2299
2433
  source: meta.source || '',
2300
2434
  weight: (parseFloat(meta.weight) > 0 ? parseFloat(meta.weight) : 1.0),
2301
2435
  feedback_net: feedbackNetOf(meta),
2436
+ hit_days: meta.hit_days || '',
2437
+ hit_queries: meta.hit_queries || '',
2302
2438
  tokens: buildTokens(subject, statement, tags),
2303
2439
  meta: meta,
2304
2440
  };
@@ -2312,23 +2448,72 @@ function rewriteFrontmatter(fp, patch, root, owner) {
2312
2448
  for (const k of Object.keys(patch)) meta[k] = patch[k];
2313
2449
  writeMemoryText(root || memoryRootForFile(fp), fp, frontmatterToText(meta, parsed.body), owner || ownerFromPrivatePath(root || memoryRootForFile(fp), fp));
2314
2450
  }
2315
- function bumpReadMeta(root, relFiles) {
2451
+ function bumpReadMeta(root, relFiles, options) {
2452
+ options = options || {};
2316
2453
  const now = today();
2454
+ const settings = usageSettings(options);
2455
+ const selfAgent = (options.selfAgent !== undefined) ? options.selfAgent : resolveIdentity(options).id;
2456
+ const usageByFile = {};
2317
2457
  for (const rel of relFiles) {
2318
2458
  const fp = path.join(root, rel);
2319
2459
  if (!fs.existsSync(fp)) continue;
2320
2460
  const owner = ownerFromPrivatePath(root, fp);
2461
+ // v0.18.0:跨 owner 私密条目一律不写(读得到也不代表能改别人的文件)。
2462
+ if (owner && owner !== selfAgent) continue;
2321
2463
  let parsed;
2322
2464
  try { parsed = parseFrontmatter(readMemoryText(root, fp, owner)); } catch (e) { continue; }
2323
2465
  const meta = parsed.meta;
2324
2466
  if (meta.immutable === 'true') continue;
2325
2467
  const acc = (parseInt(meta.access_count || '0', 10) || 0) + 1;
2326
- rewriteFrontmatter(fp, { access_count: acc, last_accessed: now }, root, owner);
2468
+ const patch = { access_count: acc, last_accessed: now };
2469
+ if (settings.enabled) {
2470
+ const usagePatch = usagePatchForMeta(meta, 'search', options.query || '', settings, now);
2471
+ Object.assign(patch, usagePatch);
2472
+ usageByFile[rel] = usagePatch;
2473
+ }
2474
+ try {
2475
+ rewriteFrontmatter(fp, patch, root, owner);
2476
+ } catch (e) {
2477
+ // 打点写失败不影响读取结果本身;索引也保持原样。
2478
+ delete usageByFile[rel];
2479
+ }
2327
2480
  }
2481
+ return { usageByFile: usageByFile };
2328
2482
  }
2329
2483
  function touchIndex(root, relFiles, options) {
2484
+ options = options || {};
2330
2485
  const set = new Set(relFiles);
2331
2486
  const now = today();
2487
+ const usageByFile = options.usageByFile || {};
2488
+ const bumpEntry = function (e) {
2489
+ e.access_count = (parseInt(e.access_count, 10) || 0) + 1;
2490
+ e.last_accessed = now;
2491
+ const usage = usageByFile[e.file];
2492
+ if (usage) {
2493
+ if (usage.hit_days !== undefined) e.hit_days = usage.hit_days;
2494
+ if (usage.hit_queries !== undefined) e.hit_queries = usage.hit_queries;
2495
+ }
2496
+ };
2497
+ // v0.18.0:加密库的私密条目命中必须当场同步 owner index(旧版会滞后到 reindex)。
2498
+ if (isEncrypted(root)) {
2499
+ const byOwner = {};
2500
+ for (const rel of set) {
2501
+ if (String(rel).indexOf(PRIVATE_DIR + '/') !== 0) continue;
2502
+ const owner = String(rel).split('/')[1] || '';
2503
+ (byOwner[owner] = byOwner[owner] || []).push(rel);
2504
+ }
2505
+ for (const owner of Object.keys(byOwner)) {
2506
+ const key = getOwnerKeyFor(root, owner);
2507
+ if (!key) continue;
2508
+ let ownerIdx;
2509
+ try { ownerIdx = loadOwnerIndex(root, owner, key); } catch (e) { continue; }
2510
+ let dirty = false;
2511
+ for (const e of ownerIdx) {
2512
+ if (set.has(e.file)) { bumpEntry(e); dirty = true; }
2513
+ }
2514
+ if (dirty) saveOwnerIndex(root, owner, key, ownerIdx);
2515
+ }
2516
+ }
2332
2517
  // v0.17.0 B3:带 --year 的检索只碰命中年份的分片(命中条目必然来自这些分片),
2333
2518
  // 不传年份时行为与旧版一致(遍历全部 shards)。
2334
2519
  const filterYears = (options && Array.isArray(options.years)) ? options.years : [];
@@ -2344,7 +2529,7 @@ function touchIndex(root, relFiles, options) {
2344
2529
  if (!sd || !Array.isArray(sd.entries)) continue;
2345
2530
  let dirty = false;
2346
2531
  for (const e of sd.entries) {
2347
- if (set.has(e.file)) { e.access_count = (parseInt(e.access_count, 10) || 0) + 1; e.last_accessed = now; dirty = true; }
2532
+ if (set.has(e.file)) { bumpEntry(e); dirty = true; }
2348
2533
  }
2349
2534
  if (dirty) fs.writeFileSync(sp, JSON.stringify(sd, null, 2), 'utf8');
2350
2535
  }
@@ -2353,7 +2538,7 @@ function touchIndex(root, relFiles, options) {
2353
2538
  const idx = getIndex(root);
2354
2539
  let dirty = false;
2355
2540
  for (const e of idx) {
2356
- if (set.has(e.file)) { e.access_count = (parseInt(e.access_count, 10) || 0) + 1; e.last_accessed = now; dirty = true; }
2541
+ if (set.has(e.file)) { bumpEntry(e); dirty = true; }
2357
2542
  }
2358
2543
  if (dirty) saveIndex(root, idx);
2359
2544
  }
@@ -3491,6 +3676,10 @@ const SCALE_THRESHOLD_DEFAULTS = {
3491
3676
  scale_warn_files_per_dir: 500,
3492
3677
  scale_warn_index_bytes: 5 * 1024 * 1024,
3493
3678
  scale_warn_cold_start_ms: 2000,
3679
+ scale_info_entries: 30000,
3680
+ scale_info_files_per_dir: 300,
3681
+ scale_info_index_bytes: 3 * 1024 * 1024,
3682
+ scale_info_cold_start_ms: 1200,
3494
3683
  };
3495
3684
  function scaleThreshold(cfg, key) {
3496
3685
  const raw = cfg ? cfg[key] : undefined;
@@ -3525,6 +3714,10 @@ function measureIndexColdStart(root) {
3525
3714
  const elapsed = Number(process.hrtime.bigint() - started) / 1e6;
3526
3715
  return Math.round(elapsed * 1000) / 1000;
3527
3716
  }
3717
+ function scaleMetric(key, value, unit, info, warn) {
3718
+ const level = value > warn ? 'warning' : (value > info ? 'info' : 'ok');
3719
+ return { key: key, value: value, unit: unit, info: info, warn: warn, level: level };
3720
+ }
3528
3721
  function scaleReport(root, cfg) {
3529
3722
  const thresholds = {};
3530
3723
  for (const key of Object.keys(SCALE_THRESHOLD_DEFAULTS)) thresholds[key] = scaleThreshold(cfg, key);
@@ -3536,26 +3729,37 @@ function scaleReport(root, cfg) {
3536
3729
  for (let i = 0; i < 3; i++) samples.push(measureIndexColdStart(root));
3537
3730
  samples.sort(function (a, b) { return a - b; });
3538
3731
  const coldStartMs = samples[Math.floor(samples.length / 2)];
3732
+ const metrics = [
3733
+ scaleMetric('entries', entries, '条', thresholds.scale_info_entries, thresholds.scale_warn_entries),
3734
+ scaleMetric('files_per_dir', filesPerDir, '个文件', thresholds.scale_info_files_per_dir, thresholds.scale_warn_files_per_dir),
3735
+ scaleMetric('index_bytes', indexBytes, '字节', thresholds.scale_info_index_bytes, thresholds.scale_warn_index_bytes),
3736
+ scaleMetric('cold_start_ms', coldStartMs, 'ms', thresholds.scale_info_cold_start_ms, thresholds.scale_warn_cold_start_ms),
3737
+ ];
3539
3738
  const warnings = [];
3540
- if (entries > thresholds.scale_warn_entries) {
3739
+ if (metrics[0].level === 'warning') {
3541
3740
  warnings.push('规模: 记忆条数 ' + entries + ' 超过阈值 ' + thresholds.scale_warn_entries + '(考虑按年归档旧条目,或分批导出冷数据)');
3542
3741
  }
3543
- if (filesPerDir > thresholds.scale_warn_files_per_dir) {
3742
+ if (metrics[1].level === 'warning') {
3544
3743
  warnings.push('规模: 单个目录文件数 ' + filesPerDir + ' 超过阈值 ' + thresholds.scale_warn_files_per_dir + '(检查是否有目录未按年/月分层)');
3545
3744
  }
3546
- if (indexBytes > thresholds.scale_warn_index_bytes) {
3745
+ if (metrics[2].level === 'warning') {
3547
3746
  warnings.push('规模: 索引总体积 ' + indexBytes + ' 字节超过阈值 ' + thresholds.scale_warn_index_bytes + '(可用 reindex 重建,或按年份分片)');
3548
3747
  }
3549
- if (coldStartMs > thresholds.scale_warn_cold_start_ms) {
3748
+ if (metrics[3].level === 'warning') {
3550
3749
  warnings.push('规模: 索引冷启动 ' + coldStartMs + 'ms 超过阈值 ' + thresholds.scale_warn_cold_start_ms + 'ms(可用 reindex 重建索引)');
3551
3750
  }
3751
+ // v0.18.0 A6:分级只影响 level 与提示文案;超 warn 仍进 warnings(既有行为),info 不阻断。
3752
+ const level = metrics.some(function (m) { return m.level === 'warning'; })
3753
+ ? 'warning'
3754
+ : (metrics.some(function (m) { return m.level === 'info'; }) ? 'info' : 'ok');
3552
3755
  return {
3553
3756
  entries: entries,
3554
3757
  files_per_dir: filesPerDir,
3555
3758
  index_bytes: indexBytes,
3556
3759
  cold_start_ms: coldStartMs,
3557
3760
  thresholds: thresholds,
3558
- level: warnings.length ? 'warning' : 'ok',
3761
+ metrics: metrics,
3762
+ level: level,
3559
3763
  warnings: warnings,
3560
3764
  };
3561
3765
  }
@@ -3722,7 +3926,13 @@ function doctorCore(opts) {
3722
3926
  '- 备份目录: ' + (configuredDir || (cfg.backup_setup_choice === 'manual' ? '手动模式' : '(未配置)')),
3723
3927
  ];
3724
3928
  if (checks.scale) {
3725
- lines.push('- 规模: ' + checks.scale.entries + ' 条记忆 / 单目录最大 ' + checks.scale.files_per_dir + ' 个文件 / 索引 ' + checks.scale.index_bytes + ' 字节 / 冷启动 ' + checks.scale.cold_start_ms + 'ms(' + (checks.scale.level === 'warning' ? '有告警' : '正常') + ')');
3929
+ const scaleLevelLabel = checks.scale.level === 'warning' ? '告警' : (checks.scale.level === 'info' ? '提示' : '正常');
3930
+ const scaleTop = (checks.scale.metrics || []).filter(function (m) { return m.level !== 'ok'; })
3931
+ .sort(function (a, b) {
3932
+ const rank = { warning: 2, info: 1, ok: 0 };
3933
+ return rank[b.level] - rank[a.level];
3934
+ })[0];
3935
+ lines.push('- 规模: ' + checks.scale.entries + ' 条记忆 / 单目录最大 ' + checks.scale.files_per_dir + ' 个文件 / 索引 ' + checks.scale.index_bytes + ' 字节 / 冷启动 ' + checks.scale.cold_start_ms + 'ms(' + scaleLevelLabel + (scaleTop ? ',最高项 ' + scaleTop.key + ' ' + scaleTop.value + scaleTop.unit : '') + ')');
3726
3936
  }
3727
3937
  if (checks.layout && (checks.layout.duplicates || checks.layout.conflicts || checks.layout.unverified)) {
3728
3938
  lines.push('- 布局: 同序号重复 ' + checks.layout.duplicates + ' 组 / 冲突 ' + checks.layout.conflicts + ' 组 / 待核 ' + checks.layout.unverified + ' 组');
@@ -4405,11 +4615,19 @@ function recallCore(query, opts) {
4405
4615
  }
4406
4616
  return { error: false, exitCode: 0, text: '无匹配记忆。' };
4407
4617
  }
4408
- const touchRel = shown.filter(function (h) { return !h.entry.immutable; }).map(function (h) { return h.entry.file; });
4409
- if (touchRel.length) {
4618
+ const touchAll = shown.filter(function (h) { return !h.entry.immutable; }).map(function (h) { return h.entry.file; });
4619
+ let usageSkipped = 0;
4620
+ if (touchAll.length) {
4410
4621
  for (const root of roots) {
4411
- bumpReadMeta(root, touchRel);
4412
- touchIndex(root, touchRel, { years: years });
4622
+ const touchRel = touchAll.filter(function (rel) {
4623
+ if (!fs.existsSync(path.join(root, rel))) return false;
4624
+ const owner = ownerFromPrivatePath(root, path.join(root, rel));
4625
+ if (owner && owner !== selfAgent) { usageSkipped++; return false; }
4626
+ return true;
4627
+ });
4628
+ if (!touchRel.length) continue;
4629
+ const bump = bumpReadMeta(root, touchRel, { selfAgent: selfAgent, query: q, noUsage: !!opts.noUsage });
4630
+ touchIndex(root, touchRel, { years: years, usageByFile: bump.usageByFile });
4413
4631
  }
4414
4632
  }
4415
4633
  const lines = ['共 ' + shown.length + ' 条记忆(' + (hits.length > limit ? '前 ' + limit + ' 条' : '全部') + '):'];
@@ -4426,6 +4644,9 @@ function recallCore(query, opts) {
4426
4644
  if (deniedCount > 0 && explicitCross) {
4427
4645
  lines.push('\n[警告] 本次检索共拒绝 ' + deniedCount + ' 条越界访问(其它智能体私密记忆,未授权不展示)。如需读取请加 --unsafe 或 --owner user。');
4428
4646
  }
4647
+ if (usageSkipped > 0) {
4648
+ lines.push('\n[提示] 跨 owner 私密条目未记录命中打点(' + usageSkipped + ' 条):只有条目所属 owner 才能更新自己的打点。');
4649
+ }
4429
4650
  return {
4430
4651
  error: false,
4431
4652
  exitCode: 0,
@@ -4575,28 +4796,200 @@ function forgetCore(fileRef, opts) {
4575
4796
  }
4576
4797
  return { error: false, text: '已移入回收区: ' + trashFile };
4577
4798
  }
4799
+ // ---- v0.18.0 A3:容量水位 / 淘汰候选 / 晋升建议(只读报告,不自动动数据)----
4800
+ const CAPACITY_CONFIG_DEFAULTS = {
4801
+ capacity_warn_bytes: 50 * 1024 * 1024,
4802
+ capacity_cooldown_days: 30,
4803
+ capacity_candidate_limit: 20,
4804
+ promotion_min_hits: 3,
4805
+ promotion_min_queries: 3,
4806
+ };
4807
+ function capacityNumber(cfg, key) {
4808
+ const raw = cfg ? cfg[key] : undefined;
4809
+ if (raw === undefined || raw === null || raw === '') return CAPACITY_CONFIG_DEFAULTS[key];
4810
+ const n = parseFloat(raw);
4811
+ return (n >= 0) ? n : CAPACITY_CONFIG_DEFAULTS[key];
4812
+ }
4813
+ function isEvergreenTagged(meta) {
4814
+ // tags 在真实库里可能是 JSON 数组串(["pinned"]),parseTags 会保留引号,这里统一去掉再比较。
4815
+ const tags = parseTags(meta && meta.tags).map(function (t) {
4816
+ return String(t).toLowerCase().replace(/^["']|["']$/g, '');
4817
+ });
4818
+ return tags.indexOf('evergreen') !== -1 || tags.indexOf('pinned') !== -1;
4819
+ }
4820
+ function capacityMemoryBytes(root) {
4821
+ let total = 0;
4822
+ for (const fp of collectEntryFiles(root)) {
4823
+ try { total += fs.statSync(fp).size; } catch (e) { /* 单文件缺失不影响整体水位 */ }
4824
+ }
4825
+ return total;
4826
+ }
4827
+ function capacityCandidateRow(e) {
4828
+ return {
4829
+ file: e.file,
4830
+ type: e.type,
4831
+ subject: e.subject,
4832
+ hits_30d: e.hits_30d,
4833
+ hits_90d: e.hits_90d,
4834
+ last_used: e.last_used,
4835
+ age_days: e.age_days,
4836
+ utility: e.utility,
4837
+ suggested_command: 'yotta-memory explain ' + e.file,
4838
+ };
4839
+ }
4840
+ function capacityCore(opts) {
4841
+ opts = opts || {};
4842
+ const root = path.resolve(opts.root || userRoot());
4843
+ if (!fs.existsSync(root)) return { error: false, text: '记忆库不存在。', report: null };
4844
+ const cfg = loadConfig();
4845
+ const selfAgent = (opts.selfAgent !== undefined) ? opts.selfAgent : resolveIdentity(opts).id;
4846
+ const cooldownDays = capacityNumber(cfg, 'capacity_cooldown_days');
4847
+ const limit = (opts.limit !== undefined && opts.limit !== null)
4848
+ ? (parseInt(opts.limit, 10) || 0)
4849
+ : capacityNumber(cfg, 'capacity_candidate_limit');
4850
+ const minHits = capacityNumber(cfg, 'promotion_min_hits');
4851
+ const minQueries = capacityNumber(cfg, 'promotion_min_queries');
4852
+ const skipped = { cooldown: 0, evergreen: 0, denied: 0, unreadable: 0 };
4853
+ const activityBase = [];
4854
+ const candidatesForCleanup = [];
4855
+ for (const fp of collectEntryFiles(root)) {
4856
+ const r = relOf(root, fp);
4857
+ if (checkOwnerWritable(root, r, selfAgent, opts.unsafe)) { skipped.denied++; continue; }
4858
+ let e;
4859
+ try { e = readEntry(fp, root); } catch (err) { skipped.unreadable++; continue; }
4860
+ e.hits_30d = usageTotals(e, 30);
4861
+ e.hits_90d = usageTotals(e, 90);
4862
+ e.last_used = e.last_accessed || e.created || '';
4863
+ e.age_days = e.created ? daysBetween(e.created, today()) : -1;
4864
+ e.utility = round3(utilityScore(e.meta));
4865
+ activityBase.push(e);
4866
+ if (e.immutable || e.type === 'BOUND' || isEvergreenTagged(e.meta)) { skipped.evergreen++; continue; }
4867
+ if (e.age_days >= 0 && e.age_days < cooldownDays) { skipped.cooldown++; continue; }
4868
+ candidatesForCleanup.push(e);
4869
+ }
4870
+ const byOldest = function (a, b) {
4871
+ const la = a.last_used || '', lb = b.last_used || '';
4872
+ if (la !== lb) return la.localeCompare(lb);
4873
+ return String(a.file).localeCompare(String(b.file));
4874
+ };
4875
+ const lru = candidatesForCleanup.slice().sort(byOldest).slice(0, limit).map(capacityCandidateRow);
4876
+ const lfu = candidatesForCleanup.slice().sort(function (a, b) {
4877
+ if (a.hits_90d !== b.hits_90d) return a.hits_90d - b.hits_90d;
4878
+ return byOldest(a, b);
4879
+ }).slice(0, limit).map(capacityCandidateRow);
4880
+ const promotions = candidatesForCleanup.filter(function (e) {
4881
+ if (e.hits_90d < minHits) return false;
4882
+ if (Object.keys(parseHitQueries(e.hit_queries)).length < minQueries) return false;
4883
+ if (e.weight >= 1.5) return false;
4884
+ if (isConsolidatedSummary(e)) return false;
4885
+ return true;
4886
+ }).map(function (e) {
4887
+ const row = capacityCandidateRow(e);
4888
+ row.distinct_queries = Object.keys(parseHitQueries(e.hit_queries)).length;
4889
+ row.suggested_command = 'yotta-memory feedback ' + e.file + ' --useful';
4890
+ return row;
4891
+ });
4892
+ const scale = scaleReport(root, cfg);
4893
+ const memoryBytes = capacityMemoryBytes(root);
4894
+ const warnBytes = capacityNumber(cfg, 'capacity_warn_bytes');
4895
+ const infoBytes = warnBytes * 0.6;
4896
+ const memoryLevel = memoryBytes > warnBytes ? 'warning' : (memoryBytes > infoBytes ? 'info' : 'ok');
4897
+ const waterLevel = (scale.level === 'warning' || memoryLevel === 'warning')
4898
+ ? 'warning'
4899
+ : ((scale.level === 'info' || memoryLevel === 'info') ? 'info' : 'ok');
4900
+ const thresholds = Object.assign({}, scale.thresholds, {
4901
+ capacity_warn_bytes: warnBytes,
4902
+ capacity_cooldown_days: cooldownDays,
4903
+ capacity_candidate_limit: limit,
4904
+ promotion_min_hits: minHits,
4905
+ promotion_min_queries: minQueries,
4906
+ });
4907
+ const water = {
4908
+ entries: scale.entries,
4909
+ files_per_dir: scale.files_per_dir,
4910
+ index_bytes: scale.index_bytes,
4911
+ cold_start_ms: scale.cold_start_ms,
4912
+ memory_bytes: memoryBytes,
4913
+ thresholds: thresholds,
4914
+ metrics: scale.metrics.concat([{
4915
+ key: 'memory_bytes', value: memoryBytes, unit: '字节', info: infoBytes, warn: warnBytes, level: memoryLevel,
4916
+ }]),
4917
+ level: waterLevel,
4918
+ };
4919
+ const activity = {
4920
+ readable: activityBase.length,
4921
+ hits_30d_entries: activityBase.filter(function (e) { return e.hits_30d > 0; }).length,
4922
+ hits_90d_entries: activityBase.filter(function (e) { return e.hits_90d > 0; }).length,
4923
+ never_hit: activityBase.filter(function (e) { return e.hits_90d === 0 && (parseInt(e.access_count, 10) || 0) === 0; }).length,
4924
+ top: activityBase.filter(function (e) { return e.hits_90d > 0; })
4925
+ .sort(function (a, b) {
4926
+ if (b.hits_90d !== a.hits_90d) return b.hits_90d - a.hits_90d;
4927
+ return byOldest(a, b);
4928
+ })
4929
+ .slice(0, Math.min(limit, 10))
4930
+ .map(capacityCandidateRow),
4931
+ };
4932
+ const lines = ['## yotta-memory maintain --capacity(容量水位)'];
4933
+ const waterLabel = waterLevel === 'warning' ? '告警' : (waterLevel === 'info' ? '提示' : '正常');
4934
+ lines.push('- 水位: ' + waterLabel
4935
+ + '(条目 ' + scale.entries + ' / 记忆文件 ' + memoryBytes + ' 字节(阈值 ' + warnBytes + ')'
4936
+ + ' / 索引 ' + scale.index_bytes + ' 字节 / 单目录最大 ' + scale.files_per_dir + ' 个文件 / 冷启动 ' + scale.cold_start_ms + 'ms)');
4937
+ lines.push('- 活跃度: 可读 ' + activity.readable + ' 条;30 天命中 ' + activity.hits_30d_entries
4938
+ + ' 条 / 90 天命中 ' + activity.hits_90d_entries + ' 条 / 无命中记录 ' + activity.never_hit + ' 条');
4939
+ lines.push('');
4940
+ lines.push('### LRU 淘汰候选(最久未用,' + lru.length + ' 条)');
4941
+ if (!lru.length) lines.push('(无)');
4942
+ for (const row of lru) {
4943
+ lines.push('- [' + row.type + '] ' + row.file + '(90 天命中 ' + row.hits_90d + ',' + row.age_days + ' 天龄,utility ' + row.utility + ')— ' + row.subject);
4944
+ }
4945
+ lines.push('');
4946
+ lines.push('### LFU 淘汰候选(命中最少,' + lfu.length + ' 条)');
4947
+ if (!lfu.length) lines.push('(无)');
4948
+ for (const row of lfu) {
4949
+ lines.push('- [' + row.type + '] ' + row.file + '(90 天命中 ' + row.hits_90d + ',最久未用 ' + row.last_used + ')— ' + row.subject);
4950
+ }
4951
+ lines.push('');
4952
+ lines.push('### 晋升建议(只出建议,不自动改权重,' + promotions.length + ' 条)');
4953
+ if (!promotions.length) lines.push('(无:需要 90 天命中 ≥ ' + minHits + ' 且不同查询 ≥ ' + minQueries + ' 且 weight < 1.5)');
4954
+ for (const row of promotions) {
4955
+ lines.push('- ' + row.file + '(90 天命中 ' + row.hits_90d + ',不同查询 ' + row.distinct_queries + ')→ ' + row.suggested_command);
4956
+ }
4957
+ lines.push('');
4958
+ lines.push('### 跳过(不进候选)');
4959
+ lines.push('- 冷却期(<' + cooldownDays + ' 天): ' + skipped.cooldown + ' 条 / 常青(immutable、BOUND、evergreen、pinned): '
4960
+ + skipped.evergreen + ' 条 / 其它 owner: ' + skipped.denied + ' 条 / 无法读取: ' + skipped.unreadable + ' 条');
4961
+ lines.push('- 本命令只读:不改记忆、不改权重;归档请显式执行 yotta-memory archive。');
4962
+ const report = {
4963
+ schemaVersion: 1,
4964
+ root: root,
4965
+ thresholds: thresholds,
4966
+ water: water,
4967
+ activity: activity,
4968
+ candidates: { lru: lru, lfu: lfu },
4969
+ promotions: promotions,
4970
+ skipped: skipped,
4971
+ };
4972
+ return { error: false, text: lines.join('\n'), report: report };
4973
+ }
4578
4974
  function archiveCore(opts) {
4579
4975
  opts = opts || {};
4580
4976
  const cfg = loadConfig();
4581
4977
  const days = opts.days || parseInt(cfg.maintain_archived_age || '180', 10) || 180;
4582
4978
  const threshold = (opts.threshold !== undefined && opts.threshold !== null) ? opts.threshold : (parseFloat(cfg.maintain_archived_utility || 0.35));
4979
+ const cooldownDays = capacityNumber(cfg, 'capacity_cooldown_days');
4583
4980
  const root = userRoot();
4584
4981
  if (!fs.existsSync(root)) return { error: false, text: '记忆库不存在。' };
4585
- const guard = destructiveGuardCore({
4586
- root: root,
4587
- action: 'archive',
4588
- snapshotDir: opts.snapshotDir,
4589
- backupDir: opts.backupDir,
4590
- allowSameVolumeForTest: opts.allowSameVolumeForTest,
4591
- sameVolumeFn: opts.sameVolumeFn,
4592
- now: opts.now,
4593
- });
4594
- if (guard.error) return { error: true, text: guard.text };
4982
+ const dryRun = !!opts.dryRun;
4983
+ const force = !!opts.force;
4595
4984
  const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
4596
- let moved = 0;
4597
- let deniedCrossOwner = 0;
4598
- const movedFiles = [];
4599
4985
  const selfAgent = resolveIdentity(opts).id;
4986
+ // v0.18.0 修正:先只读扫描候选与跳过统计,再决定要不要过破坏性闸门 ——
4987
+ // 预演(--dry-run)与「没有候选」两种情况下都不写盘。
4988
+ // 旧行为是进门就建整库快照 + 写审计,且 --dry-run 标志被静默吞掉(照常归档)。
4989
+ const candidates = [];
4990
+ let deniedCrossOwner = 0;
4991
+ let skippedCooldown = 0;
4992
+ let skippedEvergreen = 0;
4600
4993
  for (const fp of collectEntryFiles(root)) {
4601
4994
  const owner = ownerFromPrivatePath(root, fp);
4602
4995
  // v0.17.2:只归档自己有权写的条目 —— 旧行为遍历全部 owner,
@@ -4613,23 +5006,86 @@ function archiveCore(opts) {
4613
5006
  if (isNaN(createdTs)) continue;
4614
5007
  const t = (meta.type || 'FACT').toUpperCase();
4615
5008
  if (t === 'BOUND') continue; // v0.10.0 BOUND 豁免归档(边界常驻)
5009
+ if (!force && isEvergreenTagged(meta)) { skippedEvergreen++; continue; }
5010
+ const ageDays = meta.created ? daysBetween(meta.created, today()) : -1;
5011
+ if (!force && ageDays >= 0 && ageDays < cooldownDays) { skippedCooldown++; continue; }
4616
5012
  // v0.8.0 统一效用分(盖棺分)替代 vitality
4617
- if (utilityScore(meta) < threshold && createdTs < cutoff) {
4618
- const rel = relOf(root, fp);
4619
- const dest = path.join(root, archiveRelFor(root, rel, t, owner));
4620
- const destDir = path.dirname(dest);
4621
- fs.mkdirSync(destDir, { recursive: true });
4622
- fs.renameSync(fp, dest);
4623
- movedFiles.push(rel);
4624
- moved++;
5013
+ const score = utilityScore(meta);
5014
+ if (score < threshold && createdTs < cutoff) {
5015
+ candidates.push({
5016
+ fp: fp,
5017
+ owner: owner,
5018
+ entry: {
5019
+ file: relOf(root, fp),
5020
+ type: t,
5021
+ subject: String(meta.subject || ''),
5022
+ age_days: ageDays,
5023
+ utility: round3(score),
5024
+ },
5025
+ });
4625
5026
  }
4626
5027
  }
4627
- if (movedFiles.length) removeIndexEntries(root, movedFiles);
4628
- let text = '已归档 ' + moved + ' 条旧记忆到 ' + path.join(root, ARCHIVE_DIR) + '\n事务快照: ' + guard.snapshot.id;
5028
+ const skippedLines = [];
4629
5029
  if (deniedCrossOwner) {
4630
- text += '\n跨 owner 私密条目已跳过(' + deniedCrossOwner + ' 条):请用 --agent <id> 声明自己的身份;只有用户显式授权(--unsafe)才能越界维护。';
5030
+ skippedLines.push('跨 owner 私密条目已跳过(' + deniedCrossOwner + ' 条):请用 --agent <id> 声明自己的身份;只有用户显式授权(--unsafe)才能越界维护。');
4631
5031
  }
4632
- return { error: false, text: text };
5032
+ if (skippedCooldown || skippedEvergreen) {
5033
+ skippedLines.push('因冷却期 / 常青豁免跳过 ' + (skippedCooldown + skippedEvergreen) + ' 条(冷却期 ' + skippedCooldown + ' / 常青 ' + skippedEvergreen + ');确认要一并归档时加 --force(immutable 与 BOUND 仍豁免)。');
5034
+ }
5035
+ const report = {
5036
+ schemaVersion: 1,
5037
+ root: root,
5038
+ mode: dryRun ? 'dry-run' : 'apply',
5039
+ days: days,
5040
+ threshold: threshold,
5041
+ cooldown_days: cooldownDays,
5042
+ force: force,
5043
+ candidates: candidates.map(function (c) { return c.entry; }),
5044
+ archived: [],
5045
+ skipped: { cooldown: skippedCooldown, evergreen: skippedEvergreen, cross_owner: deniedCrossOwner },
5046
+ };
5047
+ if (dryRun) {
5048
+ // 预演只读:不动文件、不建事务快照、不写审计、不改索引。
5049
+ const lines = ['预览(未改动): 将归档 ' + candidates.length + ' 条旧记忆到 ' + path.join(root, ARCHIVE_DIR)];
5050
+ lines.push('- 判定: 效用 < ' + threshold + ' 且未使用 > ' + days + ' 天' + (force
5051
+ ? '(--force:冷却期与常青标签不再豁免)'
5052
+ : '(冷却期 ' + cooldownDays + ' 天内与 immutable / BOUND / evergreen / pinned 豁免)'));
5053
+ for (const c of candidates) {
5054
+ lines.push('- [' + c.entry.type + '] ' + c.entry.file + '(' + c.entry.age_days + ' 天龄,utility ' + c.entry.utility + ')— ' + c.entry.subject);
5055
+ }
5056
+ if (!candidates.length) lines.push('(无符合条件的记忆)');
5057
+ lines.push('- 预演只读:未创建事务快照、未写审计、未移动文件;去掉 --dry-run 才执行(执行前先过破坏性写入闸门:doctor + 独立备份目录 + 异卷)。');
5058
+ lines.push.apply(lines, skippedLines);
5059
+ return { error: false, dryRun: true, text: lines.join('\n'), report: report };
5060
+ }
5061
+ if (!candidates.length) {
5062
+ // 无候选 = 无写入:不创建整库事务快照,也不写审计。
5063
+ const lines = ['已归档 0 条旧记忆到 ' + path.join(root, ARCHIVE_DIR), '没有符合条件的记忆:未创建事务快照(无写入)。'];
5064
+ lines.push.apply(lines, skippedLines);
5065
+ return { error: false, text: lines.join('\n'), report: report };
5066
+ }
5067
+ const guard = destructiveGuardCore({
5068
+ root: root,
5069
+ action: 'archive',
5070
+ snapshotDir: opts.snapshotDir,
5071
+ backupDir: opts.backupDir,
5072
+ allowSameVolumeForTest: opts.allowSameVolumeForTest,
5073
+ sameVolumeFn: opts.sameVolumeFn,
5074
+ now: opts.now,
5075
+ });
5076
+ if (guard.error) return { error: true, text: guard.text };
5077
+ const movedFiles = [];
5078
+ for (const c of candidates) {
5079
+ const dest = path.join(root, archiveRelFor(root, c.entry.file, c.entry.type, c.owner));
5080
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
5081
+ fs.renameSync(c.fp, dest);
5082
+ movedFiles.push(c.entry.file);
5083
+ }
5084
+ if (movedFiles.length) removeIndexEntries(root, movedFiles);
5085
+ report.archived = movedFiles;
5086
+ let text = '已归档 ' + movedFiles.length + ' 条旧记忆到 ' + path.join(root, ARCHIVE_DIR) + '\n事务快照: ' + guard.snapshot.id;
5087
+ if (skippedLines.length) text += '\n' + skippedLines.join('\n');
5088
+ return { error: false, text: text, report: report };
4633
5089
  }
4634
5090
 
4635
5091
  // ---- v0.8.0 自我学习/自我进化/自我提升:feedback / explain / maintain / distill ----
@@ -4691,6 +5147,10 @@ function feedbackCore(ref, opts) {
4691
5147
  const before = { weight: round3(weight), confidence: round3(confidence), feedback_net: fb };
4692
5148
  const after = { weight: round3(nw), confidence: round3(nc), feedback_net: nfb };
4693
5149
  const patch = { weight: after.weight, confidence: after.confidence, feedback_net: after.feedback_net, updated: today() };
5150
+ if (useful) {
5151
+ const usageCfg = usageSettings(opts);
5152
+ if (usageCfg.enabled) Object.assign(patch, usagePatchForMeta(meta, 'use', '', usageCfg, today()));
5153
+ }
4694
5154
  rewriteFrontmatter(target.fp, patch, target.root, owner);
4695
5155
  upsertIndexEntry(target.root, readEntry(target.fp, target.root));
4696
5156
  appendAudit(target.root, 'feedback', { ts: new Date().toISOString(), file: target.rel, action: useful ? 'useful' : 'useless', before: before, after: after, reason: opts.reason || '' });
@@ -4734,6 +5194,7 @@ function explainCore(ref, opts) {
4734
5194
  // v0.17.2:跨 owner 私密条目 fail-closed —— 旧行为只是加一句「仅元数据」,
4735
5195
  // 正文(subject / statement)照样输出,等于把别人的私密记忆读给调用方。
4736
5196
  if (deny) return { error: true, text: deny };
5197
+ applyUsageHits(target.root, [target.rel], 'get', '', { selfAgent: selfAgent, noUsage: !!opts.noUsage });
4737
5198
  const ub = utilityBreakdown(entry);
4738
5199
  const ageDays = entry.created ? daysBetween(entry.created, today()) : 0;
4739
5200
  const cfg = loadConfig();
@@ -6157,6 +6618,13 @@ function cmdExplain(ref, opts) {
6157
6618
  if (r.error) process.exit(2);
6158
6619
  }
6159
6620
  function cmdMaintain(opts) {
6621
+ if (opts.capacity) {
6622
+ const r = capacityCore(opts);
6623
+ if (opts.json && r.report) console.log(JSON.stringify(r.report, null, 2));
6624
+ else console.log(r.text);
6625
+ if (r.error) process.exit(2);
6626
+ return;
6627
+ }
6160
6628
  if (opts.merge) {
6161
6629
  const parts = String(opts.merge).split(',').map(function (s) { return s.trim(); }).filter(Boolean);
6162
6630
  if (parts.length < 2) { console.error('--merge 需要两个文件,逗号分隔'); process.exit(2); }
@@ -6205,7 +6673,9 @@ function cmdRename(fileRef, newName, opts) {
6205
6673
  }
6206
6674
  function cmdArchive(opts) {
6207
6675
  const r = archiveCore(opts);
6208
- console.log(r.text);
6676
+ if (opts.json && r.report) console.log(JSON.stringify(r.report, null, 2));
6677
+ else console.log(r.text);
6678
+ if (r.error) process.exit(2);
6209
6679
  }
6210
6680
  function cmdBackupCreate(opts) {
6211
6681
  const r = backupCreateCore(opts);
@@ -6723,6 +7193,7 @@ function contextCore(opts) {
6723
7193
  opts = opts || {};
6724
7194
  const roots = memoryRoots();
6725
7195
  if (!roots.length) return { error: false, exitCode: 0, text: '记忆库不存在,请先运行: yotta-memory init' };
7196
+ if (opts.audit) return contextAuditCore(opts);
6726
7197
  const root = userRoot();
6727
7198
  const limit = parseInt(opts.limit || '10', 10) || 10;
6728
7199
  const ident = resolveIdentity(opts);
@@ -6750,6 +7221,7 @@ function contextCore(opts) {
6750
7221
  const trace = [];
6751
7222
  const lines = [];
6752
7223
  const shownFiles = new Set();
7224
+ const usedFiles = new Set();
6753
7225
  const FENCE = String.fromCharCode(96, 96, 96);
6754
7226
  function usedChars() { return lines.reduce(function (s, x) { return s + String(x).length + 1; }, 0); }
6755
7227
  function appendContextEntry(entry, reason, options) {
@@ -6766,6 +7238,7 @@ function contextCore(opts) {
6766
7238
  }
6767
7239
  lines.push(line);
6768
7240
  shownFiles.add(entry.file);
7241
+ usedFiles.add(entry.file);
6769
7242
  trace.push('[included] ' + entry.file + ' reason: ' + reason);
6770
7243
  return true;
6771
7244
  }
@@ -6847,7 +7320,8 @@ function contextCore(opts) {
6847
7320
  selfAgent: selfAgent,
6848
7321
  years: years,
6849
7322
  embedding: embeddingCommand,
6850
- embeddingTimeout: embeddingTimeout
7323
+ embeddingTimeout: embeddingTimeout,
7324
+ noUsage: true
6851
7325
  });
6852
7326
  const focusedEntries = focused.entries || [];
6853
7327
  if (!focusedEntries.length) lines.push('(无匹配记忆)');
@@ -6914,6 +7388,14 @@ function contextCore(opts) {
6914
7388
  if (!trace.length) lines.push('(无选择记录)');
6915
7389
  for (const t of trace) lines.push(t);
6916
7390
  }
7391
+ // v0.18.0 A3:纳入上下文包的条目记 use 信号(本次调用内按文件去重;--no-usage 可关)。
7392
+ if (usedFiles.size) {
7393
+ const usageOpts = { selfAgent: selfAgent, noUsage: !!opts.noUsage };
7394
+ for (const r of roots) {
7395
+ const rels = Array.from(usedFiles).filter(function (rel) { return fs.existsSync(path.join(r, rel)); });
7396
+ if (rels.length) applyUsageHits(r, rels, 'use', '', usageOpts);
7397
+ }
7398
+ }
6917
7399
  const reliability = doctorCore({ root: root });
6918
7400
  if (reliability.level !== 'ok') {
6919
7401
  lines.push('');
@@ -6923,11 +7405,12 @@ function contextCore(opts) {
6923
7405
  for (const message of reliability.warnings) lines.push('- [警告] ' + message);
6924
7406
  if (!reliability.ok) lines.push('- 破坏性写入已锁定:先运行 yotta-memory doctor 修复严重问题。');
6925
7407
  }
6926
- return { error: false, exitCode: 0, text: lines.join('\n') };
7408
+ return { error: false, exitCode: 0, text: lines.join('\n'), trace: trace };
6927
7409
  }
6928
7410
  function cmdContext(opts) {
6929
7411
  const r = contextCore(opts);
6930
- console.log(r.text);
7412
+ if (opts.json && r.report) console.log(JSON.stringify(r.report, null, 2));
7413
+ else console.log(r.text);
6931
7414
  if (r.error) {
6932
7415
  const reminder = migrationReminderText(userRoot());
6933
7416
  if (reminder) console.log('\n' + reminder);
@@ -6935,6 +7418,221 @@ function cmdContext(opts) {
6935
7418
  if (r.exitCode) process.exit(r.exitCode);
6936
7419
  }
6937
7420
 
7421
+ // ---- v0.18.0 A7:context --audit 上下文压缩审计(只读;不自动补写)----
7422
+ function contextAuditGate(opts) {
7423
+ const raw = Array.isArray(opts.gate) ? opts.gate[opts.gate.length - 1] : opts.gate;
7424
+ if (raw === undefined || raw === null || raw === '') return null;
7425
+ const n = parseInt(raw, 10);
7426
+ return isNaN(n) ? null : n;
7427
+ }
7428
+ function contextAuditReadStdin() {
7429
+ // 只在 fd 0 明确是管道 / socket 时读取,避免测试运行器或交互终端被阻塞。
7430
+ try {
7431
+ const st = fs.fstatSync(0);
7432
+ if (!st.isFIFO() && !st.isSocket()) return null;
7433
+ return fs.readFileSync(0, 'utf8');
7434
+ } catch (e) {
7435
+ return null;
7436
+ }
7437
+ }
7438
+ function contextAuditSegments(text) {
7439
+ const out = [];
7440
+ // 中文信号词为主;补英文决策词,保证中英混排的宿主压缩摘要也能切出候选句。
7441
+ const keywordRe = /决定|敲定|约定|口径|红线|规则|承诺|下一步|待办|遗留|注意|以后|必须|不要|decision|decided|agreement|policy|rule|commit|next step|todo|remaining|note|must|never/i;
7442
+ const sectionRe = /^#{1,6}\s*(决策|下一步|遗留问题|decisions?|next\s*steps?|remaining|todos?)/i;
7443
+ let inSection = false;
7444
+ let inCode = false;
7445
+ for (const raw of String(text || '').split(/\r?\n/)) {
7446
+ const line = raw.trim();
7447
+ if (/^```/.test(line)) { inCode = !inCode; continue; }
7448
+ if (inCode || !line) continue;
7449
+ if (/^#{1,6}\s+/.test(line)) { inSection = sectionRe.test(line); continue; }
7450
+ if (/^\|/.test(line)) continue;
7451
+ const item = line.replace(/^[-*+]\s+/, '').replace(/^\d+[.、)]\s*/, '').trim();
7452
+ if (item.length < 4) continue;
7453
+ if (inSection || keywordRe.test(item)) {
7454
+ if (out.indexOf(item) === -1) out.push(item);
7455
+ }
7456
+ }
7457
+ return out;
7458
+ }
7459
+ function contextAuditCoverage(candidate, entries, minCoverage) {
7460
+ for (const e of entries) {
7461
+ const corpus = String(e.subject || '') + ' ' + String(e.statement || '');
7462
+ if (corpus.indexOf(candidate) !== -1) return { entry: e, coverage: 1, best: e };
7463
+ const stmt = String(e.statement || '');
7464
+ if (stmt.length >= 6 && candidate.indexOf(stmt) !== -1) return { entry: e, coverage: 1, best: e };
7465
+ }
7466
+ const tokens = new Set(tokenize(candidate).filter(function (t) { return t.indexOf(':') === -1; }));
7467
+ if (!tokens.size) return { entry: null, coverage: 0, best: null };
7468
+ let best = null;
7469
+ let bestCoverage = 0;
7470
+ for (const e of entries) {
7471
+ const etoks = new Set(tokenize(String(e.subject || '') + ' ' + String(e.statement || '')).filter(function (t) { return t.indexOf(':') === -1; }));
7472
+ let inter = 0;
7473
+ for (const t of tokens) if (etoks.has(t)) inter++;
7474
+ const coverage = inter / tokens.size;
7475
+ if (coverage > bestCoverage) { bestCoverage = coverage; best = e; }
7476
+ }
7477
+ return { entry: bestCoverage >= minCoverage ? best : null, coverage: bestCoverage, best: best };
7478
+ }
7479
+ function contextAuditRememberCommand(text) {
7480
+ const clean = String(text || '').replace(/"/g, "'").trim();
7481
+ const specificity = clean.replace(/^[^::]*[::]\s*/, '').trim();
7482
+ const subject = contextAuditSuggestionSubject(specificity || clean, 20);
7483
+ return 'yotta-memory remember FACT "' + subject + '" "' + clean + '" --verify';
7484
+ }
7485
+ function contextAuditSuggestionSubject(text, max) {
7486
+ // v0.18.0 修正:截断回退到非标点边界,避免切在「(」这类标点中间。
7487
+ const limit = parseInt(max, 10) || 20;
7488
+ const clean = String(text || '').trim();
7489
+ if (clean.length <= limit) return clean;
7490
+ const cut = clean.slice(0, limit);
7491
+ const trimmed = cut.replace(/[\s,。;:、,.;:!?!?()()【】\[\]「」『』“”'"—–\-]+$/, '');
7492
+ return trimmed.length >= 8 ? trimmed : cut;
7493
+ }
7494
+ function contextAuditCore(opts) {
7495
+ opts = opts || {};
7496
+ const roots = memoryRoots();
7497
+ if (!roots.length) return { error: false, exitCode: 0, text: '记忆库不存在,请先运行: yotta-memory init' };
7498
+ const ident = resolveIdentity(opts);
7499
+ if (ident.error) return { error: true, exitCode: 3, text: ident.error };
7500
+ if (!ident.id) {
7501
+ return {
7502
+ error: true,
7503
+ exitCode: 3,
7504
+ text: 'context --audit 需要先声明身份:CLI 传 --agent <id>;stdio MCP 传 --agent-id <id> + --agent-key-file <path>;HTTP MCP 发送 X-Agent-Id + X-Agent-Key。',
7505
+ };
7506
+ }
7507
+ const selfAgent = ident.id;
7508
+ const owner = opts.owner || selfAgent;
7509
+ const root = userRoot();
7510
+ const keyError = validatePrivateIdentity(root, ident, owner);
7511
+ if (keyError) return { error: true, exitCode: 3, text: keyError };
7512
+ const cfg = loadConfig();
7513
+ const rawMin = parseFloat(cfg.context_audit_min_coverage);
7514
+ const minCoverage = (rawMin > 0 && rawMin <= 1) ? rawMin : 0.5;
7515
+ let input = null;
7516
+ let source = '';
7517
+ if (opts.text !== undefined && opts.text !== null && String(opts.text) !== '') {
7518
+ // v0.18.0:内联文本输入(MCP 侧用它替代文件路径,避免把 --from 变成任意文件读取通道)。
7519
+ input = String(opts.text);
7520
+ source = 'inline';
7521
+ } else if (opts.from !== undefined && opts.from !== null && String(opts.from) !== '') {
7522
+ if (String(opts.from) === '-') {
7523
+ input = fs.readFileSync(0, 'utf8');
7524
+ source = 'stdin';
7525
+ } else {
7526
+ const fp = path.resolve(String(opts.from));
7527
+ if (!fs.existsSync(fp)) return { error: true, exitCode: 2, text: '输入文件不存在: ' + fp };
7528
+ input = fs.readFileSync(fp, 'utf8');
7529
+ source = fp;
7530
+ }
7531
+ } else {
7532
+ // noStdin:MCP(stdio)的 fd 0 是协议流,绝不能被当成待审计内容读走。
7533
+ const piped = opts.noStdin ? null : contextAuditReadStdin();
7534
+ if (piped !== null && piped.trim()) { input = piped; source = 'stdin'; }
7535
+ }
7536
+ const entries = [];
7537
+ for (const r of roots) {
7538
+ for (const e of indexEntriesFor(r, {}).entries) {
7539
+ if (classifyRead(e, owner, '', !!opts.unsafe, selfAgent) === 'denied') continue;
7540
+ entries.push(e);
7541
+ }
7542
+ }
7543
+ const gate = contextAuditGate(opts);
7544
+ if (input === null) {
7545
+ const ctx = contextCore(Object.assign({}, opts, { audit: false, explain: true, noUsage: true }));
7546
+ const drops = (ctx.trace || []).filter(function (t) { return t.indexOf('[dropped]') === 0; });
7547
+ const items = drops.map(function (line) {
7548
+ const m = /^\[dropped\]\s+(\S+)\s+reason:\s+(\S+)/.exec(line);
7549
+ const file = m ? m[1] : '';
7550
+ const reason = m ? m[2] : '';
7551
+ const found = entries.filter(function (e) { return e.file === file; })[0];
7552
+ return {
7553
+ file: file,
7554
+ reason: reason,
7555
+ recallable: !!found,
7556
+ subject: found ? found.subject : '',
7557
+ };
7558
+ });
7559
+ const unrecallable = items.filter(function (x) { return !x.recallable; }).length;
7560
+ const lines = ['## yotta-memory context --audit(丢弃清单审计)', ''];
7561
+ lines.push('- 模式: 无输入内容,审计当前上下文包的丢弃清单(duplicate / budget_exceeded)');
7562
+ lines.push('- 计数: dropped ' + items.length + ' 条 / 仍可召回 ' + (items.length - unrecallable) + ' 条 / 无法召回 ' + unrecallable + ' 条');
7563
+ lines.push('');
7564
+ if (!items.length) lines.push('(本次上下文包无丢弃项。)');
7565
+ for (const item of items) {
7566
+ lines.push('- ' + item.file + '(' + item.reason + ')' + (item.recallable
7567
+ ? ' → 仍可召回: yotta-memory recall "' + String(item.subject).replace(/"/g, "'") + '"'
7568
+ : ' → 未在当前可读索引中找到,请人工核对'));
7569
+ }
7570
+ if (gate !== null) lines.push('', '- --gate ' + gate + ': 无法召回条数 ' + unrecallable + '(超过门槛时 exit 1)');
7571
+ return {
7572
+ error: false,
7573
+ exitCode: (gate !== null && unrecallable > gate) ? 1 : 0,
7574
+ text: lines.join('\n'),
7575
+ report: {
7576
+ schemaVersion: 1,
7577
+ mode: 'dropped',
7578
+ summary: { dropped: items.length, recallable: items.length - unrecallable, unrecallable: unrecallable },
7579
+ items: items,
7580
+ },
7581
+ };
7582
+ }
7583
+ const segments = contextAuditSegments(input);
7584
+ const items = segments.map(function (text) {
7585
+ const cov = contextAuditCoverage(text, entries, minCoverage);
7586
+ let status = 'missing';
7587
+ let matched = null;
7588
+ if (cov.entry) { status = 'landed'; matched = cov.entry; }
7589
+ else if (cov.best && cov.coverage > 0 && cov.coverage >= minCoverage * 0.6) status = 'uncertain';
7590
+ return {
7591
+ text: text,
7592
+ status: status,
7593
+ coverage: round3(cov.coverage),
7594
+ matched_file: matched ? matched.file : '',
7595
+ matched_subject: matched ? matched.subject : '',
7596
+ suggested_command: status === 'missing' ? contextAuditRememberCommand(text) : '',
7597
+ };
7598
+ });
7599
+ const landed = items.filter(function (x) { return x.status === 'landed'; });
7600
+ const missing = items.filter(function (x) { return x.status === 'missing'; });
7601
+ const uncertain = items.filter(function (x) { return x.status === 'uncertain'; });
7602
+ const lines = ['## yotta-memory context --audit(上下文压缩审计)', ''];
7603
+ lines.push('- 来源: ' + source + '(只读;不会自动补写记忆)');
7604
+ lines.push('- 汇总: 候选 ' + items.length + ' 条 / 已落盘 ' + landed.length + ' 条 / 未落盘 ' + missing.length + ' 条 / 无法判定 ' + uncertain.length + ' 条');
7605
+ lines.push('');
7606
+ lines.push('### 未落盘(建议落盘,命令仅打印不执行)');
7607
+ if (!missing.length) lines.push('(无)');
7608
+ for (const item of missing) {
7609
+ lines.push('- ' + item.text.slice(0, 120));
7610
+ lines.push(' → ' + item.suggested_command);
7611
+ }
7612
+ lines.push('');
7613
+ lines.push('### 无法判定(人工核对)');
7614
+ if (!uncertain.length) lines.push('(无)');
7615
+ for (const item of uncertain) lines.push('- ' + item.text.slice(0, 120) + '(覆盖率 ' + item.coverage + ')');
7616
+ lines.push('');
7617
+ lines.push('### 已落盘');
7618
+ if (!landed.length) lines.push('(无)');
7619
+ for (const item of landed) lines.push('- ' + item.text.slice(0, 120) + ' → ' + item.matched_file);
7620
+ if (gate !== null) lines.push('', '- --gate ' + gate + ': 未落盘 ' + missing.length + ' 条(超过门槛时 exit 1)');
7621
+ return {
7622
+ error: false,
7623
+ exitCode: (gate !== null && missing.length > gate) ? 1 : 0,
7624
+ text: lines.join('\n'),
7625
+ report: {
7626
+ schemaVersion: 1,
7627
+ mode: 'input',
7628
+ source: source,
7629
+ thresholds: { min_coverage: minCoverage },
7630
+ summary: { candidates: items.length, landed: landed.length, missing: missing.length, uncertain: uncertain.length },
7631
+ items: items,
7632
+ },
7633
+ };
7634
+ }
7635
+
6938
7636
  // ---- v0.17.0 A1: bench 可复算基准评测(只读 / 本地 / 零依赖)----
6939
7637
  const BENCH_SCHEMA_VERSION = 1;
6940
7638
  const BENCH_DEFAULT_SEED = 20260925;
@@ -8023,17 +8721,18 @@ async function cmdScan(opts) {
8023
8721
 
8024
8722
  // ---- config 命令 ----
8025
8723
  // v0.10.0:maintain_* / consolidate_* 数值键纳入 config set/get(此前只支持 3 个键但文档已写可调)
8026
- function isNumericConfigKey(key) { return /^(maintain_|consolidate_|scale_|backup_max_age_hours$)/.test(key); }
8724
+ function isNumericConfigKey(key) { return /^(maintain_|consolidate_|scale_|backup_max_age_hours$|usage_retention_days$|usage_query_slots$|capacity_|promotion_|context_audit_min_coverage$)/.test(key); }
8027
8725
  // v0.17.0 B2:doctor 规模体检阈值键(0 表示只要超过 0 就告警,便于压测与演练)
8028
- const SCALE_CONFIG_KEYS = ['scale_warn_entries', 'scale_warn_files_per_dir', 'scale_warn_index_bytes', 'scale_warn_cold_start_ms'];
8726
+ const SCALE_CONFIG_KEYS = ['scale_warn_entries', 'scale_warn_files_per_dir', 'scale_warn_index_bytes', 'scale_warn_cold_start_ms', 'scale_info_entries', 'scale_info_files_per_dir', 'scale_info_index_bytes', 'scale_info_cold_start_ms'];
8029
8727
  function cmdConfigSet(key, value) {
8030
- const known = ['memory_home', 'embedding_cmd', 'embedding_timeout', 'backup_dir', 'backup_enabled', 'backup_schedule', 'backup_time', 'backup_max_age_hours', 'backup_setup_choice', 'maintain_archived_utility', 'maintain_archived_age', 'maintain_forget_utility', 'maintain_forget_age', 'maintain_decay_halflife_FACT', 'maintain_decay_halflife_PREF', 'maintain_decay_halflife_COMMIT', 'consolidate_min_age', 'consolidate_min_idle', 'consolidate_max_utility', 'consolidate_min_group', 'consolidate_period'].concat(SCALE_CONFIG_KEYS);
8031
- if (known.indexOf(key) === -1) { console.error('未知配置项: ' + key + '(可用: memory_home / backup_dir / embedding_cmd / embedding_timeout / maintain_* / consolidate_* / scale_*)'); process.exit(2); }
8728
+ const known = ['memory_home', 'embedding_cmd', 'embedding_timeout', 'backup_dir', 'backup_enabled', 'backup_schedule', 'backup_time', 'backup_max_age_hours', 'backup_setup_choice', 'maintain_archived_utility', 'maintain_archived_age', 'maintain_forget_utility', 'maintain_forget_age', 'maintain_decay_halflife_FACT', 'maintain_decay_halflife_PREF', 'maintain_decay_halflife_COMMIT', 'consolidate_min_age', 'consolidate_min_idle', 'consolidate_max_utility', 'consolidate_min_group', 'consolidate_period', 'usage_enabled', 'usage_retention_days', 'usage_query_slots', 'capacity_cooldown_days', 'capacity_warn_bytes', 'capacity_candidate_limit', 'promotion_min_hits', 'promotion_min_queries', 'context_audit_min_coverage'].concat(SCALE_CONFIG_KEYS);
8729
+ if (known.indexOf(key) === -1) { console.error('未知配置项: ' + key + '(可用: memory_home / backup_dir / embedding_cmd / embedding_timeout / maintain_* / consolidate_* / scale_* / usage_* / capacity_* / promotion_*)'); process.exit(2); }
8032
8730
  if (value === undefined || value === null || value === '') { console.error('缺少值: config set ' + key + ' <值>'); process.exit(2); }
8033
8731
  const cfg = loadConfig();
8034
8732
  if (key === 'memory_home') cfg.memory_home = value;
8035
8733
  else if (key === 'backup_dir') cfg.backup_dir = value;
8036
8734
  else if (key === 'backup_enabled') cfg.backup_enabled = value !== 'false' && value !== '0';
8735
+ else if (key === 'usage_enabled') cfg.usage_enabled = value !== 'false' && value !== '0';
8037
8736
  else if (key === 'backup_schedule' || key === 'backup_time' || key === 'backup_setup_choice') cfg[key] = value;
8038
8737
  else if (key === 'embedding_cmd') cfg.embedding_cmd = value;
8039
8738
  else if (key === 'embedding_timeout') cfg.embedding_timeout = parseInt(value, 10) || 3000;
@@ -8059,11 +8758,12 @@ function cmdConfigGet(opts) {
8059
8758
  console.log('backup_enabled: ' + (cfg.backup_enabled === undefined ? '(未设置)' : cfg.backup_enabled));
8060
8759
  console.log('backup_schedule: ' + (cfg.backup_schedule || '(未设置)'));
8061
8760
  console.log('backup_time: ' + (cfg.backup_time || '(未设置)'));
8761
+ console.log('usage_enabled: ' + (cfg.usage_enabled === undefined ? '(默认 true)' : cfg.usage_enabled));
8062
8762
  console.log('embedding_cmd: ' + (cfg.embedding_cmd || '(未设置)'));
8063
8763
  console.log('embedding_timeout: ' + (cfg.embedding_timeout || 3000));
8064
- const keys = ['memory_home', 'backup_dir', 'backup_enabled', 'backup_schedule', 'backup_time', 'backup_max_age_hours', 'backup_setup_choice', 'embedding_cmd', 'embedding_timeout', 'maintain_archived_utility', 'maintain_archived_age', 'maintain_forget_utility', 'maintain_forget_age', 'maintain_decay_halflife_FACT', 'maintain_decay_halflife_PREF', 'maintain_decay_halflife_COMMIT', 'consolidate_min_age', 'consolidate_min_idle', 'consolidate_max_utility', 'consolidate_min_group', 'consolidate_period'].concat(SCALE_CONFIG_KEYS);
8764
+ const keys = ['memory_home', 'backup_dir', 'backup_enabled', 'backup_schedule', 'backup_time', 'backup_max_age_hours', 'backup_setup_choice', 'embedding_cmd', 'embedding_timeout', 'maintain_archived_utility', 'maintain_archived_age', 'maintain_forget_utility', 'maintain_forget_age', 'maintain_decay_halflife_FACT', 'maintain_decay_halflife_PREF', 'maintain_decay_halflife_COMMIT', 'consolidate_min_age', 'consolidate_min_idle', 'consolidate_max_utility', 'consolidate_min_group', 'consolidate_period', 'usage_enabled', 'usage_retention_days', 'usage_query_slots', 'capacity_cooldown_days', 'capacity_warn_bytes', 'capacity_candidate_limit', 'promotion_min_hits', 'promotion_min_queries', 'context_audit_min_coverage'].concat(SCALE_CONFIG_KEYS);
8065
8765
  for (const k of keys) {
8066
- if (k === 'memory_home' || k === 'backup_dir' || k === 'backup_enabled' || k === 'backup_schedule' || k === 'backup_time' || k === 'embedding_cmd' || k === 'embedding_timeout') continue;
8766
+ if (k === 'memory_home' || k === 'backup_dir' || k === 'backup_enabled' || k === 'backup_schedule' || k === 'backup_time' || k === 'embedding_cmd' || k === 'embedding_timeout' || k === 'usage_enabled') continue;
8067
8767
  if (cfg[k] !== undefined) console.log(k + ': ' + cfg[k]);
8068
8768
  }
8069
8769
  console.log('当前生效用户级位置: ' + userRoot());
@@ -8080,17 +8780,18 @@ function mcpTools(profile) {
8080
8780
  { name: 'remember', description: '写入一条记忆。参数 type(FACT/PREF/BOUND/COMMIT)、subject、statement 必填;owner 可选,默认当前智能体', inputSchema: { type: 'object', properties: { type: { type: 'string', description: 'FACT/PREF/BOUND/COMMIT' }, subject: { type: 'string' }, statement: { type: 'string' }, owner: { type: 'string' } }, required: ['type', 'subject', 'statement'] } },
8081
8781
  { name: 'recall', description: '检索记忆。query 可选;type 可选;limit 可选(默认 20);explain 可选。只返回当前智能体可读记忆;embedding 插件只能由本机 config 配置,远端不可传命令', inputSchema: { type: 'object', properties: { query: { type: 'string' }, type: { type: 'string' }, limit: { type: 'number' }, embeddingTimeout: { type: 'number' }, explain: { type: 'boolean' } } } },
8082
8782
  { name: 'search', description: '检索记忆(同 recall)。query 可选;type 可选;limit 可选(默认 20);explain 可选;embedding 插件只能由本机 config 配置,远端不可传命令', inputSchema: { type: 'object', properties: { query: { type: 'string' }, type: { type: 'string' }, limit: { type: 'number' }, embeddingTimeout: { type: 'number' }, explain: { type: 'boolean' } } } },
8083
- { name: 'context', description: '生成开工上下文包。focus 可选;limit 可选;budget 可选;explain 可选;embedding 插件只能由本机 config 配置,远端不可传命令', inputSchema: { type: 'object', properties: { focus: { type: 'string' }, limit: { type: 'number' }, budget: { type: 'number' }, explain: { type: 'boolean' }, embeddingTimeout: { type: 'number' } } } },
8783
+ { name: 'context', description: '生成开工上下文包。focus 可选;limit 可选;budget 可选;explain 可选;audit=true 时改为上下文压缩审计(用 auditText 传待审计文本,只读),gate 控制未落盘条数门槛;embedding 插件只能由本机 config 配置,远端不可传命令', inputSchema: { type: 'object', properties: { focus: { type: 'string' }, limit: { type: 'number' }, budget: { type: 'number' }, explain: { type: 'boolean' }, embeddingTimeout: { type: 'number' }, audit: { type: 'boolean' }, auditText: { type: 'string' }, gate: { type: 'number' } } } },
8084
8784
  { name: 'doctor', description: '开工可靠性检查:检查记忆库根目录、密钥库、索引、身份登记与最近备份;只读,不会修改记忆', inputSchema: { type: 'object', properties: {} } },
8085
8785
  { name: 'forget', description: '删除一条记忆。file 为记忆文件路径(如 facts/2026-08-24-0001.md 或文件名)', inputSchema: { type: 'object', properties: { file: { type: 'string' } }, required: ['file'] } },
8086
- { name: 'archive', description: '归档旧记忆。days 默认 180;threshold 默认 0.4', inputSchema: { type: 'object', properties: { days: { type: 'number' }, threshold: { type: 'number' } } } },
8786
+ { name: 'archive', description: '归档旧记忆。days 默认 180;threshold 默认 0.4;dryRun=true 只预览、不落盘。破坏性覆盖 --force 仅本机 CLI 提供(MCP 不接受)', inputSchema: { type: 'object', properties: { days: { type: 'number' }, threshold: { type: 'number' }, dryRun: { type: 'boolean' } } } },
8087
8787
  { name: 'reindex', description: '重建索引(手动改 .md 后校正;扫描 facts/prefs/bounds/commits 四目录)', inputSchema: { type: 'object', properties: {} } },
8088
8788
  { name: 'export', description: '导出全部记忆到记忆库内的 JSON 文件。out 可选(默认 <记忆库>/yottamemory-export-<日期>.json;仅限记忆库内路径)', inputSchema: { type: 'object', properties: { out: { type: 'string' } } } },
8089
8789
  { name: 'import', description: '从记忆库内的 JSON 文件导入记忆。src 为文件路径(相对记忆库目录,或记忆库内绝对路径;仅限记忆库内)', inputSchema: { type: 'object', properties: { src: { type: 'string' } }, required: ['src'] } },
8090
8790
  { name: 'agent_info', description: '查看当前智能体身份与登记状态(HTTP 读经 token 校验的 X-Agent-Id;stdio 读 --agent-id 显式参数)。开工先确认「我是谁」,禁止从记忆里抄别人的 ID', inputSchema: { type: 'object', properties: {} } },
8091
8791
  { name: 'profile', description: '生成当前智能体的用户画像(只读聚合 private/<owner>/ 下 PREF/BOUND/COMMIT 原文,零推断,写入 private/<owner>/profile.md)。owner 默认当前智能体', inputSchema: { type: 'object', properties: { owner: { type: 'string' } } } },
8092
8792
  { name: 'feedback', description: '显式使用反馈(自我学习):useful/useless 调整记忆 weight/confidence/feedback_net。file 为记忆文件路径或文件名', inputSchema: { type: 'object', properties: { file: { type: 'string' }, useful: { type: 'boolean' }, useless: { type: 'boolean' }, reason: { type: 'string' } }, required: ['file'] } },
8093
- { name: 'maintain', description: '记忆自组织(自我进化):规则层归档/遗忘/去重预览。默认 dry-run;apply 才执行,purge 才真删', inputSchema: { type: 'object', properties: { apply: { type: 'boolean' }, purge: { type: 'boolean' }, dedup: { type: 'boolean' }, threshold: { type: 'number' }, age: { type: 'number' } } } },
8793
+ { name: 'maintain', description: '记忆自组织(自我进化):规则层归档/遗忘/去重预览。默认 dry-run;apply 才执行,purge 才真删;capacity=true 改为只读容量水位报告', inputSchema: { type: 'object', properties: { apply: { type: 'boolean' }, purge: { type: 'boolean' }, dedup: { type: 'boolean' }, capacity: { type: 'boolean' }, limit: { type: 'number' }, threshold: { type: 'number' }, age: { type: 'number' } } } },
8794
+ { name: 'consolidate', description: '周期摘要压缩候选报告(只读 propose 模式)。MCP 不提供 --apply / --undo / --batches;执行与回滚请在本机 CLI 由用户确认后运行', inputSchema: { type: 'object', properties: { minAge: { type: 'number' }, minIdle: { type: 'number' }, maxUtility: { type: 'number' }, minGroup: { type: 'number' }, period: { type: 'number' }, type: { type: 'string' } } } },
8094
8795
  { name: 'distill', description: '心理日志蒸馏(自我提升):统计摘要/主题画像/知识地图。owner 默认当前智能体(MCP 不支持 --model 外部命令)', inputSchema: { type: 'object', properties: { owner: { type: 'string' }, subject: { type: 'string' } } } },
8095
8796
  { name: 'explain', description: '解释单条记忆效用分项(为什么靠前/归档/遗忘)。file 为记忆文件路径或文件名', inputSchema: { type: 'object', properties: { file: { type: 'string' } }, required: ['file'] } },
8096
8797
  ];
@@ -8132,6 +8833,16 @@ function callToolInner(name, args, ctx) {
8132
8833
  return { text: r.text, error: r.error };
8133
8834
  }
8134
8835
  if (name === 'context') {
8836
+ if (args.audit) {
8837
+ // v0.18.0 A7:MCP 侧只接受内联文本(不接受文件路径,避免把 --from 变成任意文件读取通道),
8838
+ // 也不读 stdin(stdio MCP 的 fd 0 是协议流)。
8839
+ const auditText = (args.auditText !== undefined && args.auditText !== null) ? String(args.auditText) : '';
8840
+ if (!auditText.trim()) {
8841
+ return { text: '拒绝: MCP 的 context audit 只接受内联文本(auditText)。审计本地文件或标准输入请在本机 CLI 运行 yotta-memory context --audit --from <文件|->。', error: true };
8842
+ }
8843
+ const r = contextAuditCore({ selfAgent: agent, owner: agent, text: auditText, gate: args.gate, noStdin: true });
8844
+ return { text: r.text, error: r.error };
8845
+ }
8135
8846
  const r = contextCore({
8136
8847
  focus: args.focus ? String(args.focus) : '',
8137
8848
  limit: args.limit || 10,
@@ -8151,7 +8862,7 @@ function callToolInner(name, args, ctx) {
8151
8862
  return { text: r.text, error: r.error };
8152
8863
  }
8153
8864
  if (name === 'archive') {
8154
- const r = archiveCore({ selfAgent: agent, days: args.days, threshold: args.threshold });
8865
+ const r = archiveCore({ selfAgent: agent, days: args.days, threshold: args.threshold, dryRun: !!args.dryRun });
8155
8866
  return { text: r.text, error: r.error };
8156
8867
  }
8157
8868
  if (name === 'reindex') {
@@ -8208,7 +8919,17 @@ function callToolInner(name, args, ctx) {
8208
8919
  return { text: r.text, error: r.error };
8209
8920
  }
8210
8921
  if (name === 'maintain') {
8211
- const r = maintainCore({ selfAgent: agent, apply: !!args.apply, purge: !!args.purge, dedup: !!args.dedup, threshold: args.threshold, age: args.age });
8922
+ const r = args.capacity
8923
+ ? capacityCore({ selfAgent: agent, limit: args.limit })
8924
+ : maintainCore({ selfAgent: agent, apply: !!args.apply, purge: !!args.purge, dedup: !!args.dedup, threshold: args.threshold, age: args.age });
8925
+ return { text: r.text, error: r.error };
8926
+ }
8927
+ if (name === 'consolidate') {
8928
+ // v0.18.0 A5:MCP 只读 propose;apply / undo / batches 留在本机 CLI(由用户确认后执行)。
8929
+ if (args.apply || args.undo || args.batches || args.yes) {
8930
+ return { text: '拒绝: MCP 的 consolidate 只提供只读 propose 报告;--apply / --undo / --batches 请在本机 CLI 由用户确认后运行(AI 不得代替用户执行 --apply)。', error: true };
8931
+ }
8932
+ const r = consolidateCore({ selfAgent: agent, apply: false, minAge: args.minAge, minIdle: args.minIdle, maxUtility: args.maxUtility, minGroup: args.minGroup, period: args.period, type: args.type });
8212
8933
  return { text: r.text, error: r.error };
8213
8934
  }
8214
8935
  if (name === 'distill') {
@@ -9150,6 +9871,37 @@ function applyConsolidateGroup(root, group, batch, period) {
9150
9871
  return { made: 1, moved: moved, summaryRel: summaryRel };
9151
9872
  }
9152
9873
 
9874
+ // v0.18.0 A5:首次启用说明按 .archive 审计判定(不新建标记文件)。
9875
+ function consolidateHasManifest(root) {
9876
+ const dir = path.join(root, ARCHIVE_DIR);
9877
+ let names;
9878
+ try { names = fs.readdirSync(dir); } catch (e) { return false; }
9879
+ for (const name of names) {
9880
+ if (!/^audit-\d{4}-\d{2}-\d{2}\.jsonl$/.test(name)) continue;
9881
+ let raw;
9882
+ try { raw = fs.readFileSync(path.join(dir, name), 'utf8'); } catch (e) { continue; }
9883
+ for (const line of raw.split('\n')) {
9884
+ if (!line.trim()) continue;
9885
+ let rec;
9886
+ try { rec = JSON.parse(line); } catch (e) { continue; }
9887
+ if (rec && rec.action === 'manifest' && rec.command === 'consolidate') return true;
9888
+ }
9889
+ }
9890
+ return false;
9891
+ }
9892
+ function consolidateFirstRunNotice() {
9893
+ return [
9894
+ '### 首次启用说明(只显示一次)',
9895
+ '- 保留期:原文移入 .archive/ 后一直保留,直到你显式 purge 或 undo;不会自动删除。',
9896
+ '- 检查:yotta-memory consolidate(默认 --propose,只出报告不写盘)。',
9897
+ '- 纠正:yotta-memory consolidate --undo <batch>。',
9898
+ '- 导出:yotta-memory export。',
9899
+ '- 停用:不再执行 --apply,或用 yotta-memory config set consolidate_min_age 99999 抬高门槛。',
9900
+ '- 删除:yotta-memory maintain --purge(对遗忘候选真删)。',
9901
+ '- AI 不得代替用户执行 --apply;--apply 需要确认,非交互必须显式加 --yes。',
9902
+ ];
9903
+ }
9904
+
9153
9905
  function consolidateCore(opts) {
9154
9906
  opts = opts || {};
9155
9907
  if (opts.undo) return consolidateUndoCore(String(opts.undo), opts);
@@ -9157,6 +9909,15 @@ function consolidateCore(opts) {
9157
9909
  const root = userRoot();
9158
9910
  if (!fs.existsSync(root)) return { error: false, text: '记忆库不存在。' };
9159
9911
  const apply = !!opts.apply;
9912
+ // v0.18.0 A5:--apply 是破坏性写入;非交互必须显式 --yes,交互确认在 cmdConsolidate 完成。
9913
+ if (apply && !opts.yes) {
9914
+ return {
9915
+ error: true,
9916
+ exitCode: 2,
9917
+ text: '拒绝: consolidate --apply 会移动原文并生成摘要(原文保留在 .archive/,可用 --undo 回滚)。请先运行 yotta-memory consolidate 审阅候选报告;交互式执行会要求输入确认串,非交互必须显式加 --yes。',
9918
+ };
9919
+ }
9920
+ const firstRunNotice = consolidateHasManifest(root) ? [] : consolidateFirstRunNotice();
9160
9921
  const selfAgent = resolveIdentity(opts).id;
9161
9922
  const unsafe = !!opts.unsafe;
9162
9923
  const cfg = loadConfig();
@@ -9168,7 +9929,7 @@ function consolidateCore(opts) {
9168
9929
  const period = (opts.period !== undefined && opts.period !== null) ? opts.period : (parseInt(cfg.consolidate_period || '90', 10) || 90);
9169
9930
  const typeOnly = opts.type ? String(opts.type).toUpperCase() : '';
9170
9931
  const lines = ['## yotta-memory consolidate(周期摘要压缩)'];
9171
- lines.push('- 模式: ' + (apply ? '执行' : '预览(dry-run,未改动;加 --apply 执行)'));
9932
+ lines.push('- 模式: ' + (apply ? 'apply(执行压缩)' : 'propose(只出报告,未改动;确认后加 --apply --yes 执行)'));
9172
9933
  lines.push('- 候选: 天龄 ≥ ' + minAge + ' 天 ∧ 闲置 ≥ ' + minIdle + ' 天 ∧ utility ≤ ' + maxU + (typeOnly ? ' ∧ 仅类型 ' + typeOnly : '') + ';immutable / BOUND 豁免');
9173
9934
  lines.push('');
9174
9935
  const cands = [];
@@ -9191,16 +9952,59 @@ function consolidateCore(opts) {
9191
9952
  if (age < minAge || idle < minIdle || u > maxU) { stats.active++; continue; }
9192
9953
  cands.push({ e: e, owner: owner, type: e.type, age: age, idle: idle, utility: u, scope: e.scope === 'private' ? 'private' : 'public' });
9193
9954
  }
9955
+ const makeConsolidateGroup = function (g) {
9956
+ const type = g[0].type;
9957
+ const owner = g[0].scope === 'private' ? (g[0].owner || '') : '';
9958
+ const ages = g.map(function (c) { return c.age; });
9959
+ const theme = pickSummaryTheme(g);
9960
+ return {
9961
+ type: type,
9962
+ scope: g[0].scope === 'private' ? 'private' : 'public',
9963
+ owner: owner,
9964
+ count: g.length,
9965
+ age_min: Math.min.apply(null, ages),
9966
+ age_max: Math.max.apply(null, ages),
9967
+ avg_utility: round3(g.reduce(function (s, c) { return s + c.utility; }, 0) / g.length),
9968
+ theme: theme,
9969
+ summary_subject: '周期摘要 ' + theme + '(' + period + ' 天窗)',
9970
+ files: g.map(function (c) { return c.e.file; }),
9971
+ archive_to: path.relative(root, archiveDirFor(root, type, owner)).replace(/\\/g, '/'),
9972
+ };
9973
+ };
9974
+ const makeConsolidateReport = function (builtGroups, batch) {
9975
+ return {
9976
+ schemaVersion: 1,
9977
+ mode: apply ? 'apply' : 'propose',
9978
+ batch: batch || '',
9979
+ root: root,
9980
+ thresholds: {
9981
+ min_age: minAge,
9982
+ min_idle: minIdle,
9983
+ max_utility: maxU,
9984
+ min_group: minGroup,
9985
+ period: period,
9986
+ },
9987
+ stats: stats,
9988
+ candidates: cands.length,
9989
+ groups: builtGroups.map(makeConsolidateGroup),
9990
+ retention: '原文移入 .archive/ 后一直保留,直到显式 purge 或 undo。',
9991
+ rollback: batch
9992
+ ? ('yotta-memory consolidate --undo ' + batch)
9993
+ : 'yotta-memory consolidate --undo <batch>(执行后批次号见报告)',
9994
+ };
9995
+ };
9194
9996
  if (!cands.length) {
9195
9997
  lines.push('(无可压缩候选;扫描 ' + stats.scan + ' 条,跳过: 其它 owner/无密钥 ' + stats.denied + ' / immutable ' + stats.immutable + ' / BOUND ' + stats.bound + ' / 类型过滤 ' + stats.typeSkip + ' / 无 created ' + stats.noCreated + ' / 不够老或仍活跃 ' + stats.active + ')');
9196
- return { error: false, text: lines.join('\n') };
9998
+ if (firstRunNotice.length) { lines.push(''); lines.push.apply(lines, firstRunNotice); }
9999
+ return { error: false, text: lines.join('\n'), report: makeConsolidateReport([], '') };
9197
10000
  }
9198
10001
  const groups = clusterConsolidateCandidates(cands);
9199
10002
  lines.push('候选 ' + cands.length + ' 条(扫描 ' + stats.scan + ',跳过: 其它 owner/无密钥 ' + stats.denied + ' / immutable ' + stats.immutable + ' / BOUND ' + stats.bound + ' / 类型过滤 ' + stats.typeSkip + ' / 无 created ' + stats.noCreated + ' / 不够老或仍活跃 ' + stats.active + ')');
9200
10003
  if (!groups.length) {
9201
10004
  lines.push('');
9202
10005
  lines.push('(无可归纳组:同主题聚类 ≥ ' + minGroup + ' 条才生成周期摘要;零散单条旧记忆请用 maintain --apply 归档)');
9203
- return { error: false, text: lines.join('\n') };
10006
+ if (firstRunNotice.length) { lines.push(''); lines.push.apply(lines, firstRunNotice); }
10007
+ return { error: false, text: lines.join('\n'), report: makeConsolidateReport([], '') };
9204
10008
  }
9205
10009
  lines.push('');
9206
10010
  lines.push('### 周期摘要组(' + groups.length + ' 组,每组将生成摘要 1 条 + 归档原文 N 条)');
@@ -9211,12 +10015,16 @@ function consolidateCore(opts) {
9211
10015
  const minA = Math.min.apply(null, ages), maxA = Math.max.apply(null, ages);
9212
10016
  const avgU = round3(g.reduce(function (s, c) { return s + c.utility; }, 0) / g.length);
9213
10017
  lines.push('- [' + type + '] 主题: ' + pickSummaryTheme(g) + '(' + scopeLabel + ',' + g.length + ' 条,' + minA + '~' + maxA + ' 天,平均效用 ' + avgU + ')' + (type === 'COMMIT' ? ' ⚠️ COMMIT 承诺/任务类,请确认' : ''));
10018
+ lines.push(' → 将生成 周期摘要 ' + pickSummaryTheme(g) + '(' + period + ' 天窗),原文归档到 ' + path.relative(root, archiveDirFor(root, type, g[0].owner || '')).replace(/\\/g, '/'));
9214
10019
  for (const c of g) lines.push(' · ' + c.e.file + ' — ' + String(c.e.subject || '').slice(0, 32) + ': ' + String(c.e.statement || '').slice(0, 60));
9215
10020
  }
9216
10021
  if (!apply) {
9217
10022
  lines.push('');
9218
- lines.push('dry-run 未执行任何变更。确认后加 --apply 执行(写入批次审计,可 consolidate --undo <batch> 回滚)。');
9219
- return { error: false, text: lines.join('\n') };
10023
+ lines.push('- 保留期: 原文移入 .archive/ 后一直保留,直到显式 purge 或 undo。');
10024
+ lines.push('- 回滚: 执行后可用 yotta-memory consolidate --undo <batch> 恢复。');
10025
+ lines.push('未执行任何变更。确认后加 --apply(非交互再加 --yes)执行。');
10026
+ if (firstRunNotice.length) { lines.push(''); lines.push.apply(lines, firstRunNotice); }
10027
+ return { error: false, text: lines.join('\n'), report: makeConsolidateReport(groups, '') };
9220
10028
  }
9221
10029
  const guard = destructiveGuardCore({
9222
10030
  root: root,
@@ -9241,12 +10049,35 @@ function consolidateCore(opts) {
9241
10049
  }
9242
10050
  appendAudit(root, 'audit', { action: 'batch_done', ts: new Date().toISOString(), batch: batch, command: 'consolidate', summaries: made, archived: moved, active_after: collectEntryFiles(root).length });
9243
10051
  lines.push('- 完成: 生成摘要 ' + made + ' 条 / 原文归档 ' + moved + ' 条');
9244
- return { error: false, text: lines.join('\n') };
9245
- }
9246
-
9247
- function cmdConsolidate(opts) {
10052
+ if (firstRunNotice.length) { lines.push(''); lines.push.apply(lines, firstRunNotice); }
10053
+ return { error: false, text: lines.join('\n'), report: makeConsolidateReport(groups, batch) };
10054
+ }
10055
+
10056
+ async function cmdConsolidate(opts) {
10057
+ // v0.18.0 A5:交互式确认串(X 组 / Y 条);非交互明确拒绝并提示 --yes。
10058
+ if (opts.apply && !opts.yes) {
10059
+ if (!process.stdin.isTTY) {
10060
+ const blocked = consolidateCore(opts);
10061
+ console.log(blocked.text);
10062
+ process.exit(blocked.exitCode || 2);
10063
+ }
10064
+ const plan = consolidateCore(Object.assign({}, opts, { apply: false }));
10065
+ const groups = (plan.report && plan.report.groups) || [];
10066
+ const items = groups.reduce(function (s, g) { return s + g.count; }, 0);
10067
+ if (groups.length) {
10068
+ const token = groups.length + ' 组 / ' + items + ' 条';
10069
+ const typed = await promptLine('即将把 ' + token + ' 压缩为摘要(原文保留在 .archive/,可 --undo 回滚)。输入「' + token + '」确认: ');
10070
+ if (typed === null || String(typed).trim() !== token) {
10071
+ console.log('已取消,未做任何改动。');
10072
+ process.exit(2);
10073
+ }
10074
+ }
10075
+ opts = Object.assign({}, opts, { yes: true });
10076
+ }
9248
10077
  const r = consolidateCore(opts);
9249
- console.log(r.text);
10078
+ if (opts.json && r.report) console.log(JSON.stringify(r.report, null, 2));
10079
+ else console.log(r.text);
10080
+ if (r.exitCode) process.exit(r.exitCode);
9250
10081
  if (r.error) process.exit(2);
9251
10082
  }
9252
10083
 
@@ -9539,6 +10370,10 @@ async function main() {
9539
10370
  else if (a === '--yes') opts.yes = true;
9540
10371
  else if (a === '--keep-memories') opts.keepMemories = true;
9541
10372
  else if (a === '--keep-identity') opts.keepIdentity = true;
10373
+ else if (a === '--no-usage') opts.noUsage = true;
10374
+ else if (a === '--capacity') opts.capacity = true;
10375
+ else if (a === '--propose') opts.propose = true;
10376
+ else if (a === '--audit') opts.audit = true;
9542
10377
  else if (valueOpts.has(a)) {
9543
10378
  const v = args[++i];
9544
10379
  if (a === '--type') opts.type = v;
@@ -9593,6 +10428,7 @@ async function main() {
9593
10428
  else if (a === '--against') opts.against = v;
9594
10429
  else if (a === '--template') opts.template = v;
9595
10430
  else if (a === '--path') opts.path = v;
10431
+ else if (a === '--from') opts.from = v;
9596
10432
  } else if (a.startsWith('--')) {
9597
10433
  if (a === '--query') {
9598
10434
  console.error('未知选项: --query。recall/search 的关键词是位置参数:yotta-memory recall [关键词]');
@@ -9649,7 +10485,7 @@ async function main() {
9649
10485
  case 'scan': await cmdScan(opts); break;
9650
10486
  case 'maintain': cmdMaintain(opts); break;
9651
10487
  case 'distill': cmdDistill(opts); break;
9652
- case 'consolidate': if (opts.undo === true && rest.length) opts.undo = rest[0]; cmdConsolidate(opts); break;
10488
+ case 'consolidate': if (opts.undo === true && rest.length) opts.undo = rest[0]; await cmdConsolidate(opts); break;
9653
10489
  case 'forget': cmdForget(rest[0], opts); break;
9654
10490
  case 'rename': cmdRename(rest[0], rest[1], opts); break;
9655
10491
  case 'archive': cmdArchive(opts); break;
@@ -9737,10 +10573,19 @@ module.exports = {
9737
10573
  recallCore: recallCore,
9738
10574
  profileCore: profileCore,
9739
10575
  contextCore: contextCore,
10576
+ contextAuditCore: contextAuditCore,
9740
10577
  importanceScore: importanceScore,
10578
+ usageSettings: usageSettings,
10579
+ usageTotals: usageTotals,
10580
+ usagePatchForMeta: usagePatchForMeta,
10581
+ applyUsageHits: applyUsageHits,
10582
+ parseHitDays: parseHitDays,
10583
+ getOwnerKeyFor: getOwnerKeyFor,
10584
+ loadOwnerIndex: loadOwnerIndex,
9741
10585
  forgetCore: forgetCore,
9742
10586
  renameCore: renameCore,
9743
10587
  archiveCore: archiveCore,
10588
+ capacityCore: capacityCore,
9744
10589
  maintainCore: maintainCore,
9745
10590
  consolidateCore: consolidateCore,
9746
10591
  mergeCore: mergeCore,
@@ -9760,6 +10605,8 @@ module.exports = {
9760
10605
  migrateLayout: migrateLayout,
9761
10606
  loadIndexFor: loadIndexFor,
9762
10607
  indexEntriesFor: indexEntriesFor,
10608
+ loadOwnerIndex: loadOwnerIndex,
10609
+ getOwnerKeyFor: getOwnerKeyFor,
9763
10610
  normalizeYearList: normalizeYearList,
9764
10611
  indexFingerprint: indexFingerprint,
9765
10612
  benchCore: benchCore,
@@ -9782,6 +10629,10 @@ module.exports = {
9782
10629
  SCALE_CONFIG_KEYS: SCALE_CONFIG_KEYS,
9783
10630
  SCALE_THRESHOLD_DEFAULTS: SCALE_THRESHOLD_DEFAULTS,
9784
10631
  scaleReport: scaleReport,
10632
+ USAGE_CONFIG_DEFAULTS: USAGE_CONFIG_DEFAULTS,
10633
+ usageSettings: usageSettings,
10634
+ usageTotals: usageTotals,
10635
+ applyUsageHits: applyUsageHits,
9785
10636
  runtimeRoot: runtimeRoot,
9786
10637
  runtimeManifestPath: runtimeManifestPath,
9787
10638
  runtimeVersionsDir: runtimeVersionsDir,