@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.
- package/CHANGELOG.md +23 -1
- package/README.md +28 -6
- package/README.zh-CN.md +30 -6
- package/SKILL.md +7 -6
- package/USER_GUIDE.md +38 -10
- package/bin/yotta-memory.js +559 -57
- package/package.json +2 -2
- package/references/faq.md +12 -0
- package/references/protocol.md +52 -1
package/bin/yotta-memory.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 (
|
|
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)
|
|
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 =
|
|
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 =
|
|
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 (
|
|
1662
|
-
if (
|
|
1663
|
-
|
|
1664
|
-
|
|
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
|
-
|
|
2816
|
-
if (
|
|
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 === '
|
|
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
|
|
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
|
|
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,
|