@yottameta/yotta-memory 0.17.4 → 0.18.1

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.1';
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', '--rules', '--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,8 @@ const HELP_MODEL = [
115
117
  helpOption('--json', '', '输出 JSON', '脚本或自动化读取体检结果时', ''),
116
118
  ] },
117
119
  { name: 'maintain', usage: 'maintain [选项]', what: '记忆自组织:归档、遗忘候选、去重和合并', when: '定期清理低价值记忆、合并重复条目时', options: [
120
+ helpOption('--capacity', '', '查看容量水位与淘汰 / 晋升建议', '库变大,想看哪些条目值得清理或提升时', '只读报告,不会自动归档或改权重'),
121
+ helpOption('--rules', '', '查看重复踩坑 → 建议升级为规则的清单', '同一类问题反复出现、想把它固化成 BOUND 规则时', '只读报告,不自动写规则;阈值走 config maintain_rule_min_hits'),
118
122
  helpOption('--dedup', '', '只做重复检查并给置信度', '想先看哪些条目重复时', ''),
119
123
  helpOption('--apply', '', '真正执行,而不是只预览', '确认预览结果后要落盘时', '会改动记忆库并创建事务快照;先跑 dry-run'),
120
124
  helpOption('--purge', '', '对遗忘候选执行真删', '确认候选确实不再需要时', '真删不可逆;通常先用归档而不是 purge'),
@@ -126,8 +130,10 @@ const HELP_MODEL = [
126
130
  helpOption('--merge', '<id列表>', '显式合并指定条目', '已经确认这些条目可以合并时', ''),
127
131
  helpOption('--json', '', '输出 JSON', '自动化处理维护结果时', ''),
128
132
  ] },
129
- { name: 'consolidate', usage: 'consolidate [--apply | --undo <batch> | --batches] [选项]', what: '把旧记忆压缩成周期摘要', when: '库很大,想把超龄、闲置、低效用条目压成摘要时', options: [
130
- helpOption('--apply', '', '真正执行压缩', '确认 dry-run 结果后要落盘时', '会改动记忆库并创建事务快照;先预览'),
133
+ { name: 'consolidate', usage: 'consolidate [--propose | --apply | --undo <batch> | --batches] [选项]', what: '把旧记忆压缩成周期摘要', when: '库很大,想把超龄、闲置、低效用条目压成摘要时', options: [
134
+ helpOption('--propose', '', '只输出结构化候选报告(默认行为)', '想先审阅会压缩哪些内容、保留与回滚口径时', '不写盘;等价于直接运行 consolidate'),
135
+ helpOption('--apply', '', '真正执行压缩', '确认 propose 报告后要落盘时', '会改动记忆库并创建事务快照;先预览'),
136
+ helpOption('--yes', '', '非交互确认执行', '脚本或自动化环境里已经审阅过 propose 报告时', '跳过交互确认;破坏性操作,务必先看报告'),
131
137
  helpOption('--undo', '<batch>', '回滚一个摘要批次', '压缩后发现结果不合适时', ''),
132
138
  helpOption('--batches', '', '列出历史批次', '想找回滚批次号时', ''),
133
139
  helpOption('--period', '<天数>', '设置摘要周期', '想改变摘要覆盖的时间跨度时', ''),
@@ -141,6 +147,8 @@ const HELP_MODEL = [
141
147
  { name: 'distill', usage: 'distill [--model <命令>] [选项]', what: '把心理日志蒸馏成统计摘要、主题画像和知识地图', when: '想把长期记录整理成更高层的自我画像时', options: [
142
148
  helpOption('--model', '<命令>', '调用本地外部模型辅助蒸馏', '需要更高质量摘要、且已准备本地模型命令时', '命令在本地执行;不要指向会外传原文的服务'),
143
149
  helpOption('--subject', '<标题>', '指定蒸馏输出主题', '想给这次摘要一个固定标题时', ''),
150
+ helpOption('--owner', '<id>', '指定蒸馏归属的智能体', '想蒸馏某个 owner 的记忆时', '默认当前身份;跨 owner 私密仍按 owner 门跳过'),
151
+ helpOption('--out', '<路径>', '把蒸馏报告导出到指定路径', '想把报告放到自定义位置时', '不指定时按归属写入 distills 目录'),
144
152
  helpOption('--json', '', '输出 JSON', '自动化处理蒸馏结果时', ''),
145
153
  ] },
146
154
  { name: 'feedback', usage: 'feedback <记忆 id> [--useful | --useless | --undo]', what: '记录记忆是否有用', when: '一条记忆刚刚帮助了你,或明显误导了你时', options: [
@@ -148,7 +156,10 @@ const HELP_MODEL = [
148
156
  helpOption('--useless', '', '记为没用', '这条记忆造成误导或噪音时', ''),
149
157
  helpOption('--undo', '', '撤销上一次反馈', '误点反馈时', ''),
150
158
  ] },
151
- { name: 'explain', usage: 'explain <记忆 id> [--json]', what: '解释一条记忆的效用分和归档判定', when: '想理解为什么它被排序、归档或遗忘时', options: [helpOption('--json', '', '输出 JSON', '脚本读取解释结果时', '')] },
159
+ { name: 'explain', usage: 'explain <记忆 id> [--json] [--no-usage]', what: '解释一条记忆的效用分和归档判定', when: '想理解为什么它被排序、归档或遗忘时', options: [
160
+ helpOption('--json', '', '输出 JSON', '脚本读取解释结果时', ''),
161
+ helpOption('--no-usage', '', '本次不记录命中打点', '只想临时关闭使用统计时', '只影响这一次调用,不改配置'),
162
+ ] },
152
163
  { name: 'bench', usage: 'bench [--evalset <文件>] [--k <条数>] [--seed <数值>] [--bootstrap <次数>] [--ablate] [--gate <指标>=<数值>] [--timing] [--json] [--out <文件>]', what: '可复算的检索基准评测', when: '改检索 / 索引逻辑前后要证明「没变差」,或把质量门禁接进 CI 时', options: [
153
164
  helpOption('--evalset', '<文件>', '指定评测集 JSON 文件', '想固定一组查询来复算指标时', '不指定时按库内条目做确定性抽样,生成基线评测集'),
154
165
  helpOption('--k', '<条数>', '设置结果窗口大小', '想按 Recall@k / nDCG@k 的 k 口径统计时', '默认 5'),
@@ -195,7 +206,9 @@ const HELP_MODEL = [
195
206
  helpOption('--owner', '<id>', '指定画像归属者', '查某个 owner 的私密画像时', '仍然受私密区权限约束'),
196
207
  helpOption('--json', '', '输出 JSON', '自动化读取画像时', ''),
197
208
  ] },
198
- { name: 'context', usage: 'context [选项]', what: '生成开工上下文包', when: '每个会话开工时恢复身份、边界、近期记忆和承诺', options: [
209
+ { name: 'context', usage: 'context [--audit [--from <文件|->]] [选项]', what: '生成开工上下文包', when: '每个会话开工时恢复身份、边界、近期记忆和承诺', options: [
210
+ helpOption('--audit', '', '审计被压缩掉的内容是否已经落盘', '宿主压缩上下文后,想确认有没有决策只存在于对话里时', '只读:只打印 remember 建议,不会自动补写'),
211
+ helpOption('--from', '<文件|->', '指定待审计内容(- 表示标准输入)', '把宿主压缩摘要或丢弃段落喂给审计时', '不传时审计当前上下文包的丢弃清单'),
199
212
  helpOption('--limit', '<条数>', '限制近期记忆条数', '上下文太长、想缩短时', ''),
200
213
  helpOption('--owner', '<id>', '指定 owner 范围', '需要看某个 owner 的上下文时', '仍然受私密区权限约束'),
201
214
  helpOption('--budget', '<字符数>', '设置动态记忆字符预算', '需要控制上下文包大小时', '0 表示不限制'),
@@ -204,6 +217,7 @@ const HELP_MODEL = [
204
217
  helpOption('--embedding', '<命令>', '临时指定本地 embedding 插件命令', '上下文检索想用本地向量命令时', '只在本地执行'),
205
218
  helpOption('--embedding-timeout', '<毫秒>', '设置 embedding 插件超时', '插件较慢或需要快速失败时', ''),
206
219
  helpOption('--year', '<yyyy>', '只装载指定年份的记忆', '只想让上下文包覆盖某一年份时', '可重复传多次;不传则装载全部年份'),
220
+ helpOption('--no-usage', '', '本次不记录命中打点', '只想临时关闭使用统计时', '只影响这一次调用,不改配置'),
207
221
  ] },
208
222
  { name: 'token', usage: 'token <子命令> [选项]', what: '管理远端 MCP 访问 token', when: '给远端 MCP 客户端发放或吊销访问凭据时', subcommands: [
209
223
  helpSub('new', 'new --agent <id> [--scope <范围>] [--json]', '生成一个访问 token', '远端客户端第一次接入时', [
@@ -304,7 +318,7 @@ const PRIVATE_LEAF = ['prefs', 'bounds', 'commits'];
304
318
  const ARCHIVE_DIR = '.archive';
305
319
  const INDEX_FILE = 'index.json';
306
320
  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'];
321
+ 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
322
  const CONFIG_FILE = 'config.json';
309
323
  const SERVER_SUBDIR = '.server';
310
324
  const TOKENS_FILE = 'tokens.json';
@@ -1330,6 +1344,129 @@ function semanticMatch(entry, query, wantDetail) {
1330
1344
  function round3(n) { return Math.round(n * 1000) / 1000; }
1331
1345
  // ---- v0.8.0 自我进化:统一效用分(盖棺分)----
1332
1346
  function feedbackNetOf(meta) { return parseInt(meta.feedback_net || '0', 10) || 0; }
1347
+ // ---- v0.18.0 A3:命中打点(本地元数据;查询只存 8 位指纹,不落原文)----
1348
+ // hit_days 形如 s2026-09-26:2|g2026-09-25:1|u2026-09-24:1(s=search / g=get / u=use)。
1349
+ // hit_queries 形如 q3f9a1c7:2|q77bd021e:1(sha256(query) 前 8 位十六进制 + 次数)。
1350
+ const USAGE_SIGNAL_LABELS = { search: 's', get: 'g', use: 'u' };
1351
+ const USAGE_SIGNAL_ORDER = { s: 0, g: 1, u: 2 };
1352
+ const USAGE_CONFIG_DEFAULTS = { usage_retention_days: 90, usage_query_slots: 12 };
1353
+ function usageNumber(cfg, key) {
1354
+ const raw = cfg ? cfg[key] : undefined;
1355
+ if (raw === undefined || raw === null || raw === '') return USAGE_CONFIG_DEFAULTS[key];
1356
+ const n = parseFloat(raw);
1357
+ return (n >= 0) ? n : USAGE_CONFIG_DEFAULTS[key];
1358
+ }
1359
+ function usageSettings(opts) {
1360
+ const cfg = loadConfig();
1361
+ return {
1362
+ enabled: !(opts && opts.noUsage) && cfg.usage_enabled !== false,
1363
+ retentionDays: usageNumber(cfg, 'usage_retention_days'),
1364
+ querySlots: usageNumber(cfg, 'usage_query_slots'),
1365
+ };
1366
+ }
1367
+ function usageFingerprint(query) {
1368
+ return crypto.createHash('sha256').update(String(query), 'utf8').digest('hex').slice(0, 8);
1369
+ }
1370
+ function parseHitDays(value) {
1371
+ const map = {};
1372
+ const re = /([sgu])(\d{4}-\d{2}-\d{2}):(\d+)/g;
1373
+ let m;
1374
+ while ((m = re.exec(String(value || '')))) {
1375
+ const key = m[1] + m[2];
1376
+ map[key] = (map[key] || 0) + (parseInt(m[3], 10) || 0);
1377
+ }
1378
+ return map;
1379
+ }
1380
+ function serializeHitDays(map) {
1381
+ const keys = Object.keys(map).filter(function (k) { return /^[sgu]\d{4}-\d{2}-\d{2}$/.test(k) && map[k] > 0; });
1382
+ keys.sort(function (a, b) {
1383
+ const da = a.slice(1), db = b.slice(1);
1384
+ if (da !== db) return db.localeCompare(da);
1385
+ return (USAGE_SIGNAL_ORDER[a[0]] || 0) - (USAGE_SIGNAL_ORDER[b[0]] || 0);
1386
+ });
1387
+ return keys.map(function (k) { return k + ':' + map[k]; }).join('|');
1388
+ }
1389
+ function parseHitQueries(value) {
1390
+ const map = {};
1391
+ const re = /q([0-9a-f]{8}):(\d+)/g;
1392
+ let m;
1393
+ while ((m = re.exec(String(value || '')))) {
1394
+ map[m[1]] = (map[m[1]] || 0) + (parseInt(m[2], 10) || 0);
1395
+ }
1396
+ return map;
1397
+ }
1398
+ function serializeHitQueries(map, slots) {
1399
+ const fps = Object.keys(map).filter(function (fp) { return /^[0-9a-f]{8}$/.test(fp) && map[fp] > 0; });
1400
+ fps.sort(function (a, b) {
1401
+ if (map[b] !== map[a]) return map[b] - map[a];
1402
+ return a.localeCompare(b);
1403
+ });
1404
+ const limit = (slots >= 0) ? slots : USAGE_CONFIG_DEFAULTS.usage_query_slots;
1405
+ return fps.slice(0, limit).map(function (fp) { return 'q' + fp + ':' + map[fp]; }).join('|');
1406
+ }
1407
+ // 纯函数:算好本次命中要写入的 frontmatter 字段(不写盘,便于 recall/feedback 复用同一口径)。
1408
+ function usagePatchForMeta(meta, signal, query, settings, dateStr) {
1409
+ const label = USAGE_SIGNAL_LABELS[signal];
1410
+ if (!label) return {};
1411
+ const patch = {};
1412
+ const days = parseHitDays(meta.hit_days);
1413
+ const key = label + dateStr;
1414
+ days[key] = (days[key] || 0) + 1;
1415
+ const kept = {};
1416
+ for (const k of Object.keys(days)) {
1417
+ const age = daysBetween(k.slice(1), dateStr);
1418
+ if (age < 0 || age > settings.retentionDays) continue;
1419
+ kept[k] = days[k];
1420
+ }
1421
+ const daysStr = serializeHitDays(kept);
1422
+ if (daysStr) patch.hit_days = daysStr;
1423
+ if (signal === 'search' && query) {
1424
+ const queries = parseHitQueries(meta.hit_queries);
1425
+ const fp = usageFingerprint(query);
1426
+ queries[fp] = (queries[fp] || 0) + 1;
1427
+ patch.hit_queries = serializeHitQueries(queries, settings.querySlots);
1428
+ }
1429
+ return patch;
1430
+ }
1431
+ // 独立写点(explain / context use 信号):写失败只计数,不影响读取结果。
1432
+ function applyUsageHits(root, relFiles, signal, query, opts) {
1433
+ opts = opts || {};
1434
+ const out = { enabled: false, written: 0, skipped: 0, failed: 0 };
1435
+ const settings = usageSettings(opts);
1436
+ out.enabled = settings.enabled;
1437
+ if (!settings.enabled) return out;
1438
+ const selfAgent = (opts.selfAgent !== undefined) ? opts.selfAgent : resolveIdentity(opts).id;
1439
+ const dateStr = today();
1440
+ for (const rel of relFiles) {
1441
+ const fp = path.join(root, rel);
1442
+ if (!fs.existsSync(fp)) continue;
1443
+ const owner = ownerFromPrivatePath(root, fp);
1444
+ if (owner && owner !== selfAgent) { out.skipped++; continue; }
1445
+ try {
1446
+ const parsed = parseFrontmatter(readMemoryText(root, fp, owner));
1447
+ if (parsed.meta.immutable === 'true') { out.skipped++; continue; }
1448
+ const patch = usagePatchForMeta(parsed.meta, signal, query, settings, dateStr);
1449
+ if (!Object.keys(patch).length) continue;
1450
+ const meta = Object.assign({}, parsed.meta, patch);
1451
+ writeMemoryText(root, fp, frontmatterToText(meta, parsed.body), owner);
1452
+ upsertIndexEntry(root, readEntry(fp, root));
1453
+ out.written++;
1454
+ } catch (e) {
1455
+ out.failed++;
1456
+ }
1457
+ }
1458
+ return out;
1459
+ }
1460
+ // 报告侧派生:窗口内命中次数(不落盘,避免派生值与原始值漂移)。
1461
+ function usageTotals(entry, days) {
1462
+ const map = parseHitDays(entry && entry.hit_days);
1463
+ let total = 0;
1464
+ for (const k of Object.keys(map)) {
1465
+ const age = daysBetween(k.slice(1), today());
1466
+ if (age >= 0 && age <= days) total += map[k];
1467
+ }
1468
+ return total;
1469
+ }
1333
1470
  function utilityScore(meta) {
1334
1471
  const conf = Math.min(Math.max(parseFloat(meta.confidence || 1.0) || 1.0, 0), 1);
1335
1472
  const acc = parseInt(meta.access_count || '0', 10) || 0;
@@ -2299,6 +2436,8 @@ function readEntry(fp, root) {
2299
2436
  source: meta.source || '',
2300
2437
  weight: (parseFloat(meta.weight) > 0 ? parseFloat(meta.weight) : 1.0),
2301
2438
  feedback_net: feedbackNetOf(meta),
2439
+ hit_days: meta.hit_days || '',
2440
+ hit_queries: meta.hit_queries || '',
2302
2441
  tokens: buildTokens(subject, statement, tags),
2303
2442
  meta: meta,
2304
2443
  };
@@ -2312,23 +2451,72 @@ function rewriteFrontmatter(fp, patch, root, owner) {
2312
2451
  for (const k of Object.keys(patch)) meta[k] = patch[k];
2313
2452
  writeMemoryText(root || memoryRootForFile(fp), fp, frontmatterToText(meta, parsed.body), owner || ownerFromPrivatePath(root || memoryRootForFile(fp), fp));
2314
2453
  }
2315
- function bumpReadMeta(root, relFiles) {
2454
+ function bumpReadMeta(root, relFiles, options) {
2455
+ options = options || {};
2316
2456
  const now = today();
2457
+ const settings = usageSettings(options);
2458
+ const selfAgent = (options.selfAgent !== undefined) ? options.selfAgent : resolveIdentity(options).id;
2459
+ const usageByFile = {};
2317
2460
  for (const rel of relFiles) {
2318
2461
  const fp = path.join(root, rel);
2319
2462
  if (!fs.existsSync(fp)) continue;
2320
2463
  const owner = ownerFromPrivatePath(root, fp);
2464
+ // v0.18.0:跨 owner 私密条目一律不写(读得到也不代表能改别人的文件)。
2465
+ if (owner && owner !== selfAgent) continue;
2321
2466
  let parsed;
2322
2467
  try { parsed = parseFrontmatter(readMemoryText(root, fp, owner)); } catch (e) { continue; }
2323
2468
  const meta = parsed.meta;
2324
2469
  if (meta.immutable === 'true') continue;
2325
2470
  const acc = (parseInt(meta.access_count || '0', 10) || 0) + 1;
2326
- rewriteFrontmatter(fp, { access_count: acc, last_accessed: now }, root, owner);
2471
+ const patch = { access_count: acc, last_accessed: now };
2472
+ if (settings.enabled) {
2473
+ const usagePatch = usagePatchForMeta(meta, 'search', options.query || '', settings, now);
2474
+ Object.assign(patch, usagePatch);
2475
+ usageByFile[rel] = usagePatch;
2476
+ }
2477
+ try {
2478
+ rewriteFrontmatter(fp, patch, root, owner);
2479
+ } catch (e) {
2480
+ // 打点写失败不影响读取结果本身;索引也保持原样。
2481
+ delete usageByFile[rel];
2482
+ }
2327
2483
  }
2484
+ return { usageByFile: usageByFile };
2328
2485
  }
2329
2486
  function touchIndex(root, relFiles, options) {
2487
+ options = options || {};
2330
2488
  const set = new Set(relFiles);
2331
2489
  const now = today();
2490
+ const usageByFile = options.usageByFile || {};
2491
+ const bumpEntry = function (e) {
2492
+ e.access_count = (parseInt(e.access_count, 10) || 0) + 1;
2493
+ e.last_accessed = now;
2494
+ const usage = usageByFile[e.file];
2495
+ if (usage) {
2496
+ if (usage.hit_days !== undefined) e.hit_days = usage.hit_days;
2497
+ if (usage.hit_queries !== undefined) e.hit_queries = usage.hit_queries;
2498
+ }
2499
+ };
2500
+ // v0.18.0:加密库的私密条目命中必须当场同步 owner index(旧版会滞后到 reindex)。
2501
+ if (isEncrypted(root)) {
2502
+ const byOwner = {};
2503
+ for (const rel of set) {
2504
+ if (String(rel).indexOf(PRIVATE_DIR + '/') !== 0) continue;
2505
+ const owner = String(rel).split('/')[1] || '';
2506
+ (byOwner[owner] = byOwner[owner] || []).push(rel);
2507
+ }
2508
+ for (const owner of Object.keys(byOwner)) {
2509
+ const key = getOwnerKeyFor(root, owner);
2510
+ if (!key) continue;
2511
+ let ownerIdx;
2512
+ try { ownerIdx = loadOwnerIndex(root, owner, key); } catch (e) { continue; }
2513
+ let dirty = false;
2514
+ for (const e of ownerIdx) {
2515
+ if (set.has(e.file)) { bumpEntry(e); dirty = true; }
2516
+ }
2517
+ if (dirty) saveOwnerIndex(root, owner, key, ownerIdx);
2518
+ }
2519
+ }
2332
2520
  // v0.17.0 B3:带 --year 的检索只碰命中年份的分片(命中条目必然来自这些分片),
2333
2521
  // 不传年份时行为与旧版一致(遍历全部 shards)。
2334
2522
  const filterYears = (options && Array.isArray(options.years)) ? options.years : [];
@@ -2344,7 +2532,7 @@ function touchIndex(root, relFiles, options) {
2344
2532
  if (!sd || !Array.isArray(sd.entries)) continue;
2345
2533
  let dirty = false;
2346
2534
  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; }
2535
+ if (set.has(e.file)) { bumpEntry(e); dirty = true; }
2348
2536
  }
2349
2537
  if (dirty) fs.writeFileSync(sp, JSON.stringify(sd, null, 2), 'utf8');
2350
2538
  }
@@ -2353,7 +2541,7 @@ function touchIndex(root, relFiles, options) {
2353
2541
  const idx = getIndex(root);
2354
2542
  let dirty = false;
2355
2543
  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; }
2544
+ if (set.has(e.file)) { bumpEntry(e); dirty = true; }
2357
2545
  }
2358
2546
  if (dirty) saveIndex(root, idx);
2359
2547
  }
@@ -3491,6 +3679,10 @@ const SCALE_THRESHOLD_DEFAULTS = {
3491
3679
  scale_warn_files_per_dir: 500,
3492
3680
  scale_warn_index_bytes: 5 * 1024 * 1024,
3493
3681
  scale_warn_cold_start_ms: 2000,
3682
+ scale_info_entries: 30000,
3683
+ scale_info_files_per_dir: 300,
3684
+ scale_info_index_bytes: 3 * 1024 * 1024,
3685
+ scale_info_cold_start_ms: 1200,
3494
3686
  };
3495
3687
  function scaleThreshold(cfg, key) {
3496
3688
  const raw = cfg ? cfg[key] : undefined;
@@ -3525,6 +3717,10 @@ function measureIndexColdStart(root) {
3525
3717
  const elapsed = Number(process.hrtime.bigint() - started) / 1e6;
3526
3718
  return Math.round(elapsed * 1000) / 1000;
3527
3719
  }
3720
+ function scaleMetric(key, value, unit, info, warn) {
3721
+ const level = value > warn ? 'warning' : (value > info ? 'info' : 'ok');
3722
+ return { key: key, value: value, unit: unit, info: info, warn: warn, level: level };
3723
+ }
3528
3724
  function scaleReport(root, cfg) {
3529
3725
  const thresholds = {};
3530
3726
  for (const key of Object.keys(SCALE_THRESHOLD_DEFAULTS)) thresholds[key] = scaleThreshold(cfg, key);
@@ -3536,26 +3732,37 @@ function scaleReport(root, cfg) {
3536
3732
  for (let i = 0; i < 3; i++) samples.push(measureIndexColdStart(root));
3537
3733
  samples.sort(function (a, b) { return a - b; });
3538
3734
  const coldStartMs = samples[Math.floor(samples.length / 2)];
3735
+ const metrics = [
3736
+ scaleMetric('entries', entries, '条', thresholds.scale_info_entries, thresholds.scale_warn_entries),
3737
+ scaleMetric('files_per_dir', filesPerDir, '个文件', thresholds.scale_info_files_per_dir, thresholds.scale_warn_files_per_dir),
3738
+ scaleMetric('index_bytes', indexBytes, '字节', thresholds.scale_info_index_bytes, thresholds.scale_warn_index_bytes),
3739
+ scaleMetric('cold_start_ms', coldStartMs, 'ms', thresholds.scale_info_cold_start_ms, thresholds.scale_warn_cold_start_ms),
3740
+ ];
3539
3741
  const warnings = [];
3540
- if (entries > thresholds.scale_warn_entries) {
3742
+ if (metrics[0].level === 'warning') {
3541
3743
  warnings.push('规模: 记忆条数 ' + entries + ' 超过阈值 ' + thresholds.scale_warn_entries + '(考虑按年归档旧条目,或分批导出冷数据)');
3542
3744
  }
3543
- if (filesPerDir > thresholds.scale_warn_files_per_dir) {
3745
+ if (metrics[1].level === 'warning') {
3544
3746
  warnings.push('规模: 单个目录文件数 ' + filesPerDir + ' 超过阈值 ' + thresholds.scale_warn_files_per_dir + '(检查是否有目录未按年/月分层)');
3545
3747
  }
3546
- if (indexBytes > thresholds.scale_warn_index_bytes) {
3748
+ if (metrics[2].level === 'warning') {
3547
3749
  warnings.push('规模: 索引总体积 ' + indexBytes + ' 字节超过阈值 ' + thresholds.scale_warn_index_bytes + '(可用 reindex 重建,或按年份分片)');
3548
3750
  }
3549
- if (coldStartMs > thresholds.scale_warn_cold_start_ms) {
3751
+ if (metrics[3].level === 'warning') {
3550
3752
  warnings.push('规模: 索引冷启动 ' + coldStartMs + 'ms 超过阈值 ' + thresholds.scale_warn_cold_start_ms + 'ms(可用 reindex 重建索引)');
3551
3753
  }
3754
+ // v0.18.0 A6:分级只影响 level 与提示文案;超 warn 仍进 warnings(既有行为),info 不阻断。
3755
+ const level = metrics.some(function (m) { return m.level === 'warning'; })
3756
+ ? 'warning'
3757
+ : (metrics.some(function (m) { return m.level === 'info'; }) ? 'info' : 'ok');
3552
3758
  return {
3553
3759
  entries: entries,
3554
3760
  files_per_dir: filesPerDir,
3555
3761
  index_bytes: indexBytes,
3556
3762
  cold_start_ms: coldStartMs,
3557
3763
  thresholds: thresholds,
3558
- level: warnings.length ? 'warning' : 'ok',
3764
+ metrics: metrics,
3765
+ level: level,
3559
3766
  warnings: warnings,
3560
3767
  };
3561
3768
  }
@@ -3722,7 +3929,13 @@ function doctorCore(opts) {
3722
3929
  '- 备份目录: ' + (configuredDir || (cfg.backup_setup_choice === 'manual' ? '手动模式' : '(未配置)')),
3723
3930
  ];
3724
3931
  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' ? '有告警' : '正常') + ')');
3932
+ const scaleLevelLabel = checks.scale.level === 'warning' ? '告警' : (checks.scale.level === 'info' ? '提示' : '正常');
3933
+ const scaleTop = (checks.scale.metrics || []).filter(function (m) { return m.level !== 'ok'; })
3934
+ .sort(function (a, b) {
3935
+ const rank = { warning: 2, info: 1, ok: 0 };
3936
+ return rank[b.level] - rank[a.level];
3937
+ })[0];
3938
+ 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
3939
  }
3727
3940
  if (checks.layout && (checks.layout.duplicates || checks.layout.conflicts || checks.layout.unverified)) {
3728
3941
  lines.push('- 布局: 同序号重复 ' + checks.layout.duplicates + ' 组 / 冲突 ' + checks.layout.conflicts + ' 组 / 待核 ' + checks.layout.unverified + ' 组');
@@ -4405,11 +4618,19 @@ function recallCore(query, opts) {
4405
4618
  }
4406
4619
  return { error: false, exitCode: 0, text: '无匹配记忆。' };
4407
4620
  }
4408
- const touchRel = shown.filter(function (h) { return !h.entry.immutable; }).map(function (h) { return h.entry.file; });
4409
- if (touchRel.length) {
4621
+ const touchAll = shown.filter(function (h) { return !h.entry.immutable; }).map(function (h) { return h.entry.file; });
4622
+ let usageSkipped = 0;
4623
+ if (touchAll.length) {
4410
4624
  for (const root of roots) {
4411
- bumpReadMeta(root, touchRel);
4412
- touchIndex(root, touchRel, { years: years });
4625
+ const touchRel = touchAll.filter(function (rel) {
4626
+ if (!fs.existsSync(path.join(root, rel))) return false;
4627
+ const owner = ownerFromPrivatePath(root, path.join(root, rel));
4628
+ if (owner && owner !== selfAgent) { usageSkipped++; return false; }
4629
+ return true;
4630
+ });
4631
+ if (!touchRel.length) continue;
4632
+ const bump = bumpReadMeta(root, touchRel, { selfAgent: selfAgent, query: q, noUsage: !!opts.noUsage });
4633
+ touchIndex(root, touchRel, { years: years, usageByFile: bump.usageByFile });
4413
4634
  }
4414
4635
  }
4415
4636
  const lines = ['共 ' + shown.length + ' 条记忆(' + (hits.length > limit ? '前 ' + limit + ' 条' : '全部') + '):'];
@@ -4426,6 +4647,9 @@ function recallCore(query, opts) {
4426
4647
  if (deniedCount > 0 && explicitCross) {
4427
4648
  lines.push('\n[警告] 本次检索共拒绝 ' + deniedCount + ' 条越界访问(其它智能体私密记忆,未授权不展示)。如需读取请加 --unsafe 或 --owner user。');
4428
4649
  }
4650
+ if (usageSkipped > 0) {
4651
+ lines.push('\n[提示] 跨 owner 私密条目未记录命中打点(' + usageSkipped + ' 条):只有条目所属 owner 才能更新自己的打点。');
4652
+ }
4429
4653
  return {
4430
4654
  error: false,
4431
4655
  exitCode: 0,
@@ -4575,28 +4799,200 @@ function forgetCore(fileRef, opts) {
4575
4799
  }
4576
4800
  return { error: false, text: '已移入回收区: ' + trashFile };
4577
4801
  }
4802
+ // ---- v0.18.0 A3:容量水位 / 淘汰候选 / 晋升建议(只读报告,不自动动数据)----
4803
+ const CAPACITY_CONFIG_DEFAULTS = {
4804
+ capacity_warn_bytes: 50 * 1024 * 1024,
4805
+ capacity_cooldown_days: 30,
4806
+ capacity_candidate_limit: 20,
4807
+ promotion_min_hits: 3,
4808
+ promotion_min_queries: 3,
4809
+ };
4810
+ function capacityNumber(cfg, key) {
4811
+ const raw = cfg ? cfg[key] : undefined;
4812
+ if (raw === undefined || raw === null || raw === '') return CAPACITY_CONFIG_DEFAULTS[key];
4813
+ const n = parseFloat(raw);
4814
+ return (n >= 0) ? n : CAPACITY_CONFIG_DEFAULTS[key];
4815
+ }
4816
+ function isEvergreenTagged(meta) {
4817
+ // tags 在真实库里可能是 JSON 数组串(["pinned"]),parseTags 会保留引号,这里统一去掉再比较。
4818
+ const tags = parseTags(meta && meta.tags).map(function (t) {
4819
+ return String(t).toLowerCase().replace(/^["']|["']$/g, '');
4820
+ });
4821
+ return tags.indexOf('evergreen') !== -1 || tags.indexOf('pinned') !== -1;
4822
+ }
4823
+ function capacityMemoryBytes(root) {
4824
+ let total = 0;
4825
+ for (const fp of collectEntryFiles(root)) {
4826
+ try { total += fs.statSync(fp).size; } catch (e) { /* 单文件缺失不影响整体水位 */ }
4827
+ }
4828
+ return total;
4829
+ }
4830
+ function capacityCandidateRow(e) {
4831
+ return {
4832
+ file: e.file,
4833
+ type: e.type,
4834
+ subject: e.subject,
4835
+ hits_30d: e.hits_30d,
4836
+ hits_90d: e.hits_90d,
4837
+ last_used: e.last_used,
4838
+ age_days: e.age_days,
4839
+ utility: e.utility,
4840
+ suggested_command: 'yotta-memory explain ' + e.file,
4841
+ };
4842
+ }
4843
+ function capacityCore(opts) {
4844
+ opts = opts || {};
4845
+ const root = path.resolve(opts.root || userRoot());
4846
+ if (!fs.existsSync(root)) return { error: false, text: '记忆库不存在。', report: null };
4847
+ const cfg = loadConfig();
4848
+ const selfAgent = (opts.selfAgent !== undefined) ? opts.selfAgent : resolveIdentity(opts).id;
4849
+ const cooldownDays = capacityNumber(cfg, 'capacity_cooldown_days');
4850
+ const limit = (opts.limit !== undefined && opts.limit !== null)
4851
+ ? (parseInt(opts.limit, 10) || 0)
4852
+ : capacityNumber(cfg, 'capacity_candidate_limit');
4853
+ const minHits = capacityNumber(cfg, 'promotion_min_hits');
4854
+ const minQueries = capacityNumber(cfg, 'promotion_min_queries');
4855
+ const skipped = { cooldown: 0, evergreen: 0, denied: 0, unreadable: 0 };
4856
+ const activityBase = [];
4857
+ const candidatesForCleanup = [];
4858
+ for (const fp of collectEntryFiles(root)) {
4859
+ const r = relOf(root, fp);
4860
+ if (checkOwnerWritable(root, r, selfAgent, opts.unsafe)) { skipped.denied++; continue; }
4861
+ let e;
4862
+ try { e = readEntry(fp, root); } catch (err) { skipped.unreadable++; continue; }
4863
+ e.hits_30d = usageTotals(e, 30);
4864
+ e.hits_90d = usageTotals(e, 90);
4865
+ e.last_used = e.last_accessed || e.created || '';
4866
+ e.age_days = e.created ? daysBetween(e.created, today()) : -1;
4867
+ e.utility = round3(utilityScore(e.meta));
4868
+ activityBase.push(e);
4869
+ if (e.immutable || e.type === 'BOUND' || isEvergreenTagged(e.meta)) { skipped.evergreen++; continue; }
4870
+ if (e.age_days >= 0 && e.age_days < cooldownDays) { skipped.cooldown++; continue; }
4871
+ candidatesForCleanup.push(e);
4872
+ }
4873
+ const byOldest = function (a, b) {
4874
+ const la = a.last_used || '', lb = b.last_used || '';
4875
+ if (la !== lb) return la.localeCompare(lb);
4876
+ return String(a.file).localeCompare(String(b.file));
4877
+ };
4878
+ const lru = candidatesForCleanup.slice().sort(byOldest).slice(0, limit).map(capacityCandidateRow);
4879
+ const lfu = candidatesForCleanup.slice().sort(function (a, b) {
4880
+ if (a.hits_90d !== b.hits_90d) return a.hits_90d - b.hits_90d;
4881
+ return byOldest(a, b);
4882
+ }).slice(0, limit).map(capacityCandidateRow);
4883
+ const promotions = candidatesForCleanup.filter(function (e) {
4884
+ if (e.hits_90d < minHits) return false;
4885
+ if (Object.keys(parseHitQueries(e.hit_queries)).length < minQueries) return false;
4886
+ if (e.weight >= 1.5) return false;
4887
+ if (isConsolidatedSummary(e)) return false;
4888
+ return true;
4889
+ }).map(function (e) {
4890
+ const row = capacityCandidateRow(e);
4891
+ row.distinct_queries = Object.keys(parseHitQueries(e.hit_queries)).length;
4892
+ row.suggested_command = 'yotta-memory feedback ' + e.file + ' --useful';
4893
+ return row;
4894
+ });
4895
+ const scale = scaleReport(root, cfg);
4896
+ const memoryBytes = capacityMemoryBytes(root);
4897
+ const warnBytes = capacityNumber(cfg, 'capacity_warn_bytes');
4898
+ const infoBytes = warnBytes * 0.6;
4899
+ const memoryLevel = memoryBytes > warnBytes ? 'warning' : (memoryBytes > infoBytes ? 'info' : 'ok');
4900
+ const waterLevel = (scale.level === 'warning' || memoryLevel === 'warning')
4901
+ ? 'warning'
4902
+ : ((scale.level === 'info' || memoryLevel === 'info') ? 'info' : 'ok');
4903
+ const thresholds = Object.assign({}, scale.thresholds, {
4904
+ capacity_warn_bytes: warnBytes,
4905
+ capacity_cooldown_days: cooldownDays,
4906
+ capacity_candidate_limit: limit,
4907
+ promotion_min_hits: minHits,
4908
+ promotion_min_queries: minQueries,
4909
+ });
4910
+ const water = {
4911
+ entries: scale.entries,
4912
+ files_per_dir: scale.files_per_dir,
4913
+ index_bytes: scale.index_bytes,
4914
+ cold_start_ms: scale.cold_start_ms,
4915
+ memory_bytes: memoryBytes,
4916
+ thresholds: thresholds,
4917
+ metrics: scale.metrics.concat([{
4918
+ key: 'memory_bytes', value: memoryBytes, unit: '字节', info: infoBytes, warn: warnBytes, level: memoryLevel,
4919
+ }]),
4920
+ level: waterLevel,
4921
+ };
4922
+ const activity = {
4923
+ readable: activityBase.length,
4924
+ hits_30d_entries: activityBase.filter(function (e) { return e.hits_30d > 0; }).length,
4925
+ hits_90d_entries: activityBase.filter(function (e) { return e.hits_90d > 0; }).length,
4926
+ never_hit: activityBase.filter(function (e) { return e.hits_90d === 0 && (parseInt(e.access_count, 10) || 0) === 0; }).length,
4927
+ top: activityBase.filter(function (e) { return e.hits_90d > 0; })
4928
+ .sort(function (a, b) {
4929
+ if (b.hits_90d !== a.hits_90d) return b.hits_90d - a.hits_90d;
4930
+ return byOldest(a, b);
4931
+ })
4932
+ .slice(0, Math.min(limit, 10))
4933
+ .map(capacityCandidateRow),
4934
+ };
4935
+ const lines = ['## yotta-memory maintain --capacity(容量水位)'];
4936
+ const waterLabel = waterLevel === 'warning' ? '告警' : (waterLevel === 'info' ? '提示' : '正常');
4937
+ lines.push('- 水位: ' + waterLabel
4938
+ + '(条目 ' + scale.entries + ' / 记忆文件 ' + memoryBytes + ' 字节(阈值 ' + warnBytes + ')'
4939
+ + ' / 索引 ' + scale.index_bytes + ' 字节 / 单目录最大 ' + scale.files_per_dir + ' 个文件 / 冷启动 ' + scale.cold_start_ms + 'ms)');
4940
+ lines.push('- 活跃度: 可读 ' + activity.readable + ' 条;30 天命中 ' + activity.hits_30d_entries
4941
+ + ' 条 / 90 天命中 ' + activity.hits_90d_entries + ' 条 / 无命中记录 ' + activity.never_hit + ' 条');
4942
+ lines.push('');
4943
+ lines.push('### LRU 淘汰候选(最久未用,' + lru.length + ' 条)');
4944
+ if (!lru.length) lines.push('(无)');
4945
+ for (const row of lru) {
4946
+ lines.push('- [' + row.type + '] ' + row.file + '(90 天命中 ' + row.hits_90d + ',' + row.age_days + ' 天龄,utility ' + row.utility + ')— ' + row.subject);
4947
+ }
4948
+ lines.push('');
4949
+ lines.push('### LFU 淘汰候选(命中最少,' + lfu.length + ' 条)');
4950
+ if (!lfu.length) lines.push('(无)');
4951
+ for (const row of lfu) {
4952
+ lines.push('- [' + row.type + '] ' + row.file + '(90 天命中 ' + row.hits_90d + ',最久未用 ' + row.last_used + ')— ' + row.subject);
4953
+ }
4954
+ lines.push('');
4955
+ lines.push('### 晋升建议(只出建议,不自动改权重,' + promotions.length + ' 条)');
4956
+ if (!promotions.length) lines.push('(无:需要 90 天命中 ≥ ' + minHits + ' 且不同查询 ≥ ' + minQueries + ' 且 weight < 1.5)');
4957
+ for (const row of promotions) {
4958
+ lines.push('- ' + row.file + '(90 天命中 ' + row.hits_90d + ',不同查询 ' + row.distinct_queries + ')→ ' + row.suggested_command);
4959
+ }
4960
+ lines.push('');
4961
+ lines.push('### 跳过(不进候选)');
4962
+ lines.push('- 冷却期(<' + cooldownDays + ' 天): ' + skipped.cooldown + ' 条 / 常青(immutable、BOUND、evergreen、pinned): '
4963
+ + skipped.evergreen + ' 条 / 其它 owner: ' + skipped.denied + ' 条 / 无法读取: ' + skipped.unreadable + ' 条');
4964
+ lines.push('- 本命令只读:不改记忆、不改权重;归档请显式执行 yotta-memory archive。');
4965
+ const report = {
4966
+ schemaVersion: 1,
4967
+ root: root,
4968
+ thresholds: thresholds,
4969
+ water: water,
4970
+ activity: activity,
4971
+ candidates: { lru: lru, lfu: lfu },
4972
+ promotions: promotions,
4973
+ skipped: skipped,
4974
+ };
4975
+ return { error: false, text: lines.join('\n'), report: report };
4976
+ }
4578
4977
  function archiveCore(opts) {
4579
4978
  opts = opts || {};
4580
4979
  const cfg = loadConfig();
4581
4980
  const days = opts.days || parseInt(cfg.maintain_archived_age || '180', 10) || 180;
4582
4981
  const threshold = (opts.threshold !== undefined && opts.threshold !== null) ? opts.threshold : (parseFloat(cfg.maintain_archived_utility || 0.35));
4982
+ const cooldownDays = capacityNumber(cfg, 'capacity_cooldown_days');
4583
4983
  const root = userRoot();
4584
4984
  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 };
4985
+ const dryRun = !!opts.dryRun;
4986
+ const force = !!opts.force;
4595
4987
  const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
4596
- let moved = 0;
4597
- let deniedCrossOwner = 0;
4598
- const movedFiles = [];
4599
4988
  const selfAgent = resolveIdentity(opts).id;
4989
+ // v0.18.0 修正:先只读扫描候选与跳过统计,再决定要不要过破坏性闸门 ——
4990
+ // 预演(--dry-run)与「没有候选」两种情况下都不写盘。
4991
+ // 旧行为是进门就建整库快照 + 写审计,且 --dry-run 标志被静默吞掉(照常归档)。
4992
+ const candidates = [];
4993
+ let deniedCrossOwner = 0;
4994
+ let skippedCooldown = 0;
4995
+ let skippedEvergreen = 0;
4600
4996
  for (const fp of collectEntryFiles(root)) {
4601
4997
  const owner = ownerFromPrivatePath(root, fp);
4602
4998
  // v0.17.2:只归档自己有权写的条目 —— 旧行为遍历全部 owner,
@@ -4613,23 +5009,86 @@ function archiveCore(opts) {
4613
5009
  if (isNaN(createdTs)) continue;
4614
5010
  const t = (meta.type || 'FACT').toUpperCase();
4615
5011
  if (t === 'BOUND') continue; // v0.10.0 BOUND 豁免归档(边界常驻)
5012
+ if (!force && isEvergreenTagged(meta)) { skippedEvergreen++; continue; }
5013
+ const ageDays = meta.created ? daysBetween(meta.created, today()) : -1;
5014
+ if (!force && ageDays >= 0 && ageDays < cooldownDays) { skippedCooldown++; continue; }
4616
5015
  // 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++;
5016
+ const score = utilityScore(meta);
5017
+ if (score < threshold && createdTs < cutoff) {
5018
+ candidates.push({
5019
+ fp: fp,
5020
+ owner: owner,
5021
+ entry: {
5022
+ file: relOf(root, fp),
5023
+ type: t,
5024
+ subject: String(meta.subject || ''),
5025
+ age_days: ageDays,
5026
+ utility: round3(score),
5027
+ },
5028
+ });
4625
5029
  }
4626
5030
  }
4627
- if (movedFiles.length) removeIndexEntries(root, movedFiles);
4628
- let text = '已归档 ' + moved + ' 条旧记忆到 ' + path.join(root, ARCHIVE_DIR) + '\n事务快照: ' + guard.snapshot.id;
5031
+ const skippedLines = [];
4629
5032
  if (deniedCrossOwner) {
4630
- text += '\n跨 owner 私密条目已跳过(' + deniedCrossOwner + ' 条):请用 --agent <id> 声明自己的身份;只有用户显式授权(--unsafe)才能越界维护。';
5033
+ skippedLines.push('跨 owner 私密条目已跳过(' + deniedCrossOwner + ' 条):请用 --agent <id> 声明自己的身份;只有用户显式授权(--unsafe)才能越界维护。');
4631
5034
  }
4632
- return { error: false, text: text };
5035
+ if (skippedCooldown || skippedEvergreen) {
5036
+ skippedLines.push('因冷却期 / 常青豁免跳过 ' + (skippedCooldown + skippedEvergreen) + ' 条(冷却期 ' + skippedCooldown + ' / 常青 ' + skippedEvergreen + ');确认要一并归档时加 --force(immutable 与 BOUND 仍豁免)。');
5037
+ }
5038
+ const report = {
5039
+ schemaVersion: 1,
5040
+ root: root,
5041
+ mode: dryRun ? 'dry-run' : 'apply',
5042
+ days: days,
5043
+ threshold: threshold,
5044
+ cooldown_days: cooldownDays,
5045
+ force: force,
5046
+ candidates: candidates.map(function (c) { return c.entry; }),
5047
+ archived: [],
5048
+ skipped: { cooldown: skippedCooldown, evergreen: skippedEvergreen, cross_owner: deniedCrossOwner },
5049
+ };
5050
+ if (dryRun) {
5051
+ // 预演只读:不动文件、不建事务快照、不写审计、不改索引。
5052
+ const lines = ['预览(未改动): 将归档 ' + candidates.length + ' 条旧记忆到 ' + path.join(root, ARCHIVE_DIR)];
5053
+ lines.push('- 判定: 效用 < ' + threshold + ' 且未使用 > ' + days + ' 天' + (force
5054
+ ? '(--force:冷却期与常青标签不再豁免)'
5055
+ : '(冷却期 ' + cooldownDays + ' 天内与 immutable / BOUND / evergreen / pinned 豁免)'));
5056
+ for (const c of candidates) {
5057
+ lines.push('- [' + c.entry.type + '] ' + c.entry.file + '(' + c.entry.age_days + ' 天龄,utility ' + c.entry.utility + ')— ' + c.entry.subject);
5058
+ }
5059
+ if (!candidates.length) lines.push('(无符合条件的记忆)');
5060
+ lines.push('- 预演只读:未创建事务快照、未写审计、未移动文件;去掉 --dry-run 才执行(执行前先过破坏性写入闸门:doctor + 独立备份目录 + 异卷)。');
5061
+ lines.push.apply(lines, skippedLines);
5062
+ return { error: false, dryRun: true, text: lines.join('\n'), report: report };
5063
+ }
5064
+ if (!candidates.length) {
5065
+ // 无候选 = 无写入:不创建整库事务快照,也不写审计。
5066
+ const lines = ['已归档 0 条旧记忆到 ' + path.join(root, ARCHIVE_DIR), '没有符合条件的记忆:未创建事务快照(无写入)。'];
5067
+ lines.push.apply(lines, skippedLines);
5068
+ return { error: false, text: lines.join('\n'), report: report };
5069
+ }
5070
+ const guard = destructiveGuardCore({
5071
+ root: root,
5072
+ action: 'archive',
5073
+ snapshotDir: opts.snapshotDir,
5074
+ backupDir: opts.backupDir,
5075
+ allowSameVolumeForTest: opts.allowSameVolumeForTest,
5076
+ sameVolumeFn: opts.sameVolumeFn,
5077
+ now: opts.now,
5078
+ });
5079
+ if (guard.error) return { error: true, text: guard.text };
5080
+ const movedFiles = [];
5081
+ for (const c of candidates) {
5082
+ const dest = path.join(root, archiveRelFor(root, c.entry.file, c.entry.type, c.owner));
5083
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
5084
+ fs.renameSync(c.fp, dest);
5085
+ movedFiles.push(c.entry.file);
5086
+ }
5087
+ if (movedFiles.length) removeIndexEntries(root, movedFiles);
5088
+ report.archived = movedFiles;
5089
+ let text = '已归档 ' + movedFiles.length + ' 条旧记忆到 ' + path.join(root, ARCHIVE_DIR) + '\n事务快照: ' + guard.snapshot.id;
5090
+ if (skippedLines.length) text += '\n' + skippedLines.join('\n');
5091
+ return { error: false, text: text, report: report };
4633
5092
  }
4634
5093
 
4635
5094
  // ---- v0.8.0 自我学习/自我进化/自我提升:feedback / explain / maintain / distill ----
@@ -4691,6 +5150,10 @@ function feedbackCore(ref, opts) {
4691
5150
  const before = { weight: round3(weight), confidence: round3(confidence), feedback_net: fb };
4692
5151
  const after = { weight: round3(nw), confidence: round3(nc), feedback_net: nfb };
4693
5152
  const patch = { weight: after.weight, confidence: after.confidence, feedback_net: after.feedback_net, updated: today() };
5153
+ if (useful) {
5154
+ const usageCfg = usageSettings(opts);
5155
+ if (usageCfg.enabled) Object.assign(patch, usagePatchForMeta(meta, 'use', '', usageCfg, today()));
5156
+ }
4694
5157
  rewriteFrontmatter(target.fp, patch, target.root, owner);
4695
5158
  upsertIndexEntry(target.root, readEntry(target.fp, target.root));
4696
5159
  appendAudit(target.root, 'feedback', { ts: new Date().toISOString(), file: target.rel, action: useful ? 'useful' : 'useless', before: before, after: after, reason: opts.reason || '' });
@@ -4734,6 +5197,7 @@ function explainCore(ref, opts) {
4734
5197
  // v0.17.2:跨 owner 私密条目 fail-closed —— 旧行为只是加一句「仅元数据」,
4735
5198
  // 正文(subject / statement)照样输出,等于把别人的私密记忆读给调用方。
4736
5199
  if (deny) return { error: true, text: deny };
5200
+ applyUsageHits(target.root, [target.rel], 'get', '', { selfAgent: selfAgent, noUsage: !!opts.noUsage });
4737
5201
  const ub = utilityBreakdown(entry);
4738
5202
  const ageDays = entry.created ? daysBetween(entry.created, today()) : 0;
4739
5203
  const cfg = loadConfig();
@@ -4772,12 +5236,100 @@ function subjectsSimilar(a, b) {
4772
5236
  return editDistance(sa, sb) <= 3;
4773
5237
  }
4774
5238
  // 记忆自组织:规则层自动归档/遗忘/去重(默认 dry-run,--apply 才执行,--purge 才真删)
5239
+ // ---- v0.18.1 A10:重复踩坑 → 建议升级为规则(只读报告)----
5240
+ function normalizeRuleSubject(s) {
5241
+ return String(s || '').toLowerCase().replace(/[^\u4e00-\u9fa5a-z]+/g, '');
5242
+ }
5243
+ function ruleGroupKeyFor(entry) {
5244
+ for (const t of (entry.tags || [])) {
5245
+ const m = /^pattern-key:(.+)$/i.exec(String(t));
5246
+ if (m) return { key: 'pattern-key:' + m[1].trim(), kind: 'pattern-key' };
5247
+ }
5248
+ const sm = /^yotta-learn:\s*(.+)$/i.exec(String(entry.subject || ''));
5249
+ const cm = /^\[([a-z_]+)\]/i.exec(String(entry.statement || '').trim());
5250
+ if (sm && cm) return { key: cm[1].toLowerCase() + '@' + sm[1].trim(), kind: 'yotta-learn' };
5251
+ const domains = (entry.tags || []).filter(function (t) {
5252
+ return !distillHasWord(t, DISTILL_LESSON_WORDS) && !/^pattern-key:/i.test(String(t)) && String(t) !== '规则' && String(t) !== '边界';
5253
+ }).map(function (t) { return String(t).toLowerCase(); }).sort();
5254
+ if (domains.length) return { key: domains.join('+'), kind: 'tags' };
5255
+ return { key: normalizeRuleSubject(entry.subject), kind: 'subject' };
5256
+ }
5257
+ function isRuleLessonCandidate(entry) {
5258
+ const hay = (entry.tags || []).join(',') + ' ' + String(entry.subject || '');
5259
+ if (distillHasWord(hay, DISTILL_LESSON_WORDS)) return true;
5260
+ const head = String(entry.statement || '').trim().slice(0, 40);
5261
+ return /^\[([a-z_]+)\]/i.test(head) && distillHasWord(head, DISTILL_LESSON_WORDS);
5262
+ }
5263
+ function ruleMiningCore(root, opts) {
5264
+ opts = opts || {};
5265
+ const cfg = loadConfig();
5266
+ const rawMin = (opts.ruleMinHits !== undefined && opts.ruleMinHits !== null) ? opts.ruleMinHits : cfg.maintain_rule_min_hits;
5267
+ const minHits = Math.max(2, parseInt(rawMin || '3', 10) || 3);
5268
+ const selfAgent = resolveIdentity(opts).id;
5269
+ const buckets = {};
5270
+ let scanned = 0, candidates = 0, denied = 0;
5271
+ for (const fp of collectEntryFiles(root)) {
5272
+ const rel = relOf(root, fp);
5273
+ if (checkOwnerWritable(root, rel, selfAgent, opts.unsafe)) { denied += 1; continue; }
5274
+ let e;
5275
+ try { e = readEntry(fp, root); } catch (err) { continue; }
5276
+ scanned += 1;
5277
+ if (['COMMIT', 'FACT', 'PREF'].indexOf(e.type) === -1) continue;
5278
+ if (!isRuleLessonCandidate(e)) continue;
5279
+ const gk = ruleGroupKeyFor(e);
5280
+ if (!gk.key) continue;
5281
+ candidates += 1;
5282
+ (buckets[gk.key] = buckets[gk.key] || []).push({ e: e, kind: gk.kind, file: rel });
5283
+ }
5284
+ const groups = Object.keys(buckets).map(function (key) {
5285
+ const items = buckets[key].slice().sort(function (a, b) {
5286
+ return String(b.e.updated || b.e.created || '').localeCompare(String(a.e.updated || a.e.created || ''));
5287
+ });
5288
+ const best = items.slice().sort(function (a, b) { return (b.e.confidence || 0) - (a.e.confidence || 0); })[0].e;
5289
+ const dates = items.map(function (x) { return x.e.created || x.e.updated || ''; }).filter(Boolean).sort();
5290
+ return {
5291
+ key: key,
5292
+ kind: items[0].kind,
5293
+ count: items.length,
5294
+ files: items.map(function (x) { return x.file; }),
5295
+ span: dates.length ? dates[0] + ' ~ ' + dates[dates.length - 1] : '',
5296
+ best: best,
5297
+ };
5298
+ }).filter(function (g) { return g.count >= minHits; }).sort(function (a, b) {
5299
+ return b.count - a.count || a.key.localeCompare(b.key);
5300
+ });
5301
+ return { error: false, groups: groups, scanned: scanned, candidates: candidates, denied: denied, minHits: minHits };
5302
+ }
5303
+ function ruleMiningLines(result) {
5304
+ const lines = ['### 建议升级为规则(只读,不自动写入)'];
5305
+ if (!result.groups.length) {
5306
+ lines.push('- (无重复踩坑组达到阈值:同组 ≥ ' + result.minHits + ' 条;可用 config set maintain_rule_min_hits N 调整)');
5307
+ return lines;
5308
+ }
5309
+ for (const g of result.groups) {
5310
+ lines.push('- 组键: ' + g.key + '(' + g.count + ' 条' + (g.span ? ',' + g.span : '') + ')');
5311
+ lines.push(' 代表: [' + g.best.type + '] ' + (g.best.subject || '') + ' — ' + String(g.best.statement || '').slice(0, 60));
5312
+ lines.push(' 建议: 人工确认后执行 yotta-memory remember BOUND "规则:' + g.key + '" "<陈述>"');
5313
+ }
5314
+ lines.push('- 说明: 元忆只给晋升信号,不自动写 BOUND;与元习 .learnings/ 的分工见 SKILL「记忆守则」。');
5315
+ return lines;
5316
+ }
5317
+
4775
5318
  function maintainCore(opts) {
4776
5319
  opts = opts || {};
4777
5320
  const apply = !!opts.apply;
4778
5321
  const purge = !!opts.purge;
4779
5322
  const root = userRoot();
4780
5323
  if (!fs.existsSync(root)) return { error: false, text: '记忆库不存在。' };
5324
+ if (opts.rules && (apply || purge || opts.dedup)) {
5325
+ return { error: true, exitCode: 2, text: '拒绝: --rules 是只读筛选,不能与 --apply / --purge / --dedup 同时使用。' };
5326
+ }
5327
+ if (opts.rules) {
5328
+ const lines = ['## yotta-memory maintain --rules(重复踩坑 → 规则晋升建议)', ''];
5329
+ const result = ruleMiningCore(root, opts);
5330
+ lines.push.apply(lines, ruleMiningLines(result));
5331
+ return { error: false, text: lines.join('\n'), report: result };
5332
+ }
4781
5333
  let guard = null;
4782
5334
  if (apply) {
4783
5335
  guard = destructiveGuardCore({
@@ -4886,6 +5438,13 @@ function maintainCore(opts) {
4886
5438
  lines.push('');
4887
5439
  lines.push('- 跨 owner 私密条目已跳过(' + deniedCrossOwner + ' 条):请用 --agent <id> 声明自己的身份;只有用户显式授权(--unsafe)才能越界维护。');
4888
5440
  }
5441
+ if (!opts.dedup) {
5442
+ const ruleResult = ruleMiningCore(root, opts);
5443
+ if (ruleResult.groups.length) {
5444
+ lines.push('');
5445
+ lines.push.apply(lines, ruleMiningLines(ruleResult));
5446
+ }
5447
+ }
4889
5448
  // 去重(v0.10.0:置信度分档 + --apply 自动合并;--dedup 与归档/遗忘互斥)
4890
5449
  if (opts.dedup) appendDedupBlock(lines, root, opts);
4891
5450
  return { error: false, text: lines.join('\n') };
@@ -4932,7 +5491,263 @@ function mergeCore(refA, refB, opts) {
4932
5491
  appendAudit(root, 'audit', { ts: new Date().toISOString(), file: drop.file, action: 'merge', reason: '并入 ' + keep.file, utility: round3(utilityScore(keep)) });
4933
5492
  return { error: false, text: '已合并: ' + drop.file + ' -> ' + keep.file + '\n 保留: [' + keep.type + '] ' + keep.subject + ': ' + keep.statement + '\n 归档低 confidence: ' + drop.file + '\n 事务快照: ' + guard.snapshot.id };
4934
5493
  }
4935
- // 心理日志蒸馏:统计摘要 / 主题画像 / 知识地图(启发式默认,可选 --model)
5494
+ // ---- v0.18.1 A8:蒸馏分类型提取 / 溯源锚点 / 质量指标(确定性,零依赖)----
5495
+ const DISTILL_CLASS_NAMES = ['教训', '待办', '成长', '事件', '规则边界', '其他'];
5496
+ const DISTILL_LESSON_WORDS = ['教训', '踩坑', '失误', '错误', '故障', '事故', '纠正', 'learning', 'error', 'correction', 'incident'];
5497
+ const DISTILL_TASK_WORDS = ['待办', '任务', 'action', 'next', '跟进'];
5498
+ const DISTILL_TASK_TAG_RE = /^t(o)?do$/i;
5499
+ const DISTILL_GROWTH_WORDS = ['成长', '改进', '提升', '洞见', '最佳实践', '复盘', 'insight', 'growth', 'improvement', 'best-practice'];
5500
+ const DISTILL_EVENT_WORDS = ['事件', '里程碑', '发布', '上线', '会议', '活动', 'release', 'milestone'];
5501
+ const DISTILL_FIELD_LIMIT = 500;
5502
+ function distillHasWord(haystack, words) {
5503
+ const s = String(haystack || '').toLowerCase();
5504
+ for (const w of words) if (s.indexOf(String(w).toLowerCase()) !== -1) return true;
5505
+ return false;
5506
+ }
5507
+ function distillClassify(entry, meta) {
5508
+ if (!meta || !meta.type) return '其他';
5509
+ if (entry.type === 'BOUND') return '规则边界';
5510
+ const hay = (entry.tags || []).join(',') + ' ' + String(entry.subject || '');
5511
+ if (distillHasWord(hay, DISTILL_LESSON_WORDS)) return '教训';
5512
+ const hasTaskTag = (entry.tags || []).some(function (t) { return DISTILL_TASK_TAG_RE.test(String(t).trim()); });
5513
+ if (distillHasWord(hay, DISTILL_TASK_WORDS) || hasTaskTag) return '待办';
5514
+ if (distillHasWord(hay, DISTILL_GROWTH_WORDS)) return '成长';
5515
+ if (distillHasWord(hay, DISTILL_EVENT_WORDS)) return '事件';
5516
+ if (entry.type === 'COMMIT') return '待办';
5517
+ if (entry.type === 'PREF') return '成长';
5518
+ if (entry.type === 'FACT') return '事件';
5519
+ return '其他';
5520
+ }
5521
+ function distillSentences(body) {
5522
+ return String(body || '').replace(/([。!?])/g, '$1\n').split('\n').map(function (s) { return s.trim(); }).filter(Boolean);
5523
+ }
5524
+ function distillLineOf(lines, needle) {
5525
+ const n = String(needle || '');
5526
+ if (!n) return 0;
5527
+ for (let i = 0; i < lines.length; i++) if (lines[i].indexOf(n) !== -1) return i + 1;
5528
+ return 0;
5529
+ }
5530
+ function distillClamp(value, limit) {
5531
+ const s = String(value === undefined || value === null ? '' : value);
5532
+ const max = limit || DISTILL_FIELD_LIMIT;
5533
+ if (s.length <= max) return { value: s, truncated: false };
5534
+ return { value: s.slice(0, max) + '…(截断 ' + (s.length - max) + ' 字符,全文见溯源)', truncated: true };
5535
+ }
5536
+ function distillDateNear(sentences, body) {
5537
+ const dateRe = /\d{4}-\d{2}-\d{2}/;
5538
+ for (const s of sentences) {
5539
+ if (dateRe.test(s) && /(截止|前完成|之前|deadline|due)/i.test(s)) return s.match(dateRe)[0];
5540
+ }
5541
+ const all = String(body || '');
5542
+ if (/(截止|前完成|deadline|due)/i.test(all)) {
5543
+ const m = all.match(dateRe);
5544
+ if (m) return m[0];
5545
+ }
5546
+ return '';
5547
+ }
5548
+ function distillExtract(entry, cls, body, lines) {
5549
+ const sents = distillSentences(body);
5550
+ const statement = String(entry.statement || '');
5551
+ const subject = String(entry.subject || '');
5552
+ const tags = (entry.tags || []).join(', ');
5553
+ const fields = {};
5554
+ const used = [];
5555
+ const take = function (name, value) {
5556
+ const ok = value !== undefined && value !== null && String(value) !== '';
5557
+ fields[name] = ok ? String(value) : '未记录';
5558
+ if (ok) used.push(String(value));
5559
+ };
5560
+ let fieldNames = [];
5561
+ if (cls === '教训') {
5562
+ fieldNames = ['教训', '触发条件', '规避方法'];
5563
+ const avoid = sents.filter(function (s) { return /(避免|应该|下次|教训)/.test(s); })[0];
5564
+ take('教训', statement);
5565
+ take('触发条件', tags || subject);
5566
+ take('规避方法', avoid);
5567
+ if (avoid) used.push(avoid);
5568
+ } else if (cls === '待办') {
5569
+ fieldNames = ['任务', '优先级', '截止'];
5570
+ let priority = '';
5571
+ for (const t of (entry.tags || [])) if (/^(high|medium|low|高|中|低)$/i.test(String(t))) { priority = t; break; }
5572
+ const due = distillDateNear(sents.concat([statement]), body + ' ' + statement);
5573
+ take('任务', statement);
5574
+ take('优先级', priority);
5575
+ take('截止', due);
5576
+ if (due) used.push(due);
5577
+ } else if (cls === '成长') {
5578
+ fieldNames = ['改进点', '前后对比', '效果'];
5579
+ const before = sents.filter(function (s) { return /(之前|后来|从.+到)/.test(s); })[0];
5580
+ const effect = sents.filter(function (s) { return /(效果|提升|%|更好)/.test(s); })[0];
5581
+ take('改进点', statement);
5582
+ take('前后对比', before);
5583
+ take('效果', effect);
5584
+ if (before) used.push(before);
5585
+ if (effect) used.push(effect);
5586
+ } else if (cls === '事件') {
5587
+ fieldNames = ['时间', '内容', '结果'];
5588
+ const last = sents.length ? sents[sents.length - 1] : '';
5589
+ take('时间', entry.created || entry.updated);
5590
+ take('内容', subject + (statement ? ': ' + statement : ''));
5591
+ take('结果', last);
5592
+ if (last) used.push(last);
5593
+ } else if (cls === '规则边界') {
5594
+ fieldNames = ['规则', '适用范围', '例外'];
5595
+ const ex = sents.filter(function (s) { return /(除非|例外)/.test(s); })[0];
5596
+ take('规则', statement);
5597
+ take('适用范围', tags || subject);
5598
+ take('例外', ex);
5599
+ if (ex) used.push(ex);
5600
+ } else {
5601
+ fieldNames = ['内容'];
5602
+ take('内容', statement || (sents[0] || String(body || '').trim().slice(0, 200)));
5603
+ }
5604
+ const lineNumbers = used.map(function (v) { return distillLineOf(lines, v); }).filter(Boolean);
5605
+ if (!lineNumbers.length) {
5606
+ const fallback = distillLineOf(lines, statement) || distillLineOf(lines, subject) || 1;
5607
+ lineNumbers.push(fallback);
5608
+ }
5609
+ let truncated = false;
5610
+ for (const name of fieldNames) {
5611
+ if (fields[name] === '未记录') continue;
5612
+ const clamped = distillClamp(fields[name]);
5613
+ if (clamped.truncated) { fields[name] = clamped.value; truncated = true; }
5614
+ }
5615
+ return {
5616
+ fields: fields,
5617
+ fieldNames: fieldNames,
5618
+ anchor: { start: Math.min.apply(null, lineNumbers), end: Math.max.apply(null, lineNumbers) },
5619
+ truncated: truncated,
5620
+ };
5621
+ }
5622
+ function distillElementStats(entry, cls, body) {
5623
+ const sents = distillSentences(body);
5624
+ const tags = (entry.tags || []).join(', ');
5625
+ const hasPriority = (entry.tags || []).some(function (t) { return /^(high|medium|low|高|中|低)$/i.test(String(t)); });
5626
+ let specs = [];
5627
+ if (cls === '教训') specs = [!!entry.statement, !!(tags || entry.subject), /(避免|应该|下次|教训)/.test(body)];
5628
+ else if (cls === '待办') specs = [!!entry.statement, hasPriority, !!distillDateNear(sents.concat([String(entry.statement || '')]), body + ' ' + String(entry.statement || ''))];
5629
+ else if (cls === '成长') specs = [!!entry.statement, /(之前|后来|从.+到)/.test(body), /(效果|提升|%|更好)/.test(body)];
5630
+ else if (cls === '事件') specs = [!!(entry.created || entry.updated), !!(entry.subject || entry.statement), sents.length > 0];
5631
+ else if (cls === '规则边界') specs = [!!entry.statement, !!(tags || entry.subject), /(除非|例外)/.test(body)];
5632
+ else specs = [!!(entry.statement || body)];
5633
+ const detected = specs.filter(Boolean).length;
5634
+ return { detected: detected, expected: specs.length };
5635
+ }
5636
+ function distillAnalyze(root, entries, owner) {
5637
+ const classes = {};
5638
+ for (const name of DISTILL_CLASS_NAMES) classes[name] = { name: name, count: 0, items: [] };
5639
+ const skipped = [];
5640
+ let analyzed = 0, entriesWithItems = 0, itemCount = 0, traceOk = 0;
5641
+ let sourceChars = 0, extractedChars = 0, detected = 0, filled = 0;
5642
+ const refusedRe = /\uFFFD/;
5643
+ for (const e of entries) {
5644
+ const entryRoot = e._root || root;
5645
+ const fp = path.join(entryRoot, e.file);
5646
+ const entryOwner = ownerFromPrivatePath(entryRoot, fp) || owner;
5647
+ let raw;
5648
+ try {
5649
+ raw = readMemoryText(entryRoot, fp, entryOwner);
5650
+ } catch (err) {
5651
+ skipped.push({ file: e.file, reason: 'encoding', detail: String(err.message || err) });
5652
+ continue;
5653
+ }
5654
+ raw = String(raw).replace(/^\uFEFF/, '');
5655
+ if (!raw.trim()) {
5656
+ skipped.push({ file: e.file, reason: 'empty', detail: '' });
5657
+ continue;
5658
+ }
5659
+ if (refusedRe.test(raw)) {
5660
+ skipped.push({ file: e.file, reason: 'encoding', detail: 'UTF-8 解码失败' });
5661
+ continue;
5662
+ }
5663
+ const parsed = parseFrontmatter(raw);
5664
+ const hasMeta = !!(parsed.meta && parsed.meta.type);
5665
+ const local = Object.assign({}, e);
5666
+ if (hasMeta) {
5667
+ local.type = String(parsed.meta.type || e.type).toUpperCase();
5668
+ local.subject = parsed.meta.subject || e.subject;
5669
+ local.statement = parsed.meta.statement || e.statement;
5670
+ local.tags = parseTags(parsed.meta.tags);
5671
+ local.created = parsed.meta.created || e.created;
5672
+ local.updated = parsed.meta.updated || e.updated;
5673
+ } else {
5674
+ local.type = '';
5675
+ local.tags = [];
5676
+ local.subject = path.basename(e.file).replace(/\.md(\.enc)?$/, '');
5677
+ local.statement = String(parsed.body || raw).trim().split('\n')[0].slice(0, 200);
5678
+ skipped.push({ file: e.file, reason: 'no-structure', detail: '按裸文本处理', analyzed: true });
5679
+ }
5680
+ const cls = distillClassify(local, hasMeta ? parsed.meta : null);
5681
+ const body = String(parsed.body || '');
5682
+ const lines = raw.split('\n');
5683
+ const ex = distillExtract(local, cls, body, lines);
5684
+ const stats = distillElementStats(local, cls, body);
5685
+ detected += stats.detected;
5686
+ filled += ex.fieldNames.filter(function (n) { return ex.fields[n] !== '未记录'; }).length;
5687
+ const anchorText = '[溯源: ' + e.file + '#L' + ex.anchor.start + '-L' + ex.anchor.end + ']';
5688
+ const item = {
5689
+ ref: e.file,
5690
+ type: local.type || 'UNKNOWN',
5691
+ subject: local.subject,
5692
+ statement: local.statement,
5693
+ fields: ex.fields,
5694
+ anchor: e.file + '#L' + ex.anchor.start + '-L' + ex.anchor.end,
5695
+ anchorText: anchorText,
5696
+ truncated: ex.truncated,
5697
+ };
5698
+ classes[cls].items.push(item);
5699
+ classes[cls].count += 1;
5700
+ analyzed += 1;
5701
+ entriesWithItems += 1;
5702
+ itemCount += 1;
5703
+ traceOk += 1;
5704
+ sourceChars += String(local.statement || '').length + body.length;
5705
+ for (const n of ex.fieldNames) if (ex.fields[n] !== '未记录') extractedChars += String(ex.fields[n]).length;
5706
+ if (ex.truncated) skipped.push({ file: e.file, reason: 'truncated', detail: '超长字段已截断到 ' + DISTILL_FIELD_LIMIT + ' 字符', analyzed: true });
5707
+ }
5708
+ const mainLines = ['## 二、分类型提取清单', ''];
5709
+ if (!itemCount) mainLines.push('(无可提取条目)');
5710
+ for (const name of DISTILL_CLASS_NAMES) {
5711
+ const c = classes[name];
5712
+ if (!c.count) continue;
5713
+ mainLines.push('### ' + name + '(' + c.count + ' 条)');
5714
+ for (const item of c.items) {
5715
+ mainLines.push('- [' + item.type + '] ' + item.subject);
5716
+ for (const fn of Object.keys(item.fields)) mainLines.push(' - ' + fn + ': ' + item.fields[fn]);
5717
+ mainLines.push(' - ' + item.anchorText);
5718
+ mainLines.push('');
5719
+ }
5720
+ }
5721
+ const compression = extractedChars > 0 ? Math.round((sourceChars / extractedChars) * 100) / 100 : 0;
5722
+ const entryCoverage = analyzed > 0 ? round4(entriesWithItems / analyzed) : 1;
5723
+ const traceCoverage = itemCount > 0 ? round4(traceOk / itemCount) : 1;
5724
+ const elementRate = detected > 0 ? round4(filled / detected) : 1;
5725
+ mainLines.push('## 三、质量指标', '');
5726
+ mainLines.push('- 源正文字符: ' + sourceChars + ' / 提取要点字符: ' + extractedChars + ' → 实测压缩比 ' + compression + '(只报本次实测,不承诺倍数)');
5727
+ mainLines.push('- 条目覆盖率: ' + Math.round(entryCoverage * 100) + '%(' + entriesWithItems + '/' + analyzed + ')');
5728
+ mainLines.push('- 溯源覆盖率: ' + Math.round(traceCoverage * 100) + '%(' + traceOk + '/' + itemCount + ',目标 100%)');
5729
+ mainLines.push('- 要素提取率: ' + Math.round(elementRate * 100) + '%(' + filled + '/' + detected + ',确定性回归护栏,不是语义保真承诺)');
5730
+ const tailLines = ['## 六、跳过与边界', ''];
5731
+ if (!skipped.length) tailLines.push('(无跳过项)');
5732
+ for (const s of skipped) {
5733
+ tailLines.push('- ' + s.file + ': ' + s.reason + (s.detail ? '(' + s.detail + ')' : '') + (s.analyzed ? '(已分析)' : '(已跳过)'));
5734
+ }
5735
+ return {
5736
+ classes: DISTILL_CLASS_NAMES.map(function (n) { return classes[n]; }).filter(function (c) { return c.count > 0; }),
5737
+ skipped: skipped,
5738
+ mainLines: mainLines,
5739
+ tailLines: tailLines,
5740
+ analyzed: analyzed,
5741
+ metrics: {
5742
+ compression_ratio: compression,
5743
+ entry_coverage: entryCoverage,
5744
+ trace_coverage: traceCoverage,
5745
+ element_extract_rate: elementRate,
5746
+ },
5747
+ };
5748
+ }
5749
+
5750
+ // 心理日志蒸馏:统计摘要 / 分类型提取 / 质量指标 / 主题画像 / 知识地图(启发式默认,可选 --model)
4936
5751
  function distillCore(opts) {
4937
5752
  opts = opts || {};
4938
5753
  const root = userRoot();
@@ -4943,15 +5758,16 @@ function distillCore(opts) {
4943
5758
  for (const r of memoryRoots()) {
4944
5759
  for (const e of ensureIndex(r)) {
4945
5760
  if (classifyRead(e, owner, '', !!opts.unsafe, selfAgent) === 'denied') continue;
4946
- entries.push(e);
5761
+ entries.push(Object.assign({}, e, { _root: r }));
4947
5762
  }
4948
5763
  }
4949
5764
  const lines = [];
4950
- lines.push('# 元忆记忆蒸馏报告(v0.8.0)');
5765
+ lines.push('# 元忆记忆蒸馏报告(v' + VERSION + ')');
4951
5766
  lines.push('');
4952
5767
  lines.push('> 生成: yotta-memory distill | 启发式蒸馏(零依赖);--model <cmd> 可选外部模型增强');
4953
5768
  lines.push('> 时间: ' + new Date().toISOString() + ' / 可读条目: ' + entries.length + ' 条');
4954
5769
  lines.push('');
5770
+ const analysis = distillAnalyze(root, entries, owner);
4955
5771
  // 1) 统计摘要
4956
5772
  lines.push('## 一、统计摘要');
4957
5773
  lines.push('');
@@ -4973,8 +5789,10 @@ function distillCore(opts) {
4973
5789
  for (const e of entries) { const n = e.feedback_net || 0; fbSum += n; if (n > 0) usefulN++; else if (n < 0) uselessN++; }
4974
5790
  lines.push('- 反馈统计: feedback_net 总和 ' + fbSum + '(正 ' + usefulN + ' 条 / 负 ' + uselessN + ' 条)');
4975
5791
  lines.push('');
5792
+ lines.push.apply(lines, analysis.mainLines);
5793
+ lines.push('');
4976
5794
  // 2) 主题画像(按 subject 聚类合并)
4977
- lines.push('## 二、主题画像(按 subject 聚类,启发式合并)');
5795
+ lines.push('## 四、主题画像(按 subject 聚类,启发式合并)');
4978
5796
  lines.push('');
4979
5797
  const groups = {};
4980
5798
  for (const e of entries) {
@@ -4993,7 +5811,7 @@ function distillCore(opts) {
4993
5811
  lines.push('');
4994
5812
  }
4995
5813
  // 3) 知识地图(type + tags 层级)
4996
- lines.push('## 三、知识地图(type → tags)');
5814
+ lines.push('## 五、知识地图(type → tags)');
4997
5815
  lines.push('');
4998
5816
  const map = {};
4999
5817
  for (const e of entries) {
@@ -5008,6 +5826,8 @@ function distillCore(opts) {
5008
5826
  lines.push('- ' + tk + ': ' + map[t][tk].map(function (e) { return e.subject; }).join(' / '));
5009
5827
  }
5010
5828
  }
5829
+ lines.push('');
5830
+ lines.push.apply(lines, analysis.tailLines);
5011
5831
  let body = lines.join('\n') + '\n';
5012
5832
  // 可选模型增强:--model <cmd>,stdin 传结构化摘要,stdout 取提炼文本
5013
5833
  let modelOut = '';
@@ -5017,12 +5837,24 @@ function distillCore(opts) {
5017
5837
  const r = runDistillModel(String(opts.model), payload);
5018
5838
  if (r.stdout) modelOut = String(r.stdout).trim();
5019
5839
  if (!modelOut) modelOut = '(模型无输出)';
5020
- body += '\n## 四、模型提炼(--model ' + opts.model + ')\n\n' + modelOut + '\n';
5840
+ body += '\n## 七、模型提炼(--model ' + opts.model + ')\n\n' + modelOut + '\n';
5021
5841
  } catch (e) {
5022
5842
  modelOut = '(模型调用失败: ' + e.message + ')';
5023
- body += '\n## 四、模型提炼\n\n' + modelOut + '\n';
5843
+ body += '\n## 七、模型提炼\n\n' + modelOut + '\n';
5024
5844
  }
5025
5845
  }
5846
+ const makeReport = function (writtenPath) {
5847
+ return {
5848
+ schemaVersion: 1,
5849
+ generated: new Date().toISOString(),
5850
+ root: root,
5851
+ scope: { owner: owner, entries: entries.length, analyzed: analysis.analyzed },
5852
+ classes: analysis.classes,
5853
+ metrics: analysis.metrics,
5854
+ skipped: analysis.skipped,
5855
+ written: writtenPath || '',
5856
+ };
5857
+ };
5026
5858
  // 落盘:--out 指定 / 私密 distills / 公共 facts/distills
5027
5859
  let written = '';
5028
5860
  try {
@@ -5053,9 +5885,9 @@ function distillCore(opts) {
5053
5885
  }
5054
5886
  }
5055
5887
  } catch (e) {
5056
- return { error: false, text: lines.join('\n') + '\n\n[落盘失败] ' + e.message };
5888
+ return { error: false, text: lines.join('\n') + '\n\n[落盘失败] ' + e.message, report: makeReport('') };
5057
5889
  }
5058
- return { error: false, text: body + '\n[已写入] ' + written };
5890
+ return { error: false, text: body + '\n[已写入] ' + written, report: makeReport(written) };
5059
5891
  }
5060
5892
 
5061
5893
  function promptPassword(promptText) {
@@ -6157,6 +6989,13 @@ function cmdExplain(ref, opts) {
6157
6989
  if (r.error) process.exit(2);
6158
6990
  }
6159
6991
  function cmdMaintain(opts) {
6992
+ if (opts.capacity) {
6993
+ const r = capacityCore(opts);
6994
+ if (opts.json && r.report) console.log(JSON.stringify(r.report, null, 2));
6995
+ else console.log(r.text);
6996
+ if (r.error) process.exit(2);
6997
+ return;
6998
+ }
6160
6999
  if (opts.merge) {
6161
7000
  const parts = String(opts.merge).split(',').map(function (s) { return s.trim(); }).filter(Boolean);
6162
7001
  if (parts.length < 2) { console.error('--merge 需要两个文件,逗号分隔'); process.exit(2); }
@@ -6171,7 +7010,8 @@ function cmdMaintain(opts) {
6171
7010
  }
6172
7011
  function cmdDistill(opts) {
6173
7012
  const r = distillCore(opts);
6174
- console.log(r.text);
7013
+ if (opts.json && r.report) console.log(JSON.stringify(r.report, null, 2));
7014
+ else console.log(r.text);
6175
7015
  if (r.error) process.exit(2);
6176
7016
  }
6177
7017
  function cmdForget(fileRef, opts) {
@@ -6205,7 +7045,9 @@ function cmdRename(fileRef, newName, opts) {
6205
7045
  }
6206
7046
  function cmdArchive(opts) {
6207
7047
  const r = archiveCore(opts);
6208
- console.log(r.text);
7048
+ if (opts.json && r.report) console.log(JSON.stringify(r.report, null, 2));
7049
+ else console.log(r.text);
7050
+ if (r.error) process.exit(2);
6209
7051
  }
6210
7052
  function cmdBackupCreate(opts) {
6211
7053
  const r = backupCreateCore(opts);
@@ -6723,6 +7565,7 @@ function contextCore(opts) {
6723
7565
  opts = opts || {};
6724
7566
  const roots = memoryRoots();
6725
7567
  if (!roots.length) return { error: false, exitCode: 0, text: '记忆库不存在,请先运行: yotta-memory init' };
7568
+ if (opts.audit) return contextAuditCore(opts);
6726
7569
  const root = userRoot();
6727
7570
  const limit = parseInt(opts.limit || '10', 10) || 10;
6728
7571
  const ident = resolveIdentity(opts);
@@ -6750,6 +7593,7 @@ function contextCore(opts) {
6750
7593
  const trace = [];
6751
7594
  const lines = [];
6752
7595
  const shownFiles = new Set();
7596
+ const usedFiles = new Set();
6753
7597
  const FENCE = String.fromCharCode(96, 96, 96);
6754
7598
  function usedChars() { return lines.reduce(function (s, x) { return s + String(x).length + 1; }, 0); }
6755
7599
  function appendContextEntry(entry, reason, options) {
@@ -6766,6 +7610,7 @@ function contextCore(opts) {
6766
7610
  }
6767
7611
  lines.push(line);
6768
7612
  shownFiles.add(entry.file);
7613
+ usedFiles.add(entry.file);
6769
7614
  trace.push('[included] ' + entry.file + ' reason: ' + reason);
6770
7615
  return true;
6771
7616
  }
@@ -6847,7 +7692,8 @@ function contextCore(opts) {
6847
7692
  selfAgent: selfAgent,
6848
7693
  years: years,
6849
7694
  embedding: embeddingCommand,
6850
- embeddingTimeout: embeddingTimeout
7695
+ embeddingTimeout: embeddingTimeout,
7696
+ noUsage: true
6851
7697
  });
6852
7698
  const focusedEntries = focused.entries || [];
6853
7699
  if (!focusedEntries.length) lines.push('(无匹配记忆)');
@@ -6914,6 +7760,14 @@ function contextCore(opts) {
6914
7760
  if (!trace.length) lines.push('(无选择记录)');
6915
7761
  for (const t of trace) lines.push(t);
6916
7762
  }
7763
+ // v0.18.0 A3:纳入上下文包的条目记 use 信号(本次调用内按文件去重;--no-usage 可关)。
7764
+ if (usedFiles.size) {
7765
+ const usageOpts = { selfAgent: selfAgent, noUsage: !!opts.noUsage };
7766
+ for (const r of roots) {
7767
+ const rels = Array.from(usedFiles).filter(function (rel) { return fs.existsSync(path.join(r, rel)); });
7768
+ if (rels.length) applyUsageHits(r, rels, 'use', '', usageOpts);
7769
+ }
7770
+ }
6917
7771
  const reliability = doctorCore({ root: root });
6918
7772
  if (reliability.level !== 'ok') {
6919
7773
  lines.push('');
@@ -6923,11 +7777,12 @@ function contextCore(opts) {
6923
7777
  for (const message of reliability.warnings) lines.push('- [警告] ' + message);
6924
7778
  if (!reliability.ok) lines.push('- 破坏性写入已锁定:先运行 yotta-memory doctor 修复严重问题。');
6925
7779
  }
6926
- return { error: false, exitCode: 0, text: lines.join('\n') };
7780
+ return { error: false, exitCode: 0, text: lines.join('\n'), trace: trace };
6927
7781
  }
6928
7782
  function cmdContext(opts) {
6929
7783
  const r = contextCore(opts);
6930
- console.log(r.text);
7784
+ if (opts.json && r.report) console.log(JSON.stringify(r.report, null, 2));
7785
+ else console.log(r.text);
6931
7786
  if (r.error) {
6932
7787
  const reminder = migrationReminderText(userRoot());
6933
7788
  if (reminder) console.log('\n' + reminder);
@@ -6935,6 +7790,221 @@ function cmdContext(opts) {
6935
7790
  if (r.exitCode) process.exit(r.exitCode);
6936
7791
  }
6937
7792
 
7793
+ // ---- v0.18.0 A7:context --audit 上下文压缩审计(只读;不自动补写)----
7794
+ function contextAuditGate(opts) {
7795
+ const raw = Array.isArray(opts.gate) ? opts.gate[opts.gate.length - 1] : opts.gate;
7796
+ if (raw === undefined || raw === null || raw === '') return null;
7797
+ const n = parseInt(raw, 10);
7798
+ return isNaN(n) ? null : n;
7799
+ }
7800
+ function contextAuditReadStdin() {
7801
+ // 只在 fd 0 明确是管道 / socket 时读取,避免测试运行器或交互终端被阻塞。
7802
+ try {
7803
+ const st = fs.fstatSync(0);
7804
+ if (!st.isFIFO() && !st.isSocket()) return null;
7805
+ return fs.readFileSync(0, 'utf8');
7806
+ } catch (e) {
7807
+ return null;
7808
+ }
7809
+ }
7810
+ function contextAuditSegments(text) {
7811
+ const out = [];
7812
+ // 中文信号词为主;补英文决策词,保证中英混排的宿主压缩摘要也能切出候选句。
7813
+ const keywordRe = /决定|敲定|约定|口径|红线|规则|承诺|下一步|待办|遗留|注意|以后|必须|不要|decision|decided|agreement|policy|rule|commit|next step|todo|remaining|note|must|never/i;
7814
+ const sectionRe = /^#{1,6}\s*(决策|下一步|遗留问题|decisions?|next\s*steps?|remaining|todos?)/i;
7815
+ let inSection = false;
7816
+ let inCode = false;
7817
+ for (const raw of String(text || '').split(/\r?\n/)) {
7818
+ const line = raw.trim();
7819
+ if (/^```/.test(line)) { inCode = !inCode; continue; }
7820
+ if (inCode || !line) continue;
7821
+ if (/^#{1,6}\s+/.test(line)) { inSection = sectionRe.test(line); continue; }
7822
+ if (/^\|/.test(line)) continue;
7823
+ const item = line.replace(/^[-*+]\s+/, '').replace(/^\d+[.、)]\s*/, '').trim();
7824
+ if (item.length < 4) continue;
7825
+ if (inSection || keywordRe.test(item)) {
7826
+ if (out.indexOf(item) === -1) out.push(item);
7827
+ }
7828
+ }
7829
+ return out;
7830
+ }
7831
+ function contextAuditCoverage(candidate, entries, minCoverage) {
7832
+ for (const e of entries) {
7833
+ const corpus = String(e.subject || '') + ' ' + String(e.statement || '');
7834
+ if (corpus.indexOf(candidate) !== -1) return { entry: e, coverage: 1, best: e };
7835
+ const stmt = String(e.statement || '');
7836
+ if (stmt.length >= 6 && candidate.indexOf(stmt) !== -1) return { entry: e, coverage: 1, best: e };
7837
+ }
7838
+ const tokens = new Set(tokenize(candidate).filter(function (t) { return t.indexOf(':') === -1; }));
7839
+ if (!tokens.size) return { entry: null, coverage: 0, best: null };
7840
+ let best = null;
7841
+ let bestCoverage = 0;
7842
+ for (const e of entries) {
7843
+ const etoks = new Set(tokenize(String(e.subject || '') + ' ' + String(e.statement || '')).filter(function (t) { return t.indexOf(':') === -1; }));
7844
+ let inter = 0;
7845
+ for (const t of tokens) if (etoks.has(t)) inter++;
7846
+ const coverage = inter / tokens.size;
7847
+ if (coverage > bestCoverage) { bestCoverage = coverage; best = e; }
7848
+ }
7849
+ return { entry: bestCoverage >= minCoverage ? best : null, coverage: bestCoverage, best: best };
7850
+ }
7851
+ function contextAuditRememberCommand(text) {
7852
+ const clean = String(text || '').replace(/"/g, "'").trim();
7853
+ const specificity = clean.replace(/^[^::]*[::]\s*/, '').trim();
7854
+ const subject = contextAuditSuggestionSubject(specificity || clean, 20);
7855
+ return 'yotta-memory remember FACT "' + subject + '" "' + clean + '" --verify';
7856
+ }
7857
+ function contextAuditSuggestionSubject(text, max) {
7858
+ // v0.18.0 修正:截断回退到非标点边界,避免切在「(」这类标点中间。
7859
+ const limit = parseInt(max, 10) || 20;
7860
+ const clean = String(text || '').trim();
7861
+ if (clean.length <= limit) return clean;
7862
+ const cut = clean.slice(0, limit);
7863
+ const trimmed = cut.replace(/[\s,。;:、,.;:!?!?()()【】\[\]「」『』“”'"—–\-]+$/, '');
7864
+ return trimmed.length >= 8 ? trimmed : cut;
7865
+ }
7866
+ function contextAuditCore(opts) {
7867
+ opts = opts || {};
7868
+ const roots = memoryRoots();
7869
+ if (!roots.length) return { error: false, exitCode: 0, text: '记忆库不存在,请先运行: yotta-memory init' };
7870
+ const ident = resolveIdentity(opts);
7871
+ if (ident.error) return { error: true, exitCode: 3, text: ident.error };
7872
+ if (!ident.id) {
7873
+ return {
7874
+ error: true,
7875
+ exitCode: 3,
7876
+ text: 'context --audit 需要先声明身份:CLI 传 --agent <id>;stdio MCP 传 --agent-id <id> + --agent-key-file <path>;HTTP MCP 发送 X-Agent-Id + X-Agent-Key。',
7877
+ };
7878
+ }
7879
+ const selfAgent = ident.id;
7880
+ const owner = opts.owner || selfAgent;
7881
+ const root = userRoot();
7882
+ const keyError = validatePrivateIdentity(root, ident, owner);
7883
+ if (keyError) return { error: true, exitCode: 3, text: keyError };
7884
+ const cfg = loadConfig();
7885
+ const rawMin = parseFloat(cfg.context_audit_min_coverage);
7886
+ const minCoverage = (rawMin > 0 && rawMin <= 1) ? rawMin : 0.5;
7887
+ let input = null;
7888
+ let source = '';
7889
+ if (opts.text !== undefined && opts.text !== null && String(opts.text) !== '') {
7890
+ // v0.18.0:内联文本输入(MCP 侧用它替代文件路径,避免把 --from 变成任意文件读取通道)。
7891
+ input = String(opts.text);
7892
+ source = 'inline';
7893
+ } else if (opts.from !== undefined && opts.from !== null && String(opts.from) !== '') {
7894
+ if (String(opts.from) === '-') {
7895
+ input = fs.readFileSync(0, 'utf8');
7896
+ source = 'stdin';
7897
+ } else {
7898
+ const fp = path.resolve(String(opts.from));
7899
+ if (!fs.existsSync(fp)) return { error: true, exitCode: 2, text: '输入文件不存在: ' + fp };
7900
+ input = fs.readFileSync(fp, 'utf8');
7901
+ source = fp;
7902
+ }
7903
+ } else {
7904
+ // noStdin:MCP(stdio)的 fd 0 是协议流,绝不能被当成待审计内容读走。
7905
+ const piped = opts.noStdin ? null : contextAuditReadStdin();
7906
+ if (piped !== null && piped.trim()) { input = piped; source = 'stdin'; }
7907
+ }
7908
+ const entries = [];
7909
+ for (const r of roots) {
7910
+ for (const e of indexEntriesFor(r, {}).entries) {
7911
+ if (classifyRead(e, owner, '', !!opts.unsafe, selfAgent) === 'denied') continue;
7912
+ entries.push(e);
7913
+ }
7914
+ }
7915
+ const gate = contextAuditGate(opts);
7916
+ if (input === null) {
7917
+ const ctx = contextCore(Object.assign({}, opts, { audit: false, explain: true, noUsage: true }));
7918
+ const drops = (ctx.trace || []).filter(function (t) { return t.indexOf('[dropped]') === 0; });
7919
+ const items = drops.map(function (line) {
7920
+ const m = /^\[dropped\]\s+(\S+)\s+reason:\s+(\S+)/.exec(line);
7921
+ const file = m ? m[1] : '';
7922
+ const reason = m ? m[2] : '';
7923
+ const found = entries.filter(function (e) { return e.file === file; })[0];
7924
+ return {
7925
+ file: file,
7926
+ reason: reason,
7927
+ recallable: !!found,
7928
+ subject: found ? found.subject : '',
7929
+ };
7930
+ });
7931
+ const unrecallable = items.filter(function (x) { return !x.recallable; }).length;
7932
+ const lines = ['## yotta-memory context --audit(丢弃清单审计)', ''];
7933
+ lines.push('- 模式: 无输入内容,审计当前上下文包的丢弃清单(duplicate / budget_exceeded)');
7934
+ lines.push('- 计数: dropped ' + items.length + ' 条 / 仍可召回 ' + (items.length - unrecallable) + ' 条 / 无法召回 ' + unrecallable + ' 条');
7935
+ lines.push('');
7936
+ if (!items.length) lines.push('(本次上下文包无丢弃项。)');
7937
+ for (const item of items) {
7938
+ lines.push('- ' + item.file + '(' + item.reason + ')' + (item.recallable
7939
+ ? ' → 仍可召回: yotta-memory recall "' + String(item.subject).replace(/"/g, "'") + '"'
7940
+ : ' → 未在当前可读索引中找到,请人工核对'));
7941
+ }
7942
+ if (gate !== null) lines.push('', '- --gate ' + gate + ': 无法召回条数 ' + unrecallable + '(超过门槛时 exit 1)');
7943
+ return {
7944
+ error: false,
7945
+ exitCode: (gate !== null && unrecallable > gate) ? 1 : 0,
7946
+ text: lines.join('\n'),
7947
+ report: {
7948
+ schemaVersion: 1,
7949
+ mode: 'dropped',
7950
+ summary: { dropped: items.length, recallable: items.length - unrecallable, unrecallable: unrecallable },
7951
+ items: items,
7952
+ },
7953
+ };
7954
+ }
7955
+ const segments = contextAuditSegments(input);
7956
+ const items = segments.map(function (text) {
7957
+ const cov = contextAuditCoverage(text, entries, minCoverage);
7958
+ let status = 'missing';
7959
+ let matched = null;
7960
+ if (cov.entry) { status = 'landed'; matched = cov.entry; }
7961
+ else if (cov.best && cov.coverage > 0 && cov.coverage >= minCoverage * 0.6) status = 'uncertain';
7962
+ return {
7963
+ text: text,
7964
+ status: status,
7965
+ coverage: round3(cov.coverage),
7966
+ matched_file: matched ? matched.file : '',
7967
+ matched_subject: matched ? matched.subject : '',
7968
+ suggested_command: status === 'missing' ? contextAuditRememberCommand(text) : '',
7969
+ };
7970
+ });
7971
+ const landed = items.filter(function (x) { return x.status === 'landed'; });
7972
+ const missing = items.filter(function (x) { return x.status === 'missing'; });
7973
+ const uncertain = items.filter(function (x) { return x.status === 'uncertain'; });
7974
+ const lines = ['## yotta-memory context --audit(上下文压缩审计)', ''];
7975
+ lines.push('- 来源: ' + source + '(只读;不会自动补写记忆)');
7976
+ lines.push('- 汇总: 候选 ' + items.length + ' 条 / 已落盘 ' + landed.length + ' 条 / 未落盘 ' + missing.length + ' 条 / 无法判定 ' + uncertain.length + ' 条');
7977
+ lines.push('');
7978
+ lines.push('### 未落盘(建议落盘,命令仅打印不执行)');
7979
+ if (!missing.length) lines.push('(无)');
7980
+ for (const item of missing) {
7981
+ lines.push('- ' + item.text.slice(0, 120));
7982
+ lines.push(' → ' + item.suggested_command);
7983
+ }
7984
+ lines.push('');
7985
+ lines.push('### 无法判定(人工核对)');
7986
+ if (!uncertain.length) lines.push('(无)');
7987
+ for (const item of uncertain) lines.push('- ' + item.text.slice(0, 120) + '(覆盖率 ' + item.coverage + ')');
7988
+ lines.push('');
7989
+ lines.push('### 已落盘');
7990
+ if (!landed.length) lines.push('(无)');
7991
+ for (const item of landed) lines.push('- ' + item.text.slice(0, 120) + ' → ' + item.matched_file);
7992
+ if (gate !== null) lines.push('', '- --gate ' + gate + ': 未落盘 ' + missing.length + ' 条(超过门槛时 exit 1)');
7993
+ return {
7994
+ error: false,
7995
+ exitCode: (gate !== null && missing.length > gate) ? 1 : 0,
7996
+ text: lines.join('\n'),
7997
+ report: {
7998
+ schemaVersion: 1,
7999
+ mode: 'input',
8000
+ source: source,
8001
+ thresholds: { min_coverage: minCoverage },
8002
+ summary: { candidates: items.length, landed: landed.length, missing: missing.length, uncertain: uncertain.length },
8003
+ items: items,
8004
+ },
8005
+ };
8006
+ }
8007
+
6938
8008
  // ---- v0.17.0 A1: bench 可复算基准评测(只读 / 本地 / 零依赖)----
6939
8009
  const BENCH_SCHEMA_VERSION = 1;
6940
8010
  const BENCH_DEFAULT_SEED = 20260925;
@@ -8023,17 +9093,20 @@ async function cmdScan(opts) {
8023
9093
 
8024
9094
  // ---- config 命令 ----
8025
9095
  // v0.10.0:maintain_* / consolidate_* 数值键纳入 config set/get(此前只支持 3 个键但文档已写可调)
8026
- function isNumericConfigKey(key) { return /^(maintain_|consolidate_|scale_|backup_max_age_hours$)/.test(key); }
9096
+ 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
9097
  // 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'];
9098
+ 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'];
9099
+ // v0.18.1 验收修正:config set / get 共用一份键列表,新增配置键不会再出现「读端认、写端拒」。
9100
+ const CONFIG_VALUE_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', 'maintain_rule_min_hits', '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);
8029
9101
  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); }
9102
+ const known = CONFIG_VALUE_KEYS;
9103
+ 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
9104
  if (value === undefined || value === null || value === '') { console.error('缺少值: config set ' + key + ' <值>'); process.exit(2); }
8033
9105
  const cfg = loadConfig();
8034
9106
  if (key === 'memory_home') cfg.memory_home = value;
8035
9107
  else if (key === 'backup_dir') cfg.backup_dir = value;
8036
9108
  else if (key === 'backup_enabled') cfg.backup_enabled = value !== 'false' && value !== '0';
9109
+ else if (key === 'usage_enabled') cfg.usage_enabled = value !== 'false' && value !== '0';
8037
9110
  else if (key === 'backup_schedule' || key === 'backup_time' || key === 'backup_setup_choice') cfg[key] = value;
8038
9111
  else if (key === 'embedding_cmd') cfg.embedding_cmd = value;
8039
9112
  else if (key === 'embedding_timeout') cfg.embedding_timeout = parseInt(value, 10) || 3000;
@@ -8059,11 +9132,12 @@ function cmdConfigGet(opts) {
8059
9132
  console.log('backup_enabled: ' + (cfg.backup_enabled === undefined ? '(未设置)' : cfg.backup_enabled));
8060
9133
  console.log('backup_schedule: ' + (cfg.backup_schedule || '(未设置)'));
8061
9134
  console.log('backup_time: ' + (cfg.backup_time || '(未设置)'));
9135
+ console.log('usage_enabled: ' + (cfg.usage_enabled === undefined ? '(默认 true)' : cfg.usage_enabled));
8062
9136
  console.log('embedding_cmd: ' + (cfg.embedding_cmd || '(未设置)'));
8063
9137
  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);
9138
+ const keys = CONFIG_VALUE_KEYS;
8065
9139
  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;
9140
+ 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
9141
  if (cfg[k] !== undefined) console.log(k + ': ' + cfg[k]);
8068
9142
  }
8069
9143
  console.log('当前生效用户级位置: ' + userRoot());
@@ -8080,17 +9154,18 @@ function mcpTools(profile) {
8080
9154
  { 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
9155
  { 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
9156
  { 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' } } } },
9157
+ { 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
9158
  { name: 'doctor', description: '开工可靠性检查:检查记忆库根目录、密钥库、索引、身份登记与最近备份;只读,不会修改记忆', inputSchema: { type: 'object', properties: {} } },
8085
9159
  { 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' } } } },
9160
+ { 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
9161
  { name: 'reindex', description: '重建索引(手动改 .md 后校正;扫描 facts/prefs/bounds/commits 四目录)', inputSchema: { type: 'object', properties: {} } },
8088
9162
  { name: 'export', description: '导出全部记忆到记忆库内的 JSON 文件。out 可选(默认 <记忆库>/yottamemory-export-<日期>.json;仅限记忆库内路径)', inputSchema: { type: 'object', properties: { out: { type: 'string' } } } },
8089
9163
  { name: 'import', description: '从记忆库内的 JSON 文件导入记忆。src 为文件路径(相对记忆库目录,或记忆库内绝对路径;仅限记忆库内)', inputSchema: { type: 'object', properties: { src: { type: 'string' } }, required: ['src'] } },
8090
9164
  { name: 'agent_info', description: '查看当前智能体身份与登记状态(HTTP 读经 token 校验的 X-Agent-Id;stdio 读 --agent-id 显式参数)。开工先确认「我是谁」,禁止从记忆里抄别人的 ID', inputSchema: { type: 'object', properties: {} } },
8091
9165
  { name: 'profile', description: '生成当前智能体的用户画像(只读聚合 private/<owner>/ 下 PREF/BOUND/COMMIT 原文,零推断,写入 private/<owner>/profile.md)。owner 默认当前智能体', inputSchema: { type: 'object', properties: { owner: { type: 'string' } } } },
8092
9166
  { 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' } } } },
9167
+ { name: 'maintain', description: '记忆自组织(自我进化):规则层归档/遗忘/去重预览。默认 dry-run;apply 才执行,purge 才真删;capacity=true 改为只读容量水位报告;rules=true 改为只读规则晋升建议', inputSchema: { type: 'object', properties: { apply: { type: 'boolean' }, purge: { type: 'boolean' }, dedup: { type: 'boolean' }, capacity: { type: 'boolean' }, rules: { type: 'boolean' }, limit: { type: 'number' }, threshold: { type: 'number' }, age: { type: 'number' } } } },
9168
+ { 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
9169
  { name: 'distill', description: '心理日志蒸馏(自我提升):统计摘要/主题画像/知识地图。owner 默认当前智能体(MCP 不支持 --model 外部命令)', inputSchema: { type: 'object', properties: { owner: { type: 'string' }, subject: { type: 'string' } } } },
8095
9170
  { name: 'explain', description: '解释单条记忆效用分项(为什么靠前/归档/遗忘)。file 为记忆文件路径或文件名', inputSchema: { type: 'object', properties: { file: { type: 'string' } }, required: ['file'] } },
8096
9171
  ];
@@ -8132,6 +9207,16 @@ function callToolInner(name, args, ctx) {
8132
9207
  return { text: r.text, error: r.error };
8133
9208
  }
8134
9209
  if (name === 'context') {
9210
+ if (args.audit) {
9211
+ // v0.18.0 A7:MCP 侧只接受内联文本(不接受文件路径,避免把 --from 变成任意文件读取通道),
9212
+ // 也不读 stdin(stdio MCP 的 fd 0 是协议流)。
9213
+ const auditText = (args.auditText !== undefined && args.auditText !== null) ? String(args.auditText) : '';
9214
+ if (!auditText.trim()) {
9215
+ return { text: '拒绝: MCP 的 context audit 只接受内联文本(auditText)。审计本地文件或标准输入请在本机 CLI 运行 yotta-memory context --audit --from <文件|->。', error: true };
9216
+ }
9217
+ const r = contextAuditCore({ selfAgent: agent, owner: agent, text: auditText, gate: args.gate, noStdin: true });
9218
+ return { text: r.text, error: r.error };
9219
+ }
8135
9220
  const r = contextCore({
8136
9221
  focus: args.focus ? String(args.focus) : '',
8137
9222
  limit: args.limit || 10,
@@ -8151,7 +9236,7 @@ function callToolInner(name, args, ctx) {
8151
9236
  return { text: r.text, error: r.error };
8152
9237
  }
8153
9238
  if (name === 'archive') {
8154
- const r = archiveCore({ selfAgent: agent, days: args.days, threshold: args.threshold });
9239
+ const r = archiveCore({ selfAgent: agent, days: args.days, threshold: args.threshold, dryRun: !!args.dryRun });
8155
9240
  return { text: r.text, error: r.error };
8156
9241
  }
8157
9242
  if (name === 'reindex') {
@@ -8208,7 +9293,17 @@ function callToolInner(name, args, ctx) {
8208
9293
  return { text: r.text, error: r.error };
8209
9294
  }
8210
9295
  if (name === 'maintain') {
8211
- const r = maintainCore({ selfAgent: agent, apply: !!args.apply, purge: !!args.purge, dedup: !!args.dedup, threshold: args.threshold, age: args.age });
9296
+ const r = args.capacity
9297
+ ? capacityCore({ selfAgent: agent, limit: args.limit })
9298
+ : maintainCore({ selfAgent: agent, apply: !!args.apply, purge: !!args.purge, dedup: !!args.dedup, rules: !!args.rules, threshold: args.threshold, age: args.age });
9299
+ return { text: r.text, error: r.error };
9300
+ }
9301
+ if (name === 'consolidate') {
9302
+ // v0.18.0 A5:MCP 只读 propose;apply / undo / batches 留在本机 CLI(由用户确认后执行)。
9303
+ if (args.apply || args.undo || args.batches || args.yes) {
9304
+ return { text: '拒绝: MCP 的 consolidate 只提供只读 propose 报告;--apply / --undo / --batches 请在本机 CLI 由用户确认后运行(AI 不得代替用户执行 --apply)。', error: true };
9305
+ }
9306
+ 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
9307
  return { text: r.text, error: r.error };
8213
9308
  }
8214
9309
  if (name === 'distill') {
@@ -9040,6 +10135,14 @@ function consolidateUndoCore(batch, opts) {
9040
10135
  try {
9041
10136
  if (fs.existsSync(to)) fs.unlinkSync(to);
9042
10137
  if (fs.existsSync(from)) {
10138
+ const archiveOwner = archiveOwnerFromPath(x.root, from);
10139
+ try {
10140
+ const marked = readMemoryText(x.root, from, archiveOwner);
10141
+ const clean = stripConsolidateMarker(marked, x.rec.had_trailing_newline !== false);
10142
+ if (clean !== marked) writeMemoryText(x.root, from, clean, archiveOwner);
10143
+ } catch (eMarker) {
10144
+ err = err || ('巩固标记剥离失败: ' + x.rec.file + ' ' + eMarker.message);
10145
+ }
9043
10146
  fs.mkdirSync(path.dirname(to), { recursive: true });
9044
10147
  fs.renameSync(from, to);
9045
10148
  upsertIndexEntry(x.root, readEntry(to, x.root));
@@ -9103,7 +10206,102 @@ function clusterConsolidateCandidates(cands) {
9103
10206
  return groups;
9104
10207
  }
9105
10208
 
9106
- function applyConsolidateGroup(root, group, batch, period) {
10209
+ // ---- v0.18.1 A9:相对日期绝对化 + 归档巩固标记 ----
10210
+ const CONSOLIDATE_MARKER_PREFIX = '<!-- yotta-memory: consolidated to ';
10211
+ const CONSOLIDATE_MARKER_RE = /<!-- yotta-memory: consolidated to [^\n]* -->\n?/;
10212
+ function ymdPad(n) { return String(n).padStart(2, '0'); }
10213
+ function ymdParse(s) {
10214
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(s || ''));
10215
+ if (!m) return null;
10216
+ const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
10217
+ if (d.getFullYear() !== Number(m[1]) || d.getMonth() !== Number(m[2]) - 1 || d.getDate() !== Number(m[3])) return null;
10218
+ return d;
10219
+ }
10220
+ function ymdFormat(d) { return d.getFullYear() + '-' + ymdPad(d.getMonth() + 1) + '-' + ymdPad(d.getDate()); }
10221
+ function ymdAdd(s, days) { const d = ymdParse(s); if (!d) return ''; d.setDate(d.getDate() + days); return ymdFormat(d); }
10222
+ function ymdWeekRange(s) {
10223
+ const d = ymdParse(s);
10224
+ if (!d) return '';
10225
+ const mondayOffset = (d.getDay() + 6) % 7;
10226
+ const mon = new Date(d.getFullYear(), d.getMonth(), d.getDate() - mondayOffset);
10227
+ const sun = new Date(mon.getFullYear(), mon.getMonth(), mon.getDate() + 6);
10228
+ return ymdFormat(mon) + ' ~ ' + ymdFormat(sun);
10229
+ }
10230
+ function ymdMonthRange(s, offset) {
10231
+ const d = ymdParse(s);
10232
+ if (!d) return '';
10233
+ const first = new Date(d.getFullYear(), d.getMonth() + (offset || 0), 1);
10234
+ const last = new Date(first.getFullYear(), first.getMonth() + 1, 0);
10235
+ return ymdFormat(first) + ' ~ ' + ymdFormat(last);
10236
+ }
10237
+ function ymdYearRange(s, offset) {
10238
+ const d = ymdParse(s);
10239
+ if (!d) return '';
10240
+ const y = d.getFullYear() + (offset || 0);
10241
+ return y + '-01-01 ~ ' + y + '-12-31';
10242
+ }
10243
+ const RELATIVE_DATE_MAP = {
10244
+ '大前天': function (b) { return ymdAdd(b, -3); },
10245
+ '上上周': function (b) { return ymdWeekRange(ymdAdd(b, -7)); },
10246
+ '上个月': function (b) { return ymdMonthRange(b, -1); },
10247
+ '这个月': function (b) { return ymdMonthRange(b, 0); },
10248
+ '前天': function (b) { return ymdAdd(b, -2); },
10249
+ '昨天': function (b) { return ymdAdd(b, -1); },
10250
+ '昨日': function (b) { return ymdAdd(b, -1); },
10251
+ '今天': function (b) { return ymdAdd(b, 0); },
10252
+ '今日': function (b) { return ymdAdd(b, 0); },
10253
+ '明天': function (b) { return ymdAdd(b, 1); },
10254
+ '后天': function (b) { return ymdAdd(b, 2); },
10255
+ '上周': function (b) { return ymdWeekRange(ymdAdd(b, -7)); },
10256
+ '本周': function (b) { return ymdWeekRange(b); },
10257
+ '这周': function (b) { return ymdWeekRange(b); },
10258
+ '上月': function (b) { return ymdMonthRange(b, -1); },
10259
+ '本月': function (b) { return ymdMonthRange(b, 0); },
10260
+ '今年': function (b) { return ymdYearRange(b, 0); },
10261
+ '去年': function (b) { return ymdYearRange(b, -1); },
10262
+ };
10263
+ const RELATIVE_DATE_RE = /(大前天|上上周|上个月|这个月|前天|昨天|昨日|今天|今日|明天|后天|上周|本周|这周|上月|本月|今年|去年)(?![((]\d{4}-\d{2}-\d{2})/g;
10264
+ function absolutizeRelativeDates(text, baseYmd) {
10265
+ const s = String(text || '');
10266
+ if (!ymdParse(baseYmd)) return { text: s, count: 0 };
10267
+ let count = 0;
10268
+ const out = s.replace(RELATIVE_DATE_RE, function (word) {
10269
+ const fn = RELATIVE_DATE_MAP[word];
10270
+ if (!fn) return word;
10271
+ const rep = fn(baseYmd);
10272
+ if (!rep) return word;
10273
+ count += 1;
10274
+ return word + '(' + rep + ')';
10275
+ });
10276
+ return { text: out, count: count };
10277
+ }
10278
+ function consolidateMarkerText(summaryRel, batch) {
10279
+ return CONSOLIDATE_MARKER_PREFIX + summaryRel + ' | batch ' + batch + ' | ' + today() + ' -->';
10280
+ }
10281
+ function archiveOwnerFromPath(root, fp) {
10282
+ const seg = path.relative(root, fp).split(/[\\/]/);
10283
+ if (seg[0] === ARCHIVE_DIR && seg[1] === PRIVATE_DIR && seg[2]) return seg[2];
10284
+ return ownerFromPrivatePath(root, fp);
10285
+ }
10286
+ function appendConsolidateMarker(root, fp, summaryRel, batch, owner, opts) {
10287
+ if (opts && opts.markerFailForTest) throw new Error('marker fail for test');
10288
+ let text = readMemoryText(root, fp, owner);
10289
+ if (text.indexOf(CONSOLIDATE_MARKER_PREFIX) !== -1) return { appended: false, hadTrailingNewline: text.endsWith('\n') };
10290
+ const hadTrailingNewline = text.endsWith('\n');
10291
+ if (!hadTrailingNewline) text += '\n';
10292
+ text += consolidateMarkerText(summaryRel, batch) + '\n';
10293
+ writeMemoryText(root, fp, text, owner);
10294
+ return { appended: true, hadTrailingNewline: hadTrailingNewline };
10295
+ }
10296
+ function stripConsolidateMarker(text, hadTrailingNewline) {
10297
+ let out = String(text || '');
10298
+ if (out.indexOf(CONSOLIDATE_MARKER_PREFIX) === -1) return out;
10299
+ out = out.replace(CONSOLIDATE_MARKER_RE, '');
10300
+ if (hadTrailingNewline === false && out.endsWith('\n')) out = out.slice(0, -1);
10301
+ return out;
10302
+ }
10303
+
10304
+ function applyConsolidateGroup(root, group, batch, period, opts) {
9107
10305
  const e0 = group[0];
9108
10306
  const type = e0.type;
9109
10307
  const owner = e0.scope === 'private' ? e0.owner : '';
@@ -9127,7 +10325,12 @@ function applyConsolidateGroup(root, group, batch, period) {
9127
10325
  const avgU = round3(group.reduce(function (s, c) { return s + c.utility; }, 0) / group.length);
9128
10326
  const bodyLines = ['# 周期摘要:' + theme, '', '> 生成: yotta-memory consolidate | 类型 ' + type + ' | 范围 ' + createdDates[0] + ' ~ ' + createdDates[createdDates.length - 1] + ' | ' + group.length + ' 条 | 平均效用 ' + avgU, '', '## 主题要点', ''];
9129
10327
  const sorted = group.slice().sort(function (a, b) { return String(b.e.updated || '').localeCompare(String(a.e.updated || '')); });
9130
- for (const c of sorted) bodyLines.push('- [' + c.e.type + '] ' + (c.e.subject || '') + ': ' + (c.e.statement || ''));
10328
+ let dateNormalized = 0;
10329
+ for (const c of sorted) {
10330
+ const norm = absolutizeRelativeDates(c.e.statement || '', c.e.created || c.e.updated || '');
10331
+ dateNormalized += norm.count;
10332
+ bodyLines.push('- [' + c.e.type + '] ' + (c.e.subject || '') + ': ' + norm.text);
10333
+ }
9131
10334
  bodyLines.push('', '## 溯源(原文已入 .archive,可 consolidate --undo <batch> 恢复)', '');
9132
10335
  for (const c of group) bodyLines.push('- ' + c.e.file + '(created ' + c.e.created + ' / confidence ' + c.e.confidence + ' / utility ' + round3(c.utility) + ')');
9133
10336
  const body = bodyLines.join('\n');
@@ -9138,16 +10341,57 @@ function applyConsolidateGroup(root, group, batch, period) {
9138
10341
  upsertIndexEntry(root, readEntry(fp, root));
9139
10342
  appendAudit(root, 'audit', { action: 'summary_create', ts: new Date().toISOString(), batch: batch, file: summaryRel, sourceFiles: group.map(function (c) { return c.e.file; }) });
9140
10343
  let moved = 0;
10344
+ let markersWritten = 0;
10345
+ let markerErrors = 0;
9141
10346
  for (const c of group) {
9142
10347
  const destDir = archiveDirFor(root, type, owner);
9143
10348
  fs.mkdirSync(destDir, { recursive: true });
9144
10349
  const dest = path.join(destDir, path.basename(c.e.file));
9145
10350
  fs.renameSync(path.join(root, c.e.file), dest);
10351
+ let marker = { appended: false, hadTrailingNewline: true };
10352
+ try {
10353
+ marker = appendConsolidateMarker(root, dest, summaryRel, batch, owner, opts);
10354
+ if (marker.appended) markersWritten += 1;
10355
+ } catch (e) {
10356
+ markerErrors += 1;
10357
+ appendAudit(root, 'audit', { action: 'marker_failed', ts: new Date().toISOString(), batch: batch, file: c.e.file, to: relOf(root, dest), error: String(e.message || e) });
10358
+ }
9146
10359
  removeIndexEntry(root, c.e.file);
9147
- appendAudit(root, 'audit', { action: 'archive', ts: new Date().toISOString(), batch: batch, file: c.e.file, to: relOf(root, dest), type: type, owner: owner, utility: round3(c.utility) });
10360
+ appendAudit(root, 'audit', { action: 'archive', ts: new Date().toISOString(), batch: batch, file: c.e.file, to: relOf(root, dest), type: type, owner: owner, utility: round3(c.utility), had_trailing_newline: marker.hadTrailingNewline === true });
9148
10361
  moved++;
9149
10362
  }
9150
- return { made: 1, moved: moved, summaryRel: summaryRel };
10363
+ return { made: 1, moved: moved, summaryRel: summaryRel, dateNormalized: dateNormalized, markersWritten: markersWritten, markerErrors: markerErrors };
10364
+ }
10365
+
10366
+ // v0.18.0 A5:首次启用说明按 .archive 审计判定(不新建标记文件)。
10367
+ function consolidateHasManifest(root) {
10368
+ const dir = path.join(root, ARCHIVE_DIR);
10369
+ let names;
10370
+ try { names = fs.readdirSync(dir); } catch (e) { return false; }
10371
+ for (const name of names) {
10372
+ if (!/^audit-\d{4}-\d{2}-\d{2}\.jsonl$/.test(name)) continue;
10373
+ let raw;
10374
+ try { raw = fs.readFileSync(path.join(dir, name), 'utf8'); } catch (e) { continue; }
10375
+ for (const line of raw.split('\n')) {
10376
+ if (!line.trim()) continue;
10377
+ let rec;
10378
+ try { rec = JSON.parse(line); } catch (e) { continue; }
10379
+ if (rec && rec.action === 'manifest' && rec.command === 'consolidate') return true;
10380
+ }
10381
+ }
10382
+ return false;
10383
+ }
10384
+ function consolidateFirstRunNotice() {
10385
+ return [
10386
+ '### 首次启用说明(只显示一次)',
10387
+ '- 保留期:原文移入 .archive/ 后一直保留,直到你显式 purge 或 undo;不会自动删除。',
10388
+ '- 检查:yotta-memory consolidate(默认 --propose,只出报告不写盘)。',
10389
+ '- 纠正:yotta-memory consolidate --undo <batch>。',
10390
+ '- 导出:yotta-memory export。',
10391
+ '- 停用:不再执行 --apply,或用 yotta-memory config set consolidate_min_age 99999 抬高门槛。',
10392
+ '- 删除:yotta-memory maintain --purge(对遗忘候选真删)。',
10393
+ '- AI 不得代替用户执行 --apply;--apply 需要确认,非交互必须显式加 --yes。',
10394
+ ];
9151
10395
  }
9152
10396
 
9153
10397
  function consolidateCore(opts) {
@@ -9157,6 +10401,15 @@ function consolidateCore(opts) {
9157
10401
  const root = userRoot();
9158
10402
  if (!fs.existsSync(root)) return { error: false, text: '记忆库不存在。' };
9159
10403
  const apply = !!opts.apply;
10404
+ // v0.18.0 A5:--apply 是破坏性写入;非交互必须显式 --yes,交互确认在 cmdConsolidate 完成。
10405
+ if (apply && !opts.yes) {
10406
+ return {
10407
+ error: true,
10408
+ exitCode: 2,
10409
+ text: '拒绝: consolidate --apply 会移动原文并生成摘要(原文保留在 .archive/,可用 --undo 回滚)。请先运行 yotta-memory consolidate 审阅候选报告;交互式执行会要求输入确认串,非交互必须显式加 --yes。',
10410
+ };
10411
+ }
10412
+ const firstRunNotice = consolidateHasManifest(root) ? [] : consolidateFirstRunNotice();
9160
10413
  const selfAgent = resolveIdentity(opts).id;
9161
10414
  const unsafe = !!opts.unsafe;
9162
10415
  const cfg = loadConfig();
@@ -9168,7 +10421,7 @@ function consolidateCore(opts) {
9168
10421
  const period = (opts.period !== undefined && opts.period !== null) ? opts.period : (parseInt(cfg.consolidate_period || '90', 10) || 90);
9169
10422
  const typeOnly = opts.type ? String(opts.type).toUpperCase() : '';
9170
10423
  const lines = ['## yotta-memory consolidate(周期摘要压缩)'];
9171
- lines.push('- 模式: ' + (apply ? '执行' : '预览(dry-run,未改动;加 --apply 执行)'));
10424
+ lines.push('- 模式: ' + (apply ? 'apply(执行压缩)' : 'propose(只出报告,未改动;确认后加 --apply --yes 执行)'));
9172
10425
  lines.push('- 候选: 天龄 ≥ ' + minAge + ' 天 ∧ 闲置 ≥ ' + minIdle + ' 天 ∧ utility ≤ ' + maxU + (typeOnly ? ' ∧ 仅类型 ' + typeOnly : '') + ';immutable / BOUND 豁免');
9173
10426
  lines.push('');
9174
10427
  const cands = [];
@@ -9191,16 +10444,65 @@ function consolidateCore(opts) {
9191
10444
  if (age < minAge || idle < minIdle || u > maxU) { stats.active++; continue; }
9192
10445
  cands.push({ e: e, owner: owner, type: e.type, age: age, idle: idle, utility: u, scope: e.scope === 'private' ? 'private' : 'public' });
9193
10446
  }
10447
+ const makeConsolidateGroup = function (g) {
10448
+ const type = g[0].type;
10449
+ const owner = g[0].scope === 'private' ? (g[0].owner || '') : '';
10450
+ const ages = g.map(function (c) { return c.age; });
10451
+ const theme = pickSummaryTheme(g);
10452
+ return {
10453
+ type: type,
10454
+ scope: g[0].scope === 'private' ? 'private' : 'public',
10455
+ owner: owner,
10456
+ count: g.length,
10457
+ age_min: Math.min.apply(null, ages),
10458
+ age_max: Math.max.apply(null, ages),
10459
+ avg_utility: round3(g.reduce(function (s, c) { return s + c.utility; }, 0) / g.length),
10460
+ theme: theme,
10461
+ summary_subject: '周期摘要 ' + theme + '(' + period + ' 天窗)',
10462
+ files: g.map(function (c) { return c.e.file; }),
10463
+ archive_to: path.relative(root, archiveDirFor(root, type, owner)).replace(/\\/g, '/'),
10464
+ date_normalized_preview: g.reduce(function (s, c) {
10465
+ return s + absolutizeRelativeDates(c.e.statement || '', c.e.created || c.e.updated || '').count;
10466
+ }, 0),
10467
+ };
10468
+ };
10469
+ const makeConsolidateReport = function (builtGroups, batch, extras) {
10470
+ return {
10471
+ schemaVersion: 1,
10472
+ mode: apply ? 'apply' : 'propose',
10473
+ batch: batch || '',
10474
+ root: root,
10475
+ thresholds: {
10476
+ min_age: minAge,
10477
+ min_idle: minIdle,
10478
+ max_utility: maxU,
10479
+ min_group: minGroup,
10480
+ period: period,
10481
+ },
10482
+ stats: stats,
10483
+ candidates: cands.length,
10484
+ groups: builtGroups.map(makeConsolidateGroup),
10485
+ date_normalized: (extras && extras.dateNormalized) || 0,
10486
+ markers_written: (extras && extras.markersWritten) || 0,
10487
+ marker_errors: (extras && extras.markerErrors) || 0,
10488
+ retention: '原文移入 .archive/ 后一直保留,直到显式 purge 或 undo。',
10489
+ rollback: batch
10490
+ ? ('yotta-memory consolidate --undo ' + batch)
10491
+ : 'yotta-memory consolidate --undo <batch>(执行后批次号见报告)',
10492
+ };
10493
+ };
9194
10494
  if (!cands.length) {
9195
10495
  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') };
10496
+ if (firstRunNotice.length) { lines.push(''); lines.push.apply(lines, firstRunNotice); }
10497
+ return { error: false, text: lines.join('\n'), report: makeConsolidateReport([], '') };
9197
10498
  }
9198
10499
  const groups = clusterConsolidateCandidates(cands);
9199
10500
  lines.push('候选 ' + cands.length + ' 条(扫描 ' + stats.scan + ',跳过: 其它 owner/无密钥 ' + stats.denied + ' / immutable ' + stats.immutable + ' / BOUND ' + stats.bound + ' / 类型过滤 ' + stats.typeSkip + ' / 无 created ' + stats.noCreated + ' / 不够老或仍活跃 ' + stats.active + ')');
9200
10501
  if (!groups.length) {
9201
10502
  lines.push('');
9202
10503
  lines.push('(无可归纳组:同主题聚类 ≥ ' + minGroup + ' 条才生成周期摘要;零散单条旧记忆请用 maintain --apply 归档)');
9203
- return { error: false, text: lines.join('\n') };
10504
+ if (firstRunNotice.length) { lines.push(''); lines.push.apply(lines, firstRunNotice); }
10505
+ return { error: false, text: lines.join('\n'), report: makeConsolidateReport([], '') };
9204
10506
  }
9205
10507
  lines.push('');
9206
10508
  lines.push('### 周期摘要组(' + groups.length + ' 组,每组将生成摘要 1 条 + 归档原文 N 条)');
@@ -9211,12 +10513,19 @@ function consolidateCore(opts) {
9211
10513
  const minA = Math.min.apply(null, ages), maxA = Math.max.apply(null, ages);
9212
10514
  const avgU = round3(g.reduce(function (s, c) { return s + c.utility; }, 0) / g.length);
9213
10515
  lines.push('- [' + type + '] 主题: ' + pickSummaryTheme(g) + '(' + scopeLabel + ',' + g.length + ' 条,' + minA + '~' + maxA + ' 天,平均效用 ' + avgU + ')' + (type === 'COMMIT' ? ' ⚠️ COMMIT 承诺/任务类,请确认' : ''));
10516
+ const datePreview = g.reduce(function (s, c) {
10517
+ return s + absolutizeRelativeDates(c.e.statement || '', c.e.created || c.e.updated || '').count;
10518
+ }, 0);
10519
+ lines.push(' → 将生成 周期摘要 ' + pickSummaryTheme(g) + '(' + period + ' 天窗),日期归一预览 ' + datePreview + ' 处,原文归档到 ' + path.relative(root, archiveDirFor(root, type, g[0].owner || '')).replace(/\\/g, '/'));
9214
10520
  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
10521
  }
9216
10522
  if (!apply) {
9217
10523
  lines.push('');
9218
- lines.push('dry-run 未执行任何变更。确认后加 --apply 执行(写入批次审计,可 consolidate --undo <batch> 回滚)。');
9219
- return { error: false, text: lines.join('\n') };
10524
+ lines.push('- 保留期: 原文移入 .archive/ 后一直保留,直到显式 purge 或 undo。');
10525
+ lines.push('- 回滚: 执行后可用 yotta-memory consolidate --undo <batch> 恢复。');
10526
+ lines.push('未执行任何变更。确认后加 --apply(非交互再加 --yes)执行。');
10527
+ if (firstRunNotice.length) { lines.push(''); lines.push.apply(lines, firstRunNotice); }
10528
+ return { error: false, text: lines.join('\n'), report: makeConsolidateReport(groups, '') };
9220
10529
  }
9221
10530
  const guard = destructiveGuardCore({
9222
10531
  root: root,
@@ -9233,20 +10542,45 @@ function consolidateCore(opts) {
9233
10542
  lines.push('');
9234
10543
  lines.push('- 事务快照: ' + guard.snapshot.id);
9235
10544
  lines.push('- 批次: ' + batch + '(审计 .archive/audit-<日期>.jsonl;回滚: yotta-memory consolidate --undo ' + batch + ')');
9236
- let made = 0, moved = 0;
10545
+ let made = 0, moved = 0, dateNormalized = 0, markersWritten = 0, markerErrors = 0;
9237
10546
  for (const g of groups) {
9238
- const res = applyConsolidateGroup(root, g, batch, period);
10547
+ const res = applyConsolidateGroup(root, g, batch, period, opts);
9239
10548
  made += res.made; moved += res.moved;
10549
+ dateNormalized += res.dateNormalized; markersWritten += res.markersWritten; markerErrors += res.markerErrors;
9240
10550
  lines.push('- 已生成摘要 ' + res.summaryRel + '(归档原文 ' + res.moved + ' 条)');
9241
10551
  }
9242
10552
  appendAudit(root, 'audit', { action: 'batch_done', ts: new Date().toISOString(), batch: batch, command: 'consolidate', summaries: made, archived: moved, active_after: collectEntryFiles(root).length });
9243
10553
  lines.push('- 完成: 生成摘要 ' + made + ' 条 / 原文归档 ' + moved + ' 条');
9244
- return { error: false, text: lines.join('\n') };
9245
- }
9246
-
9247
- function cmdConsolidate(opts) {
10554
+ lines.push('- 日期归一: ' + dateNormalized + ' 处;巩固标记: ' + markersWritten + ' 条(失败 ' + markerErrors + ')');
10555
+ if (firstRunNotice.length) { lines.push(''); lines.push.apply(lines, firstRunNotice); }
10556
+ return { error: false, text: lines.join('\n'), report: makeConsolidateReport(groups, batch, { dateNormalized: dateNormalized, markersWritten: markersWritten, markerErrors: markerErrors }) };
10557
+ }
10558
+
10559
+ async function cmdConsolidate(opts) {
10560
+ // v0.18.0 A5:交互式确认串(X 组 / Y 条);非交互明确拒绝并提示 --yes。
10561
+ if (opts.apply && !opts.yes) {
10562
+ if (!process.stdin.isTTY) {
10563
+ const blocked = consolidateCore(opts);
10564
+ console.log(blocked.text);
10565
+ process.exit(blocked.exitCode || 2);
10566
+ }
10567
+ const plan = consolidateCore(Object.assign({}, opts, { apply: false }));
10568
+ const groups = (plan.report && plan.report.groups) || [];
10569
+ const items = groups.reduce(function (s, g) { return s + g.count; }, 0);
10570
+ if (groups.length) {
10571
+ const token = groups.length + ' 组 / ' + items + ' 条';
10572
+ const typed = await promptLine('即将把 ' + token + ' 压缩为摘要(原文保留在 .archive/,可 --undo 回滚)。输入「' + token + '」确认: ');
10573
+ if (typed === null || String(typed).trim() !== token) {
10574
+ console.log('已取消,未做任何改动。');
10575
+ process.exit(2);
10576
+ }
10577
+ }
10578
+ opts = Object.assign({}, opts, { yes: true });
10579
+ }
9248
10580
  const r = consolidateCore(opts);
9249
- console.log(r.text);
10581
+ if (opts.json && r.report) console.log(JSON.stringify(r.report, null, 2));
10582
+ else console.log(r.text);
10583
+ if (r.exitCode) process.exit(r.exitCode);
9250
10584
  if (r.error) process.exit(2);
9251
10585
  }
9252
10586
 
@@ -9539,6 +10873,11 @@ async function main() {
9539
10873
  else if (a === '--yes') opts.yes = true;
9540
10874
  else if (a === '--keep-memories') opts.keepMemories = true;
9541
10875
  else if (a === '--keep-identity') opts.keepIdentity = true;
10876
+ else if (a === '--no-usage') opts.noUsage = true;
10877
+ else if (a === '--capacity') opts.capacity = true;
10878
+ else if (a === '--rules') opts.rules = true;
10879
+ else if (a === '--propose') opts.propose = true;
10880
+ else if (a === '--audit') opts.audit = true;
9542
10881
  else if (valueOpts.has(a)) {
9543
10882
  const v = args[++i];
9544
10883
  if (a === '--type') opts.type = v;
@@ -9593,6 +10932,7 @@ async function main() {
9593
10932
  else if (a === '--against') opts.against = v;
9594
10933
  else if (a === '--template') opts.template = v;
9595
10934
  else if (a === '--path') opts.path = v;
10935
+ else if (a === '--from') opts.from = v;
9596
10936
  } else if (a.startsWith('--')) {
9597
10937
  if (a === '--query') {
9598
10938
  console.error('未知选项: --query。recall/search 的关键词是位置参数:yotta-memory recall [关键词]');
@@ -9649,7 +10989,7 @@ async function main() {
9649
10989
  case 'scan': await cmdScan(opts); break;
9650
10990
  case 'maintain': cmdMaintain(opts); break;
9651
10991
  case 'distill': cmdDistill(opts); break;
9652
- case 'consolidate': if (opts.undo === true && rest.length) opts.undo = rest[0]; cmdConsolidate(opts); break;
10992
+ case 'consolidate': if (opts.undo === true && rest.length) opts.undo = rest[0]; await cmdConsolidate(opts); break;
9653
10993
  case 'forget': cmdForget(rest[0], opts); break;
9654
10994
  case 'rename': cmdRename(rest[0], rest[1], opts); break;
9655
10995
  case 'archive': cmdArchive(opts); break;
@@ -9737,10 +11077,19 @@ module.exports = {
9737
11077
  recallCore: recallCore,
9738
11078
  profileCore: profileCore,
9739
11079
  contextCore: contextCore,
11080
+ contextAuditCore: contextAuditCore,
9740
11081
  importanceScore: importanceScore,
11082
+ usageSettings: usageSettings,
11083
+ usageTotals: usageTotals,
11084
+ usagePatchForMeta: usagePatchForMeta,
11085
+ applyUsageHits: applyUsageHits,
11086
+ parseHitDays: parseHitDays,
11087
+ getOwnerKeyFor: getOwnerKeyFor,
11088
+ loadOwnerIndex: loadOwnerIndex,
9741
11089
  forgetCore: forgetCore,
9742
11090
  renameCore: renameCore,
9743
11091
  archiveCore: archiveCore,
11092
+ capacityCore: capacityCore,
9744
11093
  maintainCore: maintainCore,
9745
11094
  consolidateCore: consolidateCore,
9746
11095
  mergeCore: mergeCore,
@@ -9760,6 +11109,8 @@ module.exports = {
9760
11109
  migrateLayout: migrateLayout,
9761
11110
  loadIndexFor: loadIndexFor,
9762
11111
  indexEntriesFor: indexEntriesFor,
11112
+ loadOwnerIndex: loadOwnerIndex,
11113
+ getOwnerKeyFor: getOwnerKeyFor,
9763
11114
  normalizeYearList: normalizeYearList,
9764
11115
  indexFingerprint: indexFingerprint,
9765
11116
  benchCore: benchCore,
@@ -9782,6 +11133,10 @@ module.exports = {
9782
11133
  SCALE_CONFIG_KEYS: SCALE_CONFIG_KEYS,
9783
11134
  SCALE_THRESHOLD_DEFAULTS: SCALE_THRESHOLD_DEFAULTS,
9784
11135
  scaleReport: scaleReport,
11136
+ USAGE_CONFIG_DEFAULTS: USAGE_CONFIG_DEFAULTS,
11137
+ usageSettings: usageSettings,
11138
+ usageTotals: usageTotals,
11139
+ applyUsageHits: applyUsageHits,
9785
11140
  runtimeRoot: runtimeRoot,
9786
11141
  runtimeManifestPath: runtimeManifestPath,
9787
11142
  runtimeVersionsDir: runtimeVersionsDir,