@yottameta/yotta-memory 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,7 +22,7 @@ const crypto = require('crypto');
22
22
  const http = require('http');
23
23
  const child_process = require('child_process');
24
24
 
25
- const VERSION = '0.9.1';
25
+ const VERSION = '0.10.0';
26
26
  const TYPES = ['FACT', 'PREF', 'BOUND', 'COMMIT'];
27
27
  const TYPE_DIRS = { FACT: 'facts', PREF: 'prefs', BOUND: 'bounds', COMMIT: 'commits' };
28
28
  const PUBLIC_DIR = 'facts';
@@ -49,6 +49,7 @@ function loadConfig() {
49
49
  }
50
50
  function saveConfig(cfg) {
51
51
  fs.mkdirSync(path.dirname(configPath()), { recursive: true });
52
+ decayConfigCache = null;
52
53
  fs.writeFileSync(configPath(), JSON.stringify(cfg, null, 2), 'utf8');
53
54
  }
54
55
  function userRoot() {
@@ -439,11 +440,7 @@ function utilityScore(meta) {
439
440
  const fb = feedbackNetOf(meta);
440
441
  const usageScore = 0.5 * (Math.min(acc, 20) / 20) + 0.5 * (fb / (1 + Math.abs(fb)));
441
442
  const last = meta.last_accessed || meta.created || '';
442
- let recencyScore = 0;
443
- if (last) {
444
- const d = daysBetween(last, today());
445
- recencyScore = Math.max(0, 1 - d / 365);
446
- }
443
+ const recencyScore = decayRecency(String(meta.type || 'FACT').toUpperCase(), last ? daysBetween(last, today()) : 0, decayConfig());
447
444
  const typeScore = ({ BOUND: 1.0, COMMIT: 0.9, PREF: 0.8, FACT: 0.6 })[String(meta.type || 'FACT').toUpperCase()] || 0.6;
448
445
  let structureScore = 0;
449
446
  if (meta.source) structureScore += 0.2;
@@ -463,8 +460,7 @@ function utilityBreakdown(meta) {
463
460
  const fb = feedbackNetOf(meta);
464
461
  const usageScore = 0.5 * (Math.min(acc, 20) / 20) + 0.5 * (fb / (1 + Math.abs(fb)));
465
462
  const last = meta.last_accessed || meta.created || '';
466
- let recencyScore = 0;
467
- if (last) { const d = daysBetween(last, today()); recencyScore = Math.max(0, 1 - d / 365); }
463
+ const recencyScore = decayRecency(String(meta.type || 'FACT').toUpperCase(), last ? daysBetween(last, today()) : 0, decayConfig());
468
464
  const typeScore = ({ BOUND: 1.0, COMMIT: 0.9, PREF: 0.8, FACT: 0.6 })[String(meta.type || 'FACT').toUpperCase()] || 0.6;
469
465
  let structureScore = 0;
470
466
  if (meta.source) structureScore += 0.2;
@@ -1442,9 +1438,10 @@ function archiveCore(opts) {
1442
1438
  const createdTs = new Date(meta.created).getTime();
1443
1439
  if (isNaN(createdTs)) continue;
1444
1440
  const t = (meta.type || 'FACT').toUpperCase();
1441
+ if (t === 'BOUND') continue; // v0.10.0 BOUND 豁免归档(边界常驻)
1445
1442
  // v0.8.0 统一效用分(盖棺分)替代 vitality
1446
1443
  if (utilityScore(meta) < threshold && createdTs < cutoff) {
1447
- const destDir = path.join(root, ARCHIVE_DIR, TYPE_DIRS[t]);
1444
+ const destDir = archiveDirFor(root, t, owner);
1448
1445
  fs.mkdirSync(destDir, { recursive: true });
1449
1446
  fs.renameSync(fp, path.join(destDir, path.basename(fp)));
1450
1447
  const rel = relOf(root, fp);
@@ -1558,18 +1555,20 @@ function explainCore(ref, opts) {
1558
1555
  const ub = utilityBreakdown(entry);
1559
1556
  const ageDays = entry.created ? daysBetween(entry.created, today()) : 0;
1560
1557
  const cfg = loadConfig();
1558
+ const hlText = entry.type === 'BOUND' ? 'BOUND 不衰减' : (decayHalflifeDays(entry.type, cfg) + ' 天');
1561
1559
  const archU = parseFloat(cfg.maintain_archived_utility || 0.35);
1562
1560
  const archAge = parseInt(cfg.maintain_archived_age || '180', 10) || 180;
1563
1561
  const forgetU = parseFloat(cfg.maintain_forget_utility || 0.12);
1564
1562
  const forgetAge = parseInt(cfg.maintain_forget_age || '365', 10) || 365;
1565
1563
  const lines = ['[' + entry.type + '] ' + entry.subject + ': ' + entry.statement];
1566
1564
  lines.push(' 文件: ' + target.rel + (readable ? '' : '(其它智能体私密,仅元数据)'));
1567
- lines.push(' 效用分: ' + ub.total + ' = confidence ' + ub.confidence + ' + 使用 ' + ub.usage + ' + 时效 ' + ub.recency + ' + 类型 ' + ub.type + ' + 结构 ' + ub.structure + ')×weight ' + ub.weight);
1565
+ lines.push(' 效用分: ' + ub.total + ' = confidence ' + ub.confidence + ' + 使用 ' + ub.usage + ' + 时效 ' + ub.recency + '(半衰 ' + hlText + ') + 类型 ' + ub.type + ' + 结构 ' + ub.structure + ')×weight ' + ub.weight);
1568
1566
  lines.push(' 年龄: ' + ageDays + ' 天 / access_count ' + (entry.access_count || 0) + ' / feedback_net ' + entry.feedback_net + ' / immutable ' + (entry.immutable ? '是' : '否'));
1569
1567
  const importScore = round3(importanceScore(entry));
1570
1568
  lines.push(' importance(旧): ' + importScore + ' / vitality(旧): ' + round3(vitality(entry)));
1571
1569
  if (entry.immutable) lines.push(' 状态: immutable 豁免自动归档/遗忘');
1572
- else if (ub.total < forgetU && ageDays > forgetAge && entry.type !== 'BOUND') lines.push(' 状态: 遗忘候选(utility < ' + forgetU + ' 且年龄 > ' + forgetAge + ' 天;--purge 才真删)');
1570
+ else if (entry.type === 'BOUND') lines.push(' 状态: BOUND 豁免归档/遗忘(边界常驻)');
1571
+ else if (ub.total < forgetU && ageDays > forgetAge) lines.push(' 状态: 遗忘候选(utility < ' + forgetU + ' 且年龄 > ' + forgetAge + ' 天;--purge 才真删)');
1573
1572
  else if (ub.total < archU && ageDays > archAge) lines.push(' 状态: 归档候选(utility < ' + archU + ' 且年龄 > ' + archAge + ' 天)');
1574
1573
  else lines.push(' 状态: 保留(未达归档/遗忘阈值)');
1575
1574
  return { error: false, text: lines.join('\n') };
@@ -1602,7 +1601,7 @@ function maintainCore(opts) {
1602
1601
  const archAge = parseInt(opts.age || cfg.maintain_archived_age || '180', 10) || 180;
1603
1602
  const forgetU = parseFloat(cfg.maintain_forget_utility || 0.12);
1604
1603
  const forgetAge = parseInt(cfg.maintain_forget_age || '365', 10) || 365;
1605
- const mode = apply ? (purge ? '执行(含遗忘真删 --purge)' : '执行') : '预览(dry-run,未改动;加 --apply 执行,--purge 才真删)';
1604
+ const mode = opts.dedup ? (apply ? '查重执行(--dedup --apply 只自动合并高置信重复组,不执行归档/遗忘)' : '查重预览(--dedup;不执行归档/遗忘)') : (apply ? (purge ? '执行(含遗忘真删 --purge)' : '执行') : '预览(dry-run,未改动;加 --apply 执行,--purge 才真删)');
1606
1605
  const lines = ['## yotta-memory maintain(记忆自组织)'];
1607
1606
  lines.push('- 模式: ' + mode);
1608
1607
  lines.push('- 归档阈值: utility < ' + archU + ' 且年龄 > ' + archAge + ' 天;遗忘阈值: utility < ' + forgetU + ' 且年龄 > ' + forgetAge + ' 天');
@@ -1622,15 +1621,18 @@ function maintainCore(opts) {
1622
1621
  if (type === 'BOUND') { skipped.push({ fp: fp, reason: 'BOUND 豁免遗忘' }); }
1623
1622
  else { toForget.push(rec); continue; }
1624
1623
  }
1625
- if (u < archU && ageDays > archAge) toArchive.push(rec);
1624
+ if (u < archU && ageDays > archAge) {
1625
+ if (type === 'BOUND') skipped.push({ fp: fp, reason: 'BOUND 豁免归档' });
1626
+ else toArchive.push(rec);
1627
+ }
1626
1628
  }
1627
1629
  let archived = 0, forgotten = 0;
1628
1630
  if (toArchive.length) {
1629
1631
  lines.push('### 归档候选(' + toArchive.length + ' 条)');
1630
1632
  for (const rec of toArchive) {
1631
1633
  lines.push('- [' + rec.type + '] ' + rec.rel + '(utility ' + round3(rec.utility) + ',' + rec.ageDays + ' 天)— ' + (rec.meta.subject || '') + ': ' + String(rec.meta.statement || '').slice(0, 40) + (rec.type === 'COMMIT' ? ' ⚠️ COMMIT 收工纪律锚点,请确认' : ''));
1632
- if (apply) {
1633
- const destDir = path.join(root, ARCHIVE_DIR, TYPE_DIRS[rec.type]);
1634
+ if (apply && !opts.dedup) {
1635
+ const destDir = archiveDirFor(root, rec.type, rec.owner);
1634
1636
  fs.mkdirSync(destDir, { recursive: true });
1635
1637
  fs.renameSync(rec.fp, path.join(destDir, path.basename(rec.fp)));
1636
1638
  appendAudit(root, 'audit', { ts: new Date().toISOString(), file: rec.rel, action: 'archive', reason: 'utility<' + round3(rec.utility) + ',age>' + rec.ageDays, utility: round3(rec.utility) });
@@ -1643,8 +1645,8 @@ function maintainCore(opts) {
1643
1645
  lines.push('### 遗忘候选(' + toForget.length + ' 条,默认不真删;--purge 才删除)');
1644
1646
  for (const rec of toForget) {
1645
1647
  lines.push('- [' + rec.type + '] ' + rec.rel + '(utility ' + round3(rec.utility) + ',' + rec.ageDays + ' 天)');
1646
- if (apply && purge) {
1647
- const destDir = path.join(root, ARCHIVE_DIR, TYPE_DIRS[rec.type]);
1648
+ if (apply && purge && !opts.dedup) {
1649
+ const destDir = archiveDirFor(root, rec.type, rec.owner);
1648
1650
  fs.mkdirSync(destDir, { recursive: true });
1649
1651
  const dest = path.join(destDir, path.basename(rec.fp));
1650
1652
  fs.renameSync(rec.fp, dest);
@@ -1652,20 +1654,22 @@ function maintainCore(opts) {
1652
1654
  // 真删前二次确认标记:purge 已显式授权,删除 .archive 内副本前的最终动作 = 记录审计后删除
1653
1655
  try { fs.unlinkSync(dest); } catch (e) { /* 保留副本 */ }
1654
1656
  forgotten++;
1655
- } else if (apply) {
1657
+ } else if (apply && !opts.dedup) {
1656
1658
  lines.push(' (--purge 未开启,跳过真删)');
1657
1659
  }
1658
1660
  }
1659
1661
  lines.push('');
1660
1662
  }
1661
- if (apply) {
1662
- if (archived || forgotten) {
1663
- const allMoved = toArchive.concat(toForget).filter(function (r) { return true; });
1664
- removeIndexEntries(root, allMoved.map(function (r) { return r.rel; }));
1663
+ if (!opts.dedup) {
1664
+ if (apply) {
1665
+ if (archived || forgotten) {
1666
+ const allMoved = toArchive.concat(toForget).filter(function (r) { return true; });
1667
+ removeIndexEntries(root, allMoved.map(function (r) { return r.rel; }));
1668
+ }
1669
+ lines.push('- 已执行: 归档 ' + archived + ' 条,遗忘 ' + forgotten + ' 条。审计: ' + auditPath(root, 'audit'));
1670
+ } else {
1671
+ lines.push('- 未执行任何变更(dry-run)。加 --apply 执行归档,--purge 才真删遗忘候选。');
1665
1672
  }
1666
- lines.push('- 已执行: 归档 ' + archived + ' 条,遗忘 ' + forgotten + ' 条。审计: ' + auditPath(root, 'audit'));
1667
- } else {
1668
- lines.push('- 未执行任何变更(dry-run)。加 --apply 执行归档,--purge 才真删遗忘候选。');
1669
1673
  }
1670
1674
  if (skipped.length) {
1671
1675
  lines.push('');
@@ -1674,32 +1678,8 @@ function maintainCore(opts) {
1674
1678
  for (const s of skipped) { grouped[s.reason] = (grouped[s.reason] || 0) + 1; }
1675
1679
  for (const k of Object.keys(grouped)) lines.push('- ' + k + ': ' + grouped[k] + ' 条');
1676
1680
  }
1677
- // 去重
1678
- if (opts.dedup) {
1679
- lines.push('');
1680
- lines.push('### 去重候选(--dedup)');
1681
- const entries = ensureIndex(root);
1682
- const groups = [];
1683
- const used = new Set();
1684
- for (let i = 0; i < entries.length; i++) {
1685
- if (used.has(i)) continue;
1686
- const g = [entries[i]];
1687
- used.add(i);
1688
- for (let j = i + 1; j < entries.length; j++) {
1689
- if (used.has(j)) continue;
1690
- if (subjectsSimilar(entries[i], entries[j]) && jaccardTokens(entries[i], entries[j]) >= 0.7) {
1691
- g.push(entries[j]); used.add(j);
1692
- }
1693
- }
1694
- if (g.length > 1) groups.push(g);
1695
- }
1696
- if (!groups.length) lines.push('(无重复候选)');
1697
- for (const g of groups) {
1698
- lines.push('- 组: ' + g.map(function (e) { return e.file; }).join(' | '));
1699
- lines.push(' ' + g.map(function (e) { return e.subject + ': ' + e.statement; }).join(' / '));
1700
- }
1701
- lines.push(' 合并: yotta-memory maintain --merge <fileA> <fileB>');
1702
- }
1681
+ // 去重(v0.10.0:置信度分档 + --apply 自动合并;--dedup 与归档/遗忘互斥)
1682
+ if (opts.dedup) appendDedupBlock(lines, root, opts);
1703
1683
  return { error: false, text: lines.join('\n') };
1704
1684
  }
1705
1685
  // 手动合并两条相似记忆:保留高 confidence,低 confidence 移入 .archive
@@ -2811,21 +2791,34 @@ function cmdContext(opts) {
2811
2791
  }
2812
2792
 
2813
2793
  // ---- config 命令 ----
2794
+ // v0.10.0:maintain_* / consolidate_* 数值键纳入 config set/get(此前只支持 3 个键但文档已写可调)
2795
+ function isNumericConfigKey(key) { return /^(maintain_|consolidate_)/.test(key); }
2814
2796
  function cmdConfigSet(key, value) {
2815
- if (key !== 'memory_home' && key !== 'embedding_cmd' && key !== 'embedding_timeout') { console.error('未知配置项: ' + key + '(可用: memory_home / embedding_cmd / embedding_timeout)'); process.exit(2); }
2816
- if (!value) { console.error('缺少值: config set ' + key + ' <值>'); process.exit(2); }
2797
+ const known = ['memory_home', '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'];
2798
+ if (known.indexOf(key) === -1) { console.error('未知配置项: ' + key + '(可用: memory_home / embedding_cmd / embedding_timeout / maintain_* / consolidate_*)'); process.exit(2); }
2799
+ if (value === undefined || value === null || value === '') { console.error('缺少值: config set ' + key + ' <值>'); process.exit(2); }
2817
2800
  const cfg = loadConfig();
2818
2801
  if (key === 'memory_home') cfg.memory_home = value;
2819
2802
  else if (key === 'embedding_cmd') cfg.embedding_cmd = value;
2820
2803
  else if (key === 'embedding_timeout') cfg.embedding_timeout = parseInt(value, 10) || 3000;
2804
+ else if (isNumericConfigKey(key)) {
2805
+ const n = parseFloat(value);
2806
+ if (!(n >= 0)) { console.error('配置项 ' + key + ' 需要非负数值'); process.exit(2); }
2807
+ cfg[key] = n;
2808
+ }
2821
2809
  saveConfig(cfg);
2822
- console.log('已写入配置: ' + key + ' = ' + (key === 'embedding_timeout' ? (parseInt(value, 10) || 3000) : value));
2810
+ console.log('已写入配置: ' + key + ' = ' + (key === 'memory_home' || key === 'embedding_cmd' ? value : cfg[key]));
2823
2811
  }
2824
2812
  function cmdConfigGet() {
2825
2813
  const cfg = loadConfig();
2826
2814
  console.log('memory_home: ' + (cfg.memory_home || '(未设置,默认 ~/.yottamemory)'));
2827
2815
  console.log('embedding_cmd: ' + (cfg.embedding_cmd || '(未设置)'));
2828
2816
  console.log('embedding_timeout: ' + (cfg.embedding_timeout || 3000));
2817
+ const keys = ['memory_home', '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'];
2818
+ for (const k of keys) {
2819
+ if (k === 'memory_home' || k === 'embedding_cmd' || k === 'embedding_timeout') continue;
2820
+ if (cfg[k] !== undefined) console.log(k + ': ' + cfg[k]);
2821
+ }
2829
2822
  console.log('当前生效用户级位置: ' + userRoot());
2830
2823
  if (cfg.serve && Object.keys(cfg.serve).length) console.log('serve: ' + JSON.stringify(cfg.serve));
2831
2824
  }
@@ -3493,6 +3486,496 @@ function cmdLanStatus() {
3493
3486
  if (p === 'linux') return cmdLanLinuxStatus();
3494
3487
  console.log('lan 命令当前仅支持 Windows / Linux;本机平台: ' + p);
3495
3488
  }
3489
+
3490
+ // ================= v0.10.0 压缩遗忘:分类型衰减 / consolidate 周期摘要 / 自动合并 / 批次回滚 =================
3491
+ var decayConfigCache = null;
3492
+
3493
+ function decayConfig() {
3494
+ if (decayConfigCache) return decayConfigCache;
3495
+ try { decayConfigCache = loadConfig() || {}; } catch (e) { decayConfigCache = {}; }
3496
+ return decayConfigCache;
3497
+ }
3498
+
3499
+ // 分类型衰减半衰期(天):FACT 慢 / PREF 中 / COMMIT(任务类)快;BOUND 固定不衰减
3500
+ function decayHalflifeDays(type, cfg) {
3501
+ cfg = cfg || {};
3502
+ const t = String(type || 'FACT').toUpperCase();
3503
+ if (t === 'BOUND') return null;
3504
+ const d = parseFloat(cfg['maintain_decay_halflife_' + t]);
3505
+ if (d > 0) return d;
3506
+ return ({ FACT: 730, PREF: 365, COMMIT: 90 })[t] || 365;
3507
+ }
3508
+
3509
+ // 指数半衰时效:recency = 0.5^(d / halfLife);BOUND 恒 1.0(不衰减)
3510
+ function decayRecency(type, ageDays, cfg) {
3511
+ const hl = decayHalflifeDays(type, cfg);
3512
+ if (hl === null) return 1;
3513
+ if (!(ageDays > 0)) return 1;
3514
+ return Math.pow(0.5, ageDays / hl);
3515
+ }
3516
+
3517
+ // v0.10.0 归档目标目录:公共 .archive/facts/;私密 .archive/private/<owner>/<type>/(带 owner,防跨 owner 撞名)
3518
+ function archiveDirFor(root, type, owner) {
3519
+ const t = String(type || 'FACT').toUpperCase();
3520
+ if (t === 'FACT') return path.join(root, ARCHIVE_DIR, 'facts');
3521
+ if (owner) return path.join(root, ARCHIVE_DIR, PRIVATE_DIR, owner, TYPE_DIRS[t] || 'facts');
3522
+ return path.join(root, ARCHIVE_DIR, TYPE_DIRS[t] || 'facts');
3523
+ }
3524
+
3525
+ function newBatchId() {
3526
+ const d = new Date();
3527
+ const p = function (n, w) { return String(n).padStart(w || 2, '0'); };
3528
+ let rand = '';
3529
+ try { rand = crypto.randomBytes(3).toString('hex'); } catch (e) { rand = String(Math.floor(Math.random() * 16777215)).padStart(6, '0'); }
3530
+ return p(d.getFullYear(), 4) + p(d.getMonth() + 1) + p(d.getDate()) + '-' + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds()) + '-' + rand;
3531
+ }
3532
+
3533
+ function collectAuditRecords(root) {
3534
+ const out = [];
3535
+ const log = auditPath(root, 'audit');
3536
+ if (!fs.existsSync(log)) return out;
3537
+ const raw = fs.readFileSync(log, 'utf8');
3538
+ for (const line of raw.split('\n')) {
3539
+ if (!line.trim()) continue;
3540
+ try { out.push(JSON.parse(line)); } catch (e) { /* 跳过坏行 */ }
3541
+ }
3542
+ return out;
3543
+ }
3544
+
3545
+ function listBatchesCore(opts) {
3546
+ opts = opts || {};
3547
+ const limit = (opts.limit && opts.limit > 0) ? opts.limit : 20;
3548
+ const roots = memoryRoots();
3549
+ const manifests = [];
3550
+ for (const root of roots) {
3551
+ for (const rec of collectAuditRecords(root)) {
3552
+ if (rec.action !== 'manifest') continue;
3553
+ manifests.push({ root: root, batch: rec.batch, ts: rec.ts || '', command: rec.command || '', mode: rec.mode || '' });
3554
+ }
3555
+ }
3556
+ manifests.sort(function (a, b) { return String(b.batch).localeCompare(String(a.batch)); });
3557
+ const lines = ['## yotta-memory consolidate --batches(近期批次审计)'];
3558
+ if (!manifests.length) lines.push('(无批次记录;批次由 v0.10.0+ consolidate / maintain --dedup --apply 写入 .archive/audit-<日期>.jsonl)');
3559
+ const shown = manifests.slice(0, limit);
3560
+ for (const m of shown) {
3561
+ lines.push('- ' + m.batch + ' [' + (m.command || '?') + ' / ' + (m.mode || '') + '] root=' + m.root);
3562
+ }
3563
+ if (manifests.length > shown.length) lines.push('(共 ' + manifests.length + ' 个批次,显示前 ' + shown.length + ' 个;--limit N 查看更多)');
3564
+ lines.push('回滚: yotta-memory consolidate --undo <batch>');
3565
+ return { error: false, text: lines.join('\n') };
3566
+ }
3567
+
3568
+ function findBatchRecords(batch) {
3569
+ const out = [];
3570
+ for (const root of memoryRoots()) {
3571
+ for (const rec of collectAuditRecords(root)) {
3572
+ if (rec.batch === batch) out.push({ root: root, rec: rec });
3573
+ }
3574
+ }
3575
+ return out;
3576
+ }
3577
+
3578
+ function consolidateUndoCore(batch, opts) {
3579
+ opts = opts || {};
3580
+ const lines = ['## yotta-memory consolidate --undo ' + batch];
3581
+ const recs = findBatchRecords(batch);
3582
+ if (!recs.length) return { error: true, text: '未找到批次 ' + batch + '(.archive/audit-<日期>.jsonl 无该 batch 记录)。' };
3583
+ const done = recs.some(function (x) { return x.rec.action === 'undo_done'; });
3584
+ if (done) return { error: false, text: lines.join('\n') + '\n批次 ' + batch + ' 已回滚过(幂等拒绝重复执行)。' };
3585
+ const force = !!opts.force;
3586
+ let err = null;
3587
+ // 1) 删除 consolidate 生成的摘要(含索引)
3588
+ for (const x of recs) {
3589
+ if (x.rec.action !== 'summary_create') continue;
3590
+ const fp = path.join(x.root, x.rec.file);
3591
+ try { if (resolveWithinRoot(x.root, fp) && fs.existsSync(fp)) fs.unlinkSync(fp); } catch (e) { err = err || ('摘要删除失败: ' + x.rec.file + ' ' + e.message); }
3592
+ if (resolveWithinRoot(x.root, fp)) removeIndexEntry(x.root, x.rec.file);
3593
+ lines.push('- 已删除摘要: ' + x.rec.file);
3594
+ }
3595
+ // 2) 合并回滚:keep 还原最早 before 影像;drop 从 .archive 归位
3596
+ const mergeKeeps = {};
3597
+ for (const x of recs) {
3598
+ if (x.rec.action !== 'merge') continue;
3599
+ const kf = x.rec.keepFile;
3600
+ if (!mergeKeeps[kf]) mergeKeeps[kf] = [];
3601
+ mergeKeeps[kf].push(x);
3602
+ }
3603
+ for (const kf of Object.keys(mergeKeeps)) {
3604
+ const xs = mergeKeeps[kf].sort(function (a, b) { return String(a.rec.ts || '').localeCompare(String(b.rec.ts || '')); });
3605
+ const first = xs[0];
3606
+ const keepFp = path.join(first.root, kf);
3607
+ const owner = ownerFromPrivatePath(first.root, keepFp);
3608
+ const before = first.rec.before || {};
3609
+ try {
3610
+ const patch = {};
3611
+ if (before.updated !== undefined) patch.updated = before.updated;
3612
+ if (before.tags !== undefined) patch.tags = JSON.stringify(before.tags);
3613
+ if (before.access_count !== undefined) patch.access_count = before.access_count;
3614
+ if (before.feedback_net !== undefined) patch.feedback_net = before.feedback_net;
3615
+ if (Object.keys(patch).length && fs.existsSync(keepFp)) {
3616
+ rewriteFrontmatter(keepFp, patch, first.root, owner);
3617
+ upsertIndexEntry(first.root, readEntry(keepFp, first.root));
3618
+ lines.push('- 已还原 keep: ' + kf);
3619
+ }
3620
+ } catch (e) { err = err || ('keep 还原失败: ' + kf + ' ' + e.message); }
3621
+ for (const x of xs) {
3622
+ const dropFp = path.join(x.root, x.rec.dropFile);
3623
+ const dropTo = path.join(x.root, x.rec.dropTo);
3624
+ if (!resolveWithinRoot(x.root, dropFp)) continue;
3625
+ if (fs.existsSync(dropFp) && !force) { err = err || ('归位冲突: ' + x.rec.dropFile + ' 已存在(--force 覆盖)'); continue; }
3626
+ try {
3627
+ if (fs.existsSync(dropFp)) fs.unlinkSync(dropFp);
3628
+ if (fs.existsSync(dropTo)) {
3629
+ fs.mkdirSync(path.dirname(dropFp), { recursive: true });
3630
+ fs.renameSync(dropTo, dropFp);
3631
+ upsertIndexEntry(x.root, readEntry(dropFp, x.root));
3632
+ lines.push('- 已归位 drop: ' + x.rec.dropFile);
3633
+ } else {
3634
+ err = err || ('drop 归档副本缺失: ' + x.rec.dropFile + '(无法归位)');
3635
+ }
3636
+ } catch (e2) { err = err || ('drop 归位失败: ' + x.rec.dropFile + ' ' + e2.message); }
3637
+ }
3638
+ }
3639
+ // 3) consolidate 归档原文归位
3640
+ for (const x of recs) {
3641
+ if (x.rec.action !== 'archive') continue;
3642
+ const to = path.join(x.root, x.rec.file);
3643
+ const from = path.join(x.root, x.rec.to);
3644
+ if (!resolveWithinRoot(x.root, to)) continue;
3645
+ if (fs.existsSync(to) && !force) { err = err || ('归位冲突: ' + x.rec.file + ' 已存在(--force 覆盖)'); continue; }
3646
+ try {
3647
+ if (fs.existsSync(to)) fs.unlinkSync(to);
3648
+ if (fs.existsSync(from)) {
3649
+ fs.mkdirSync(path.dirname(to), { recursive: true });
3650
+ fs.renameSync(from, to);
3651
+ upsertIndexEntry(x.root, readEntry(to, x.root));
3652
+ lines.push('- 已归位: ' + x.rec.file);
3653
+ } else {
3654
+ err = err || ('归档副本缺失: ' + x.rec.to + '(无法归位)');
3655
+ }
3656
+ } catch (e3) { err = err || ('归位失败: ' + x.rec.file + ' ' + e3.message); }
3657
+ }
3658
+ // 4) 幂等标记
3659
+ const roots = [];
3660
+ for (const x of recs) if (roots.indexOf(x.root) === -1) roots.push(x.root);
3661
+ for (const r of roots) appendAudit(r, 'audit', { ts: new Date().toISOString(), batch: batch, action: 'undo_done', note: err ? 'partial: ' + err : 'ok' });
3662
+ lines.push(err ? '- 结果: 部分失败 —— ' + err : '- 结果: 回滚完成(重复 --undo 会被幂等拒绝)');
3663
+ return { error: false, text: lines.join('\n') };
3664
+ }
3665
+
3666
+ function pickSummaryTheme(group) {
3667
+ let best = group[0];
3668
+ for (const c of group) {
3669
+ if (c.e.confidence > best.e.confidence) best = c;
3670
+ else if (c.e.confidence === best.e.confidence && String(c.e.updated || '') > String(best.e.updated || '')) best = c;
3671
+ }
3672
+ let s = String(best.e.subject || '(无主题)').replace(/\s+/g, ' ').trim();
3673
+ if (s.length > 30) s = s.slice(0, 29) + '…';
3674
+ return s;
3675
+ }
3676
+
3677
+ function sharedTag(a, b) {
3678
+ const ta = a.e.tags || [], tb = b.e.tags || [];
3679
+ for (const t of ta) if (tb.indexOf(t) !== -1) return true;
3680
+ return false;
3681
+ }
3682
+
3683
+ function similarForConsolidate(a, b) {
3684
+ if (subjectsSimilar(a.e, b.e)) return true;
3685
+ if (jaccardTokens(a.e, b.e) >= 0.35) return true;
3686
+ if (sharedTag(a, b)) return true;
3687
+ return false;
3688
+ }
3689
+
3690
+ function clusterConsolidateCandidates(cands) {
3691
+ const buckets = {};
3692
+ for (const c of cands) {
3693
+ const key = (c.scope === 'private' ? 'priv:' + c.owner : 'pub') + '|' + c.type;
3694
+ (buckets[key] = buckets[key] || []).push(c);
3695
+ }
3696
+ const groups = [];
3697
+ for (const key of Object.keys(buckets)) {
3698
+ const arr = buckets[key].slice().sort(function (a, b) { return String(a.e.created || '').localeCompare(String(b.e.created || '')); });
3699
+ const clusters = [];
3700
+ for (const c of arr) {
3701
+ let placed = false;
3702
+ for (const cl of clusters) {
3703
+ if (similarForConsolidate(c, cl[0])) { cl.push(c); placed = true; break; }
3704
+ }
3705
+ if (!placed) clusters.push([c]);
3706
+ }
3707
+ for (const cl of clusters) if (cl.length >= 2) groups.push(cl);
3708
+ }
3709
+ return groups;
3710
+ }
3711
+
3712
+ function applyConsolidateGroup(root, group, batch, period) {
3713
+ const e0 = group[0];
3714
+ const type = e0.type;
3715
+ const owner = e0.scope === 'private' ? e0.owner : '';
3716
+ const theme = pickSummaryTheme(group);
3717
+ const subject = '周期摘要 ' + theme + '(' + period + ' 天窗)';
3718
+ const absDir = path.join(root, typeSubdir(type, owner));
3719
+ fs.mkdirSync(absDir, { recursive: true });
3720
+ const enc = isEncrypted(root) && owner ? ENC_SUFFIX : '';
3721
+ let fname = today() + '-' + nextSeq(absDir) + '.md' + enc;
3722
+ let fp = path.join(absDir, fname);
3723
+ let guard = 0;
3724
+ while (fs.existsSync(fp) && guard < 100) { guard++; fname = today() + '-' + nextSeq(absDir) + '.md' + enc; fp = path.join(absDir, fname); }
3725
+ const confs = group.map(function (c) { return c.e.confidence; });
3726
+ const maxConf = Math.max.apply(null, confs);
3727
+ const ws = group.map(function (c) { return c.e.weight || 1.0; }).sort(function (a, b) { return a - b; });
3728
+ const medW = ws.length % 2 ? ws[(ws.length - 1) / 2] : (ws[ws.length / 2 - 1] + ws[ws.length / 2]) / 2;
3729
+ const allTags = [];
3730
+ for (const c of group) for (const t of (c.e.tags || [])) if (allTags.indexOf(t) === -1) allTags.push(t);
3731
+ const tags = ['consolidate', 'summary'].concat(allTags);
3732
+ const createdDates = group.map(function (c) { return c.e.created; }).sort();
3733
+ const avgU = round3(group.reduce(function (s, c) { return s + c.utility; }, 0) / group.length);
3734
+ const bodyLines = ['# 周期摘要:' + theme, '', '> 生成: yotta-memory consolidate | 类型 ' + type + ' | 范围 ' + createdDates[0] + ' ~ ' + createdDates[createdDates.length - 1] + ' | ' + group.length + ' 条 | 平均效用 ' + avgU, '', '## 主题要点', ''];
3735
+ const sorted = group.slice().sort(function (a, b) { return String(b.e.updated || '').localeCompare(String(a.e.updated || '')); });
3736
+ for (const c of sorted) bodyLines.push('- [' + c.e.type + '] ' + (c.e.subject || '') + ': ' + (c.e.statement || ''));
3737
+ bodyLines.push('', '## 溯源(原文已入 .archive,可 consolidate --undo <batch> 恢复)', '');
3738
+ for (const c of group) bodyLines.push('- ' + c.e.file + '(created ' + c.e.created + ' / confidence ' + c.e.confidence + ' / utility ' + round3(c.utility) + ')');
3739
+ const body = bodyLines.join('\n');
3740
+ const statement = '周期摘要 ' + theme + ':' + group.length + ' 条 ' + type + '(' + createdDates[0] + ' ~ ' + createdDates[createdDates.length - 1] + ',平均效用 ' + avgU + ')。要点与溯源见正文。';
3741
+ const meta = { type: type, subject: subject, statement: statement, confidence: round3(maxConf), created: today(), updated: today(), tags: tags, immutable: false, scope: owner ? 'private' : 'public', owner: owner, source: 'consolidate', weight: round3(medW), access_count: 0, last_accessed: '', feedback_net: 0 };
3742
+ writeMemoryText(root, fp, frontmatterToText(meta, body), owner);
3743
+ const summaryRel = relOf(root, fp);
3744
+ upsertIndexEntry(root, readEntry(fp, root));
3745
+ appendAudit(root, 'audit', { action: 'summary_create', ts: new Date().toISOString(), batch: batch, file: summaryRel, sourceFiles: group.map(function (c) { return c.e.file; }) });
3746
+ let moved = 0;
3747
+ for (const c of group) {
3748
+ const destDir = archiveDirFor(root, type, owner);
3749
+ fs.mkdirSync(destDir, { recursive: true });
3750
+ const dest = path.join(destDir, path.basename(c.e.file));
3751
+ fs.renameSync(path.join(root, c.e.file), dest);
3752
+ removeIndexEntry(root, c.e.file);
3753
+ 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) });
3754
+ moved++;
3755
+ }
3756
+ return { made: 1, moved: moved, summaryRel: summaryRel };
3757
+ }
3758
+
3759
+ function consolidateCore(opts) {
3760
+ opts = opts || {};
3761
+ if (opts.undo) return consolidateUndoCore(String(opts.undo), opts);
3762
+ if (opts.batches) return listBatchesCore(opts);
3763
+ const root = userRoot();
3764
+ if (!fs.existsSync(root)) return { error: false, text: '记忆库不存在。' };
3765
+ const apply = !!opts.apply;
3766
+ const selfAgent = opts.selfAgent || currentAgent();
3767
+ const unsafe = !!opts.unsafe;
3768
+ const cfg = loadConfig();
3769
+ const minAge = (opts.minAge !== undefined && opts.minAge !== null) ? opts.minAge : (parseInt(cfg.consolidate_min_age || '180', 10) || 180);
3770
+ const minIdle = (opts.minIdle !== undefined && opts.minIdle !== null) ? opts.minIdle : (parseInt(cfg.consolidate_min_idle || '90', 10) || 90);
3771
+ const maxUTmp = (opts.maxUtility !== undefined && opts.maxUtility !== null) ? parseFloat(opts.maxUtility) : parseFloat(cfg.consolidate_max_utility);
3772
+ const maxU = (maxUTmp > 0) ? maxUTmp : 0.6;
3773
+ const minGroup = (opts.minGroup !== undefined && opts.minGroup !== null) ? opts.minGroup : (parseInt(cfg.consolidate_min_group || '2', 10) || 2);
3774
+ const period = (opts.period !== undefined && opts.period !== null) ? opts.period : (parseInt(cfg.consolidate_period || '90', 10) || 90);
3775
+ const typeOnly = opts.type ? String(opts.type).toUpperCase() : '';
3776
+ const lines = ['## yotta-memory consolidate(周期摘要压缩)'];
3777
+ lines.push('- 模式: ' + (apply ? '执行' : '预览(dry-run,未改动;加 --apply 执行)'));
3778
+ lines.push('- 候选: 天龄 ≥ ' + minAge + ' 天 ∧ 闲置 ≥ ' + minIdle + ' 天 ∧ utility ≤ ' + maxU + (typeOnly ? ' ∧ 仅类型 ' + typeOnly : '') + ';immutable / BOUND 豁免');
3779
+ lines.push('');
3780
+ const cands = [];
3781
+ const stats = { scan: 0, denied: 0, immutable: 0, bound: 0, typeSkip: 0, noCreated: 0, active: 0 };
3782
+ for (const fp of collectEntryFiles(root)) {
3783
+ stats.scan++;
3784
+ const owner = ownerFromPrivatePath(root, fp);
3785
+ const rel = relOf(root, fp);
3786
+ if (checkOwnerWritable(root, rel, selfAgent, unsafe)) { stats.denied++; continue; }
3787
+ let e;
3788
+ try { e = readEntry(fp, root); } catch (err) { stats.denied++; continue; }
3789
+ if (e.immutable) { stats.immutable++; continue; }
3790
+ if (e.type === 'BOUND') { stats.bound++; continue; }
3791
+ if (typeOnly && e.type !== typeOnly) { stats.typeSkip++; continue; }
3792
+ if (!e.created) { stats.noCreated++; continue; }
3793
+ const age = daysBetween(e.created, today());
3794
+ const last = e.last_accessed || e.created || '';
3795
+ const idle = last ? daysBetween(last, today()) : age;
3796
+ const u = utilityScore(e.meta);
3797
+ if (age < minAge || idle < minIdle || u > maxU) { stats.active++; continue; }
3798
+ cands.push({ e: e, owner: owner, type: e.type, age: age, idle: idle, utility: u, scope: e.scope === 'private' ? 'private' : 'public' });
3799
+ }
3800
+ if (!cands.length) {
3801
+ lines.push('(无可压缩候选;扫描 ' + stats.scan + ' 条,跳过: 其它 owner/无密钥 ' + stats.denied + ' / immutable ' + stats.immutable + ' / BOUND ' + stats.bound + ' / 类型过滤 ' + stats.typeSkip + ' / 无 created ' + stats.noCreated + ' / 不够老或仍活跃 ' + stats.active + ')');
3802
+ return { error: false, text: lines.join('\n') };
3803
+ }
3804
+ const groups = clusterConsolidateCandidates(cands);
3805
+ lines.push('候选 ' + cands.length + ' 条(扫描 ' + stats.scan + ',跳过: 其它 owner/无密钥 ' + stats.denied + ' / immutable ' + stats.immutable + ' / BOUND ' + stats.bound + ' / 类型过滤 ' + stats.typeSkip + ' / 无 created ' + stats.noCreated + ' / 不够老或仍活跃 ' + stats.active + ')');
3806
+ if (!groups.length) {
3807
+ lines.push('');
3808
+ lines.push('(无可归纳组:同主题聚类 ≥ ' + minGroup + ' 条才生成周期摘要;零散单条旧记忆请用 maintain --apply 归档)');
3809
+ return { error: false, text: lines.join('\n') };
3810
+ }
3811
+ lines.push('');
3812
+ lines.push('### 周期摘要组(' + groups.length + ' 组,每组将生成摘要 1 条 + 归档原文 N 条)');
3813
+ for (const g of groups) {
3814
+ const type = g[0].type;
3815
+ const scopeLabel = g[0].scope === 'private' ? ('private/' + g[0].owner) : 'public';
3816
+ const ages = g.map(function (c) { return c.age; });
3817
+ const minA = Math.min.apply(null, ages), maxA = Math.max.apply(null, ages);
3818
+ const avgU = round3(g.reduce(function (s, c) { return s + c.utility; }, 0) / g.length);
3819
+ lines.push('- [' + type + '] 主题: ' + pickSummaryTheme(g) + '(' + scopeLabel + ',' + g.length + ' 条,' + minA + '~' + maxA + ' 天,平均效用 ' + avgU + ')' + (type === 'COMMIT' ? ' ⚠️ COMMIT 承诺/任务类,请确认' : ''));
3820
+ for (const c of g) lines.push(' · ' + c.e.file + ' — ' + String(c.e.subject || '').slice(0, 32) + ': ' + String(c.e.statement || '').slice(0, 60));
3821
+ }
3822
+ if (!apply) {
3823
+ lines.push('');
3824
+ lines.push('dry-run 未执行任何变更。确认后加 --apply 执行(写入批次审计,可 consolidate --undo <batch> 回滚)。');
3825
+ return { error: false, text: lines.join('\n') };
3826
+ }
3827
+ const batch = newBatchId();
3828
+ appendAudit(root, 'audit', { action: 'manifest', ts: new Date().toISOString(), batch: batch, command: 'consolidate', mode: 'apply', root: root, active_before: collectEntryFiles(root).length, undone: false });
3829
+ lines.push('');
3830
+ lines.push('- 批次: ' + batch + '(审计 .archive/audit-<日期>.jsonl;回滚: yotta-memory consolidate --undo ' + batch + ')');
3831
+ let made = 0, moved = 0;
3832
+ for (const g of groups) {
3833
+ const res = applyConsolidateGroup(root, g, batch, period);
3834
+ made += res.made; moved += res.moved;
3835
+ lines.push('- 已生成摘要 ' + res.summaryRel + '(归档原文 ' + res.moved + ' 条)');
3836
+ }
3837
+ appendAudit(root, 'audit', { action: 'batch_done', ts: new Date().toISOString(), batch: batch, command: 'consolidate', summaries: made, archived: moved, active_after: collectEntryFiles(root).length });
3838
+ lines.push('- 完成: 生成摘要 ' + made + ' 条 / 原文归档 ' + moved + ' 条');
3839
+ return { error: false, text: lines.join('\n') };
3840
+ }
3841
+
3842
+ function cmdConsolidate(opts) {
3843
+ const r = consolidateCore(opts);
3844
+ console.log(r.text);
3845
+ if (r.error) process.exit(2);
3846
+ }
3847
+
3848
+ // ---- 近重复自动合并:置信度分档 + --apply 批量执行(maintain --dedup 增强)----
3849
+ function dupScorePair(a, b) {
3850
+ const jac = jaccardTokens(a, b);
3851
+ const sa = String(a.subject || '').toLowerCase(), sb = String(b.subject || '').toLowerCase();
3852
+ let subSim = 0;
3853
+ if (sa && sb) { if (sa === sb) subSim = 1; else subSim = Math.max(0, 1 - editDistance(sa, sb) / 3); }
3854
+ const typeEq = (a.type === b.type) ? 1 : 0;
3855
+ const scopeEq = (String(a.scope || '') === String(b.scope || '') && String(a.owner || '') === String(b.owner || '')) ? 1 : 0;
3856
+ let score = 0.45 * jac + 0.25 * subSim + 0.15 * typeEq + 0.15 * scopeEq;
3857
+ const la = String(a.statement || '').length, lb = String(b.statement || '').length;
3858
+ const mn = Math.min(la, lb), mx = Math.max(la, lb);
3859
+ if (mx > 0 && mn / mx < 0.3) score = 0;
3860
+ return round3(score);
3861
+ }
3862
+
3863
+ function groupDupScore(g) {
3864
+ if (g.length < 2) return 0;
3865
+ let mn = 2, sum = 0, n = 0;
3866
+ for (let i = 0; i < g.length; i++) for (let j = i + 1; j < g.length; j++) {
3867
+ const sc = dupScorePair(g[i], g[j]);
3868
+ if (sc < mn) mn = sc;
3869
+ sum += sc; n++;
3870
+ }
3871
+ return round3(n ? (mn + sum / n) / 2 : 0);
3872
+ }
3873
+
3874
+ function dupGroupMergeable(g) {
3875
+ if (g.length < 2) return false;
3876
+ const t = g[0].type, sc = String(g[0].scope || ''), ow = String(g[0].owner || '');
3877
+ for (const e of g) {
3878
+ if (e.type !== t || String(e.scope || '') !== sc || String(e.owner || '') !== ow) return false;
3879
+ if (e.immutable || e.type === 'BOUND') return false;
3880
+ }
3881
+ return true;
3882
+ }
3883
+
3884
+ function autoMergePairCore(root, keepE, dropE, batch, selfAgent, unsafe) {
3885
+ for (const f of [keepE.file, dropE.file]) {
3886
+ const deny = checkOwnerWritable(root, f, selfAgent, unsafe);
3887
+ if (deny) return { error: true, text: deny };
3888
+ }
3889
+ const keepFp = path.join(root, keepE.file);
3890
+ const dropFp = path.join(root, dropE.file);
3891
+ const keepOwner = ownerFromPrivatePath(root, keepFp);
3892
+ const dropOwner = ownerFromPrivatePath(root, dropFp);
3893
+ let kMeta, dMeta;
3894
+ try {
3895
+ kMeta = parseFrontmatter(readMemoryText(root, keepFp, keepOwner)).meta;
3896
+ dMeta = parseFrontmatter(readMemoryText(root, dropFp, dropOwner)).meta;
3897
+ } catch (e) { return { error: true, text: '读取失败: ' + e.message }; }
3898
+ const before = { updated: kMeta.updated || '', tags: parseTags(kMeta.tags), access_count: parseInt(kMeta.access_count || '0', 10) || 0, feedback_net: feedbackNetOf(kMeta) };
3899
+ const mergedTags = [];
3900
+ for (const t of parseTags(kMeta.tags).concat(parseTags(dMeta.tags))) if (mergedTags.indexOf(t) === -1) mergedTags.push(t);
3901
+ const patch = {
3902
+ updated: today(),
3903
+ tags: JSON.stringify(mergedTags),
3904
+ access_count: before.access_count + (parseInt(dMeta.access_count || '0', 10) || 0),
3905
+ feedback_net: round3(before.feedback_net + feedbackNetOf(dMeta))
3906
+ };
3907
+ rewriteFrontmatter(keepFp, patch, root, keepOwner);
3908
+ upsertIndexEntry(root, readEntry(keepFp, root));
3909
+ const destDir = archiveDirFor(root, String(dMeta.type || 'FACT').toUpperCase(), dropOwner);
3910
+ fs.mkdirSync(destDir, { recursive: true });
3911
+ const dest = path.join(destDir, path.basename(dropFp));
3912
+ fs.renameSync(dropFp, dest);
3913
+ removeIndexEntry(root, dropE.file);
3914
+ appendAudit(root, 'audit', { ts: new Date().toISOString(), batch: batch, action: 'merge', keepFile: keepE.file, dropFile: dropE.file, dropTo: relOf(root, dest), before: before, after: { updated: patch.updated, tags: mergedTags, access_count: patch.access_count, feedback_net: patch.feedback_net } });
3915
+ return { error: false, text: '已合并 ' + dropE.file + ' -> ' + keepE.file };
3916
+ }
3917
+
3918
+ function appendDedupBlock(lines, root, opts) {
3919
+ const apply = !!opts.apply;
3920
+ const selfAgent = opts.selfAgent || currentAgent();
3921
+ const unsafe = !!opts.unsafe;
3922
+ lines.push('');
3923
+ lines.push('### 重复候选与置信度(--dedup' + (apply ? ' --apply 自动合并' : '') + ')');
3924
+ const entries = ensureIndex(root);
3925
+ const groups = [];
3926
+ const used = new Set();
3927
+ for (let i = 0; i < entries.length; i++) {
3928
+ if (used.has(i)) continue;
3929
+ const g = [entries[i]];
3930
+ used.add(i);
3931
+ for (let j = i + 1; j < entries.length; j++) {
3932
+ if (used.has(j)) continue;
3933
+ if (subjectsSimilar(entries[i], entries[j]) && jaccardTokens(entries[i], entries[j]) >= 0.5) {
3934
+ g.push(entries[j]); used.add(j);
3935
+ }
3936
+ }
3937
+ if (g.length > 1) groups.push(g);
3938
+ }
3939
+ if (!groups.length) { lines.push('(无重复候选)'); return; }
3940
+ const plans = [];
3941
+ for (const g of groups) {
3942
+ const score = groupDupScore(g);
3943
+ const mergeable = dupGroupMergeable(g) && score >= 0.85;
3944
+ plans.push({ g: g, score: score, mergeable: mergeable });
3945
+ }
3946
+ const high = plans.filter(function (p) { return p.mergeable; });
3947
+ const suggest = plans.filter(function (p) { return !p.mergeable && p.score >= 0.65; });
3948
+ for (const p of plans) {
3949
+ const tag = p.mergeable ? '可自动合并' : (p.score >= 0.65 ? '建议手动合并' : '低置信(忽略)');
3950
+ lines.push('- 置信度 ' + p.score + ' [' + tag + '] 组: ' + p.g.map(function (e) { return e.file; }).join(' | '));
3951
+ lines.push(' ' + p.g.map(function (e) { return e.subject + ': ' + String(e.statement || '').slice(0, 60); }).join(' / '));
3952
+ }
3953
+ if (!apply) {
3954
+ lines.push(' 执行: yotta-memory maintain --dedup --apply(自动合并高置信组,写入批次审计可回滚);手动: maintain --merge <A>,<B>');
3955
+ return;
3956
+ }
3957
+ if (!high.length) { lines.push(' (无高置信可自动合并组)'); return; }
3958
+ const batch = newBatchId();
3959
+ appendAudit(root, 'audit', { action: 'manifest', ts: new Date().toISOString(), batch: batch, command: 'dedup-merge', mode: 'apply', root: root, undone: false });
3960
+ let merged = 0;
3961
+ for (const p of high) {
3962
+ const arr = p.g.slice().sort(function (a, b) {
3963
+ const ca = a.confidence - b.confidence;
3964
+ if (ca !== 0) return b.confidence - a.confidence;
3965
+ return String(b.updated || '').localeCompare(String(a.updated || ''));
3966
+ });
3967
+ const keep = arr[0];
3968
+ for (let k = 1; k < arr.length; k++) {
3969
+ const r = autoMergePairCore(root, keep, arr[k], batch, selfAgent, unsafe);
3970
+ lines.push('- ' + r.text + (r.error ? '(失败)' : ''));
3971
+ if (!r.error) merged++;
3972
+ }
3973
+ }
3974
+ appendAudit(root, 'audit', { action: 'batch_done', ts: new Date().toISOString(), batch: batch, command: 'dedup-merge', merged: merged });
3975
+ lines.push('- 批次: ' + batch + '(自动合并 ' + merged + ' 条;回滚: yotta-memory consolidate --undo ' + batch + ')');
3976
+ }
3977
+
3978
+
3496
3979
  // ---- usage / main ----
3497
3980
  function usage() {
3498
3981
  const banner = 'yotta-memory v' + VERSION + ' — 元忆:有权限边界的文件式智能体记忆';
@@ -3503,6 +3986,11 @@ function usage() {
3503
3986
  ['recall', '检索记忆(--type/--limit/--agent/--owner/--all/--unsafe)'],
3504
3987
  ['forget', '删除一条记忆'],
3505
3988
  ['archive', '归档(--days/--threshold 盖棺分+年龄)'],
3989
+ ['maintain', '记忆自组织(归档/遗忘候选/去重/合并;默认 dry-run;--dedup 查重+置信度,--dedup --apply 自动合并高置信组)'],
3990
+ ['consolidate', '周期摘要压缩(默认 dry-run;--apply 执行;--undo <batch> 回滚;--batches 查批次;候选=超龄+闲置+低效用,immutable/BOUND 豁免)'],
3991
+ ['distill', '心理日志蒸馏(统计摘要/主题画像/知识地图;--model 可选外部模型)'],
3992
+ ['feedback', '使用反馈(--useful/--useless/--undo)'],
3993
+ ['explain', '查看单条记忆效用分项与归档/遗忘状态判定'],
3506
3994
  ['reindex', '重建索引'],
3507
3995
  ['export', '导出全部记忆(--out 文件.json)'],
3508
3996
  ['import', '导入记忆(<文件.json>)']
@@ -3519,7 +4007,7 @@ function usage() {
3519
4007
  ['view', '启动用户查看平台(--port/--host;口令解锁浏览/授权/吊销 AI)'],
3520
4008
  ['reset-password', '重设主口令(忘口令用恢复钥匙)'],
3521
4009
  ['key', '管理 AI 私密读取授权缓存(list / authorize <id> / revoke <id>)'],
3522
- ['config', '查看/设置配置(get / set memory_home <目录> / set embedding_cmd <命令> / set embedding_timeout <毫秒>)']
4010
+ ['config', '查看/设置配置(get;set memory_home <目录> / embedding_cmd <命令> / embedding_timeout <毫秒> / maintain_* 阈值与半衰 / consolidate_* 参数)']
3523
4011
  ]],
3524
4012
  ['平台与服务', [
3525
4013
  ['serve', '启动 MCP 记忆引擎(streamable HTTP;--stdio 本地零进程)'],
@@ -3547,7 +4035,7 @@ async function main() {
3547
4035
  if (!args.length) { usage(); return; }
3548
4036
  const opts = {};
3549
4037
  const positional = [];
3550
- const valueOpts = new Set(['--type', '--limit', '--days', '--out', '--owner', '--agent', '--threshold', '--scope', '--host', '--port', '--dir', '--name', '--user', '--relationship', '--source', '--weight', '--budget', '--password', '--new-password', '--recovery-key', '--reason', '--merge', '--model', '--subject', '--embedding', '--focus', '--embedding-timeout']);
4038
+ const valueOpts = new Set(['--type', '--limit', '--days', '--out', '--owner', '--agent', '--threshold', '--scope', '--host', '--port', '--dir', '--name', '--user', '--relationship', '--source', '--weight', '--budget', '--password', '--new-password', '--recovery-key', '--reason', '--merge', '--model', '--subject', '--embedding', '--focus', '--embedding-timeout', '--min-age', '--min-idle', '--max-utility', '--min-group', '--period']);
3551
4039
  for (let i = 0; i < args.length; i++) {
3552
4040
  const a = args[i];
3553
4041
  if (a === '--version' || a === '-v') { console.log(VERSION); return; }
@@ -3570,6 +4058,7 @@ async function main() {
3570
4058
  else if (a === '--apply') opts.apply = true;
3571
4059
  else if (a === '--purge') opts.purge = true;
3572
4060
  else if (a === '--dedup') opts.dedup = true;
4061
+ else if (a === '--batches') opts.batches = true;
3573
4062
  else if (a === '--explain') opts.explain = true;
3574
4063
  else if (a === '--semantic') opts.semantic = true;
3575
4064
  else if (valueOpts.has(a)) {
@@ -3601,6 +4090,11 @@ async function main() {
3601
4090
  else if (a === '--embedding') opts.embedding = v;
3602
4091
  else if (a === '--focus') opts.focus = v;
3603
4092
  else if (a === '--embedding-timeout') opts.embeddingTimeout = parseInt(v, 10) || 3000;
4093
+ else if (a === '--min-age') opts.minAge = parseInt(v, 10) || 0;
4094
+ else if (a === '--min-idle') opts.minIdle = parseInt(v, 10) || 0;
4095
+ else if (a === '--max-utility') opts.maxUtility = parseFloat(v);
4096
+ else if (a === '--min-group') opts.minGroup = parseInt(v, 10) || 0;
4097
+ else if (a === '--period') opts.period = parseInt(v, 10) || 0;
3604
4098
  } else if (a.startsWith('--')) {
3605
4099
  console.error('未知选项: ' + a);
3606
4100
  process.exit(2);
@@ -3618,7 +4112,7 @@ async function main() {
3618
4112
  const sub = rest[0];
3619
4113
  if (sub === 'set') cmdConfigSet(rest[1], rest[2]);
3620
4114
  else if (sub === 'get') cmdConfigGet();
3621
- else { console.error('config 子命令: set memory_home <目录> / set embedding_cmd <命令> / set embedding_timeout <毫秒> / get'); process.exit(2); }
4115
+ else { console.error('config 子命令: set <键> <值>(memory_home / embedding_cmd / embedding_timeout / maintain_* / consolidate_*)/ get'); process.exit(2); }
3622
4116
  break;
3623
4117
  }
3624
4118
  case 'remember': cmdRemember(rest[0], rest[1], rest[2], opts); break;
@@ -3628,6 +4122,7 @@ async function main() {
3628
4122
  case 'explain': cmdExplain(rest[0], opts); break;
3629
4123
  case 'maintain': cmdMaintain(opts); break;
3630
4124
  case 'distill': cmdDistill(opts); break;
4125
+ case 'consolidate': if (opts.undo === true && rest.length) opts.undo = rest[0]; cmdConsolidate(opts); break;
3631
4126
  case 'forget': cmdForget(rest[0]); break;
3632
4127
  case 'archive': cmdArchive(opts); break;
3633
4128
  case 'reindex': cmdReindex(); break;
@@ -3684,6 +4179,13 @@ module.exports = {
3684
4179
  importanceScore: importanceScore,
3685
4180
  forgetCore: forgetCore,
3686
4181
  archiveCore: archiveCore,
4182
+ maintainCore: maintainCore,
4183
+ consolidateCore: consolidateCore,
4184
+ mergeCore: mergeCore,
4185
+ utilityScore: utilityScore,
4186
+ utilityBreakdown: utilityBreakdown,
4187
+ decayRecency: decayRecency,
4188
+ decayHalflifeDays: decayHalflifeDays,
3687
4189
  mcpTools: mcpTools,
3688
4190
  callTool: callTool,
3689
4191
  handleMessage: handleMessage,