@yottameta/yotta-memory 0.16.7 → 0.17.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 +48 -0
- package/README.md +10 -4
- package/README.zh-CN.md +10 -4
- package/SKILL.md +22 -12
- package/USER_GUIDE.md +16 -9
- package/assets/view.html +2 -1
- package/bin/yotta-memory.js +2366 -185
- package/package.json +1 -1
- package/references/faq.md +6 -0
- package/references/protocol.md +3 -2
- package/skill-manifest.json +1 -1
package/bin/yotta-memory.js
CHANGED
|
@@ -25,9 +25,268 @@ const net = require('net');
|
|
|
25
25
|
const child_process = require('child_process');
|
|
26
26
|
const { AsyncLocalStorage } = require('async_hooks');
|
|
27
27
|
|
|
28
|
-
const VERSION = '0.
|
|
28
|
+
const VERSION = '0.17.0';
|
|
29
|
+
const CLI_VALUE_OPTS = new Set(['--type', '--limit', '--days', '--out', '--owner', '--agent', '--agent-id', '--agent-key', '--agent-key-file', '--plugin-data', '--threshold', '--scope', '--host', '--port', '--dir', '--name', '--user', '--relationship', '--source', '--weight', '--budget', '--password', '--new-password', '--recovery-key', '--recovery-key-out', '--reason', '--merge', '--model', '--subject', '--embedding', '--focus', '--embedding-timeout', '--min-age', '--min-idle', '--max-utility', '--min-group', '--period', '--to', '--id', '--time', '--tools', '--mcp-config', '--skill-dir', '--year', '--evalset', '--k', '--seed', '--bootstrap', '--gate', '--against', '--template', '--path']);
|
|
30
|
+
const CLI_FLAG_OPTS = new Set(['--project', '--all', '--unsafe', '--no-auth', '--stdio', '--onstart', '--from-current', '--restart', '--force', '--attach', '--allow-same-volume', '--verify', '--no-hint', '--encrypt', '--no-encrypt', '--password-stdin', '--json', '--manual', '--skip-schedule', '--useful', '--useless', '--undo', '--dry-run', '--apply', '--purge', '--dedup', '--batches', '--explain', '--semantic', '--runtime', '--ablate', '--timing', '--baseline', '--probe', '--quarantine', '--restore', '--yes', '--keep-memories', '--keep-identity']);
|
|
31
|
+
|
|
32
|
+
function helpOption(flag, arg, what, when, caution) {
|
|
33
|
+
return { flag: flag, arg: arg || '', what: what, when: when || '', caution: caution || '' };
|
|
34
|
+
}
|
|
35
|
+
function helpSub(name, usage, what, when, options) {
|
|
36
|
+
return { name: name, usage: usage || name, what: what, when: when || '', options: options || [] };
|
|
37
|
+
}
|
|
38
|
+
const HELP_MODEL = [
|
|
39
|
+
{ group: '核心记忆', commands: [
|
|
40
|
+
{ name: 'init', usage: 'init [--project | --dir <目录>] [--attach | --encrypt | --no-encrypt] [--password-stdin] [--recovery-key-out <文件>]', what: '初始化记忆库', when: '第一次使用,或把已有记忆库接入当前项目 / 智能体时', options: [
|
|
41
|
+
helpOption('--project', '', '把记忆库放在当前项目根目录', '只希望这个项目使用这份记忆时', ''),
|
|
42
|
+
helpOption('--dir', '<目录>', '指定记忆库位置', '不想用默认用户级位置时', ''),
|
|
43
|
+
helpOption('--attach', '', '接入已有记忆库', '目录里已经有一份库,不要重新初始化时', ''),
|
|
44
|
+
helpOption('--encrypt', '', '新建加密记忆库', '想显式要求加密时', ''),
|
|
45
|
+
helpOption('--no-encrypt', '', '新建明文记忆库', '只做临时实验、明确接受明文风险时', '私密记忆会以明文落盘;正式使用不要加这个选项'),
|
|
46
|
+
helpOption('--password-stdin', '', '从标准输入读取主口令', '脚本或非交互环境设置口令时', '只适合纯 ASCII 口令;中文口令请交互输入'),
|
|
47
|
+
helpOption('--recovery-key-out', '<文件>', '把恢复钥匙写到文件', '新建或迁移后需要立刻保存恢复钥匙时', '恢复钥匙能重置主口令,必须单独妥善保管'),
|
|
48
|
+
helpOption('--force', '', '允许覆盖已存在的运行文件', '确认要替换旧运行文件时', '可能覆盖现有内容;先确认目标路径和备份'),
|
|
49
|
+
] },
|
|
50
|
+
{ name: 'remember', usage: 'remember <type> <subject> <statement> [选项]', what: '写入一条记忆', when: '出现事实、偏好、边界或承诺,需要跨会话保留时', options: [
|
|
51
|
+
helpOption('--source', '<来源>', '记录这条记忆的来源', '需要以后追溯「谁说的 / 哪来的」时', ''),
|
|
52
|
+
helpOption('--weight', '<数值>', '设置重要性权重', '这条记忆比普通条目更重要或更次要时', ''),
|
|
53
|
+
helpOption('--verify', '', '写完立刻回读校验', '关键记忆担心写失败或索引异常时', ''),
|
|
54
|
+
helpOption('--no-hint', '', '关闭类型启发式提示', '已经明确知道类型、不想看额外提醒时', ''),
|
|
55
|
+
] },
|
|
56
|
+
{ name: 'recall', usage: 'recall [关键词] [选项];search 是别名', what: '检索记忆', when: '开工恢复上下文、找旧决定或确认边界时', options: [
|
|
57
|
+
helpOption('--type', '<类型>', '只查某一种类型', '只想看 FACT / PREF / BOUND / COMMIT 中的一类时', ''),
|
|
58
|
+
helpOption('--limit', '<条数>', '限制返回条数', '结果太多、只需看最相关的几条时', ''),
|
|
59
|
+
helpOption('--agent', '<id>', '声明当前智能体身份或按智能体筛选', '当前会话身份需要明确声明时', '声明身份不等于获得跨读权限'),
|
|
60
|
+
helpOption('--owner', '<id>', '指定记忆归属者', '查询某个 owner 的私密记忆时', '读其它智能体的私密区需要授权'),
|
|
61
|
+
helpOption('--all', '', '跨 owner 检索', '确实需要看所有可读范围内的记忆时', '跨读其它智能体私密仍未授权会拒绝并提示'),
|
|
62
|
+
helpOption('--unsafe', '', '跳过越界读取保护', '只有用户明确授权做隔离排查时', '会放宽私密读取边界,日常不要使用'),
|
|
63
|
+
helpOption('--explain', '', '输出命中理由和效用分项', '想理解为什么这条记忆被排到前面时', ''),
|
|
64
|
+
helpOption('--semantic', '', '显式开启语义检索', '想强制使用同义词 / 拼音 / 模糊匹配时', ''),
|
|
65
|
+
helpOption('--embedding', '<命令>', '临时指定本地 embedding 插件命令', '这次检索想使用自定义本地向量命令时', '只在本地执行,不会把记忆上传远端'),
|
|
66
|
+
helpOption('--embedding-timeout', '<毫秒>', '设置 embedding 插件超时', '插件较慢或需要快速失败时', ''),
|
|
67
|
+
helpOption('--year', '<yyyy>', '只检索指定年份的记忆', '库很大、只想在某一年的记忆里找时', '可重复传多次;不传则检索全部年份'),
|
|
68
|
+
] },
|
|
69
|
+
{ name: 'forget', usage: 'forget <记忆 id> [选项]', what: '删除一条记忆', when: '确认某条记忆不应继续保留时', options: [
|
|
70
|
+
helpOption('--reason', '<原因>', '记录删除原因', '需要留下为什么删除的审计线索时', ''),
|
|
71
|
+
helpOption('--unsafe', '', '跳过越界删除保护', '只有获得明确授权处理隔离库时', '会放宽删除边界,误删风险很高'),
|
|
72
|
+
] },
|
|
73
|
+
{ name: 'archive', usage: 'archive [选项]', what: '把低价值或过期记忆归档', when: '库变大,想让旧记忆退出主检索但保留可回溯时', options: [
|
|
74
|
+
helpOption('--days', '<天数>', '按未使用天数判断归档', '想调整「多久没用才归档」时', ''),
|
|
75
|
+
helpOption('--threshold', '<数值>', '按效用分阈值判断归档', '想调整「用得少不少」时', ''),
|
|
76
|
+
helpOption('--dry-run', '', '只预览归档结果', '先看会动哪些记忆,再决定是否执行时', ''),
|
|
77
|
+
] },
|
|
78
|
+
{ name: 'backup', usage: 'backup <子命令> [选项]', what: '备份、恢复和演练记忆库', when: '配置自动备份、创建备份、恢复误删或验证备份可用时', subcommands: [
|
|
79
|
+
helpSub('volumes', 'volumes [--json]', '列出可用备份卷', '想知道备份会写到哪里时', [helpOption('--json', '', '输出 JSON', '脚本处理备份卷列表时', '')]),
|
|
80
|
+
helpSub('setup', 'setup [--manual] [--skip-schedule] [--allow-same-volume]', '配置备份目录和计划任务', '第一次启用自动备份时', [
|
|
81
|
+
helpOption('--manual', '', '只配置目录,不改计划任务', '由你自己管理定时任务时', ''),
|
|
82
|
+
helpOption('--skip-schedule', '', '跳过自动计划任务', '只想先建备份目录、不注册系统计划时', ''),
|
|
83
|
+
helpOption('--allow-same-volume', '', '允许备份和记忆库在同一磁盘卷', '只有临时测试且接受同盘故障风险时', '同盘备份无法防止整盘损坏;正式使用必须换独立盘'),
|
|
84
|
+
]),
|
|
85
|
+
helpSub('status', 'status [--json]', '查看备份健康状态', '开工检查或排查备份是否正常时', [helpOption('--json', '', '输出 JSON', '脚本读取备份状态时', '')]),
|
|
86
|
+
helpSub('ensure-daily', 'ensure-daily [--json]', '确保每日备份任务存在', '修复自动备份任务缺失时', [helpOption('--json', '', '输出 JSON', '自动化里检查任务时', '')]),
|
|
87
|
+
helpSub('schedule', 'schedule <on|off|status> [--time <HH:MM>]', '管理每天自动备份', '设置或关闭每日备份时', [helpOption('--time', '<HH:MM>', '设置每日备份时间', '默认 03:30 不合适时', '')]),
|
|
88
|
+
helpSub('create', 'create [--json]', '立即创建一份备份', '手动留一个恢复点、或改库前先备份时', [helpOption('--json', '', '输出 JSON', '脚本读取新备份 id 时', '')]),
|
|
89
|
+
helpSub('list', 'list [--json]', '列出备份历史', '选一个恢复点时', [helpOption('--json', '', '输出 JSON', '脚本读取备份列表时', '')]),
|
|
90
|
+
helpSub('doctor', 'doctor [--json]', '检查备份目录和文件是否健康', '怀疑备份损坏或磁盘异常时', [helpOption('--json', '', '输出 JSON', '自动化体检时', '')]),
|
|
91
|
+
helpSub('restore', 'restore <id> --to <目录> [--force]', '从备份恢复到指定目录', '误删、迁移或需要验证恢复内容时', [
|
|
92
|
+
helpOption('--to', '<目录>', '指定恢复目标目录', '恢复时必须显式给出目标位置', ''),
|
|
93
|
+
helpOption('--id', '<id>', '指定备份 id', '不用位置参数、想显式点名备份时', ''),
|
|
94
|
+
helpOption('--force', '', '允许覆盖非空目标目录', '确认目标可以覆盖时', '可能覆盖目标目录现有文件;先确认目标和备份'),
|
|
95
|
+
]),
|
|
96
|
+
helpSub('drill', 'drill <id> [--to <目录>] [--probe]', '恢复演练', '验证备份真的能恢复时', [
|
|
97
|
+
helpOption('--to', '<目录>', '指定演练恢复目录', '演练时使用临时空目录更安全', ''),
|
|
98
|
+
helpOption('--id', '<id>', '指定备份 id', '不用位置参数、想显式点名备份时', ''),
|
|
99
|
+
helpOption('--probe', '', '恢复后自动跑恢复 / 迁移基线探针', '想确认恢复副本的身份、近期条目与操作规则都可用时', '复制的索引与访问计数不变,探针只读'),
|
|
100
|
+
helpOption('--against', '<库路径>', '指定对比库做差集校验', '想确认备份没有落后于源库、迁移没有丢条目时', '对比库只读;差集缺失会列出清单并非零退出'),
|
|
101
|
+
]),
|
|
102
|
+
] },
|
|
103
|
+
{ name: 'doctor', usage: 'doctor [选项]', what: '开工可靠性检查', when: '开工前、异常后或升级后确认系统状态时', options: [
|
|
104
|
+
helpOption('--runtime', '', '同时检查 CLI、current、MCP、进程和技能副本是否漂移', '升级后版本对不上时', ''),
|
|
105
|
+
helpOption('--mcp-config', '<文件>', '补充检查指定 MCP 配置文件', '排查宿主 MCP 是否指向旧运行时', ''),
|
|
106
|
+
helpOption('--skill-dir', '<目录>', '补充检查指定技能副本目录', '排查技能目录是否落后', ''),
|
|
107
|
+
helpOption('--baseline', '', '追加恢复 / 迁移基线探针(六类,只读)', '恢复、迁移或升级后要确认记忆真的可用时', '探针失败会列出缺失清单并以非零状态退出'),
|
|
108
|
+
helpOption('--against', '<库路径>', '探针的对比库(通常是迁移前的源库)', '要确认目标库没有丢条目时', '差集条目缺失会逐条列出'),
|
|
109
|
+
helpOption('--template', '<文件>', '用模板声明显式期望(owner / 最小条数 / 类型条数 / 查询)', '有明确验收清单、要按清单核对时', '模板只加严判定,不会放宽或改写探针'),
|
|
110
|
+
helpOption('--json', '', '输出 JSON', '脚本或自动化读取体检结果时', ''),
|
|
111
|
+
] },
|
|
112
|
+
{ name: 'maintain', usage: 'maintain [选项]', what: '记忆自组织:归档、遗忘候选、去重和合并', when: '定期清理低价值记忆、合并重复条目时', options: [
|
|
113
|
+
helpOption('--dedup', '', '只做重复检查并给置信度', '想先看哪些条目重复时', ''),
|
|
114
|
+
helpOption('--apply', '', '真正执行,而不是只预览', '确认预览结果后要落盘时', '会改动记忆库并创建事务快照;先跑 dry-run'),
|
|
115
|
+
helpOption('--purge', '', '对遗忘候选执行真删', '确认候选确实不再需要时', '真删不可逆;通常先用归档而不是 purge'),
|
|
116
|
+
helpOption('--dry-run', '', '只预览,不改库', '默认行为,想显式表达时', ''),
|
|
117
|
+
helpOption('--min-age', '<天数>', '设置遗忘候选最小年龄', '想调整「放多久才考虑遗忘」时', ''),
|
|
118
|
+
helpOption('--min-idle', '<天数>', '设置遗忘候选最小闲置天数', '想调整「多久没碰才考虑遗忘」时', ''),
|
|
119
|
+
helpOption('--max-utility', '<数值>', '设置效用分上限', '想调整「用得少到什么程度」时', ''),
|
|
120
|
+
helpOption('--min-group', '<条数>', '设置重复组最小条数', '调整查重灵敏度时', ''),
|
|
121
|
+
helpOption('--merge', '<id列表>', '显式合并指定条目', '已经确认这些条目可以合并时', ''),
|
|
122
|
+
helpOption('--json', '', '输出 JSON', '自动化处理维护结果时', ''),
|
|
123
|
+
] },
|
|
124
|
+
{ name: 'consolidate', usage: 'consolidate [--apply | --undo <batch> | --batches] [选项]', what: '把旧记忆压缩成周期摘要', when: '库很大,想把超龄、闲置、低效用条目压成摘要时', options: [
|
|
125
|
+
helpOption('--apply', '', '真正执行压缩', '确认 dry-run 结果后要落盘时', '会改动记忆库并创建事务快照;先预览'),
|
|
126
|
+
helpOption('--undo', '<batch>', '回滚一个摘要批次', '压缩后发现结果不合适时', ''),
|
|
127
|
+
helpOption('--batches', '', '列出历史批次', '想找回滚批次号时', ''),
|
|
128
|
+
helpOption('--period', '<天数>', '设置摘要周期', '想改变摘要覆盖的时间跨度时', ''),
|
|
129
|
+
helpOption('--threshold', '<数值>', '设置候选效用阈值', '想调整进入摘要的门槛时', ''),
|
|
130
|
+
helpOption('--min-age', '<天数>', '设置候选最小年龄', '想调整「放多久才进入摘要」时', ''),
|
|
131
|
+
helpOption('--min-idle', '<天数>', '设置候选最小闲置天数', '想调整「多久没碰才进入摘要」时', ''),
|
|
132
|
+
helpOption('--max-utility', '<数值>', '设置候选效用分上限', '想调整「用得少到什么程度」时', ''),
|
|
133
|
+
helpOption('--min-group', '<条数>', '设置摘要组最小条数', '调整合并力度时', ''),
|
|
134
|
+
helpOption('--json', '', '输出 JSON', '自动化处理摘要结果时', ''),
|
|
135
|
+
] },
|
|
136
|
+
{ name: 'distill', usage: 'distill [--model <命令>] [选项]', what: '把心理日志蒸馏成统计摘要、主题画像和知识地图', when: '想把长期记录整理成更高层的自我画像时', options: [
|
|
137
|
+
helpOption('--model', '<命令>', '调用本地外部模型辅助蒸馏', '需要更高质量摘要、且已准备本地模型命令时', '命令在本地执行;不要指向会外传原文的服务'),
|
|
138
|
+
helpOption('--subject', '<标题>', '指定蒸馏输出主题', '想给这次摘要一个固定标题时', ''),
|
|
139
|
+
helpOption('--json', '', '输出 JSON', '自动化处理蒸馏结果时', ''),
|
|
140
|
+
] },
|
|
141
|
+
{ name: 'feedback', usage: 'feedback <记忆 id> [--useful | --useless | --undo]', what: '记录记忆是否有用', when: '一条记忆刚刚帮助了你,或明显误导了你时', options: [
|
|
142
|
+
helpOption('--useful', '', '记为有用', '这条记忆确实帮上忙时', ''),
|
|
143
|
+
helpOption('--useless', '', '记为没用', '这条记忆造成误导或噪音时', ''),
|
|
144
|
+
helpOption('--undo', '', '撤销上一次反馈', '误点反馈时', ''),
|
|
145
|
+
] },
|
|
146
|
+
{ name: 'explain', usage: 'explain <记忆 id> [--json]', what: '解释一条记忆的效用分和归档判定', when: '想理解为什么它被排序、归档或遗忘时', options: [helpOption('--json', '', '输出 JSON', '脚本读取解释结果时', '')] },
|
|
147
|
+
{ name: 'bench', usage: 'bench [--evalset <文件>] [--k <条数>] [--seed <数值>] [--bootstrap <次数>] [--ablate] [--gate <指标>=<数值>] [--timing] [--json] [--out <文件>]', what: '可复算的检索基准评测', when: '改检索 / 索引逻辑前后要证明「没变差」,或把质量门禁接进 CI 时', options: [
|
|
148
|
+
helpOption('--evalset', '<文件>', '指定评测集 JSON 文件', '想固定一组查询来复算指标时', '不指定时按库内条目做确定性抽样,生成基线评测集'),
|
|
149
|
+
helpOption('--k', '<条数>', '设置结果窗口大小', '想按 Recall@k / nDCG@k 的 k 口径统计时', '默认 5'),
|
|
150
|
+
helpOption('--seed', '<数值>', '设置抽样与置信区间的随机种子', '要让评测集和置信区间可复算时', '默认 20260925;同种子同库必须同结果'),
|
|
151
|
+
helpOption('--bootstrap', '<次数>', '设置 bootstrap 重采样次数', '想调整 95% 置信区间的精度时', '默认 1000;0 表示不重采样'),
|
|
152
|
+
helpOption('--ablate', '', '输出关键词 / 语义 × 融合 / 纯分的消融对比', '想知道哪一层检索贡献最大时', ''),
|
|
153
|
+
helpOption('--gate', '<指标>=<数值>', '设置质量门禁', 'CI 里要求指标达到阈值时', '不达标 exit 1;可重复传多次,指标可选 recall / mrr / ndcg / hit'),
|
|
154
|
+
helpOption('--timing', '', '附带检索耗时 p50 / p95', '想了解检索耗时分布时', '带上耗时后报告不再逐字节可复算'),
|
|
155
|
+
helpOption('--year', '<yyyy>', '只评测指定年份的记忆', '库很大、只想对某一年做基准时', '可重复传多次;不传则覆盖全部年份'),
|
|
156
|
+
helpOption('--json', '', '输出 JSON', '脚本读取指标与门禁结果时', ''),
|
|
157
|
+
helpOption('--out', '<文件>', '把报告写到文件', '想把评测报告归档或交给 CI 时', ''),
|
|
158
|
+
] },
|
|
159
|
+
{ name: 'scan', usage: 'scan [--path <目录>] [--gate <安全级别>] [--quarantine --yes] [--restore] [--json]', what: '扫描记忆库里的注入、凭证泄漏与越权指令', when: '怀疑记忆被污染、要做安全复核或把门禁接进 CI 时', options: [
|
|
160
|
+
helpOption('--path', '<目录>', '指定要扫描的目录', '想扫描另一个记忆库或临时导出目录时', '默认扫描当前记忆库'),
|
|
161
|
+
helpOption('--gate', '<安全级别>', '设置安全门禁', 'CI 里要求不出现某个级别及以上的命中时', '命中该级别及以上 exit 1;可选 safe / low / medium / high / critical'),
|
|
162
|
+
helpOption('--json', '', '输出 JSON', '脚本读取命中清单与分级统计时', ''),
|
|
163
|
+
helpOption('--quarantine', '', '把命中行脱敏隔离(原文件先备份)', '确认某条记忆被污染、要立刻止住它被检索时', '会改写记忆文件:必须先备份原件,且需要显式确认'),
|
|
164
|
+
helpOption('--yes', '', '跳过交互确认', '自动化里执行隔离时', '跳过确认后不再二次询问,请先看一遍命中清单'),
|
|
165
|
+
helpOption('--restore', '', '还原最近一个隔离批次', '隔离后发现问题、要恢复原文时', '会覆盖当前文件;批次已还原过则拒绝重复还原'),
|
|
166
|
+
helpOption('--id', '<id>', '指定要还原的隔离批次', '要还原的不是最近一批时', ''),
|
|
167
|
+
] },
|
|
168
|
+
{ name: 'reindex', usage: 'reindex', what: '重建记忆索引', when: '索引损坏、手动改过记忆文件或 doctor 提示索引异常时', options: [] },
|
|
169
|
+
{ name: 'export', usage: 'export --out <文件.json>', what: '导出全部记忆', when: '备份、迁移或做离线检查时', options: [helpOption('--out', '<文件>', '指定导出文件', '导出时必须给出目标 JSON 文件', '')] },
|
|
170
|
+
{ name: 'import', usage: 'import <文件.json>', what: '导入记忆 JSON', when: '从导出文件恢复或迁移时', options: [] },
|
|
171
|
+
] },
|
|
172
|
+
{ group: '身份与画像', commands: [
|
|
173
|
+
{ name: 'iam', usage: 'iam --name <名字> --user <用户> --relationship <关系>', what: '登记当前智能体身份', when: '第一次接入,或身份信息需要更新时', options: [
|
|
174
|
+
helpOption('--name', '<名字>', '设置智能体显示名', '登记或更新身份时', ''),
|
|
175
|
+
helpOption('--user', '<用户>', '设置用户称呼', '登记或更新身份时', ''),
|
|
176
|
+
helpOption('--relationship', '<关系>', '设置与用户的关系定位', '登记或更新身份时', ''),
|
|
177
|
+
] },
|
|
178
|
+
{ name: 'identity', usage: 'identity remove <id> [选项]', what: '彻底删除一个 AI 身份与它的私密记忆', when: '某个 AI 不再使用、要把它的身份与私密记忆清干净时', subcommands: [
|
|
179
|
+
helpSub('remove', 'remove <id> [--dry-run] [--yes] [--keep-memories] [--keep-identity] [--password <口令> | --recovery-key <钥匙>]', '彻底删除身份 + 私密记忆(真删)', '确认某个 AI 不再使用、要一次清干净时', [
|
|
180
|
+
helpOption('--dry-run', '', '先列出将删除的内容', '想核对清单再决定时', '预演只读,不改任何文件'),
|
|
181
|
+
helpOption('--yes', '', '跳过交互确认', '自动化里执行删除时', '会立刻真删;请先跑 --dry-run 核对'),
|
|
182
|
+
helpOption('--keep-memories', '', '只删身份与授权,保留私密记忆', '想注销身份但保住记忆时', ''),
|
|
183
|
+
helpOption('--keep-identity', '', '只删私密记忆与授权,保留身份登记', '想清记忆但保留登记时', ''),
|
|
184
|
+
helpOption('--password', '<口令>', '用户主口令(加密库用它证明是用户本人)', '由用户本人在 CLI 执行时', '命令行会留下痕迹;优先交互输入'),
|
|
185
|
+
helpOption('--recovery-key', '<钥匙>', '用恢复钥匙证明用户身份', '忘记主口令时', '恢复钥匙只能由用户本人保管'),
|
|
186
|
+
]),
|
|
187
|
+
] },
|
|
188
|
+
{ name: 'whoami', usage: 'whoami [--json]', what: '查看当前身份和登记状态', when: '不确定当前是谁、是否已登记时', options: [helpOption('--json', '', '输出 JSON', '脚本读取身份状态时', '')] },
|
|
189
|
+
{ name: 'profile', usage: 'profile [--owner <id>] [--json]', what: '生成用户画像', when: '想按私密记忆原文结构化查看用户画像时', options: [
|
|
190
|
+
helpOption('--owner', '<id>', '指定画像归属者', '查某个 owner 的私密画像时', '仍然受私密区权限约束'),
|
|
191
|
+
helpOption('--json', '', '输出 JSON', '自动化读取画像时', ''),
|
|
192
|
+
] },
|
|
193
|
+
{ name: 'context', usage: 'context [选项]', what: '生成开工上下文包', when: '每个会话开工时恢复身份、边界、近期记忆和承诺', options: [
|
|
194
|
+
helpOption('--limit', '<条数>', '限制近期记忆条数', '上下文太长、想缩短时', ''),
|
|
195
|
+
helpOption('--owner', '<id>', '指定 owner 范围', '需要看某个 owner 的上下文时', '仍然受私密区权限约束'),
|
|
196
|
+
helpOption('--budget', '<字符数>', '设置动态记忆字符预算', '需要控制上下文包大小时', '0 表示不限制'),
|
|
197
|
+
helpOption('--focus', '<关键词>', '按当前任务聚焦', '这次任务很明确、想优先拉相关记忆时', ''),
|
|
198
|
+
helpOption('--explain', '', '输出 included / dropped 选择解释', '想理解上下文为什么收录或丢弃时', ''),
|
|
199
|
+
helpOption('--embedding', '<命令>', '临时指定本地 embedding 插件命令', '上下文检索想用本地向量命令时', '只在本地执行'),
|
|
200
|
+
helpOption('--embedding-timeout', '<毫秒>', '设置 embedding 插件超时', '插件较慢或需要快速失败时', ''),
|
|
201
|
+
helpOption('--year', '<yyyy>', '只装载指定年份的记忆', '只想让上下文包覆盖某一年份时', '可重复传多次;不传则装载全部年份'),
|
|
202
|
+
] },
|
|
203
|
+
{ name: 'token', usage: 'token <子命令> [选项]', what: '管理远端 MCP 访问 token', when: '给远端 MCP 客户端发放或吊销访问凭据时', subcommands: [
|
|
204
|
+
helpSub('new', 'new --agent <id> [--scope <范围>] [--json]', '生成一个访问 token', '远端客户端第一次接入时', [
|
|
205
|
+
helpOption('--agent', '<id>', '指定 token 对应的智能体 id', '发 token 时必须明确给谁用', ''),
|
|
206
|
+
helpOption('--scope', '<范围>', '限制 token 权限范围', '只想给某一类访问权限时', ''),
|
|
207
|
+
helpOption('--json', '', '输出 JSON', '脚本读取新 token 时', ''),
|
|
208
|
+
]),
|
|
209
|
+
helpSub('list', 'list [--json]', '列出 token', '查当前有哪些 token 时', [helpOption('--json', '', '输出 JSON', '自动化盘点 token 时', '')]),
|
|
210
|
+
helpSub('revoke', 'revoke --agent <id>', '吊销 token', '设备丢失或凭据泄漏时', [helpOption('--agent', '<id>', '指定要吊销 token 的智能体 id', '吊销时必须明确对象', '')]),
|
|
211
|
+
] },
|
|
212
|
+
] },
|
|
213
|
+
{ group: '加密与安全', commands: [
|
|
214
|
+
{ name: 'migrate', usage: 'migrate [--password-stdin] [--recovery-key-out <文件>]', what: '把明文记忆库迁移成加密记忆库;迁移后授权二选一:推荐 yotta-memory view,等价 CLI 为 yotta-memory key bind <id>', when: '旧库还是明文,确认要启用私密区加密时', options: [
|
|
215
|
+
helpOption('--password', '<口令>', '直接提供主口令', '自动化环境无法交互输入时', '命令行会留下痕迹;优先交互输入'),
|
|
216
|
+
helpOption('--password-stdin', '', '从标准输入读取主口令', '脚本迁移时', '只适合纯 ASCII 口令;中文口令请交互输入'),
|
|
217
|
+
helpOption('--agent', '<id>', '声明迁移使用的身份', '迁移时需要指定 owner 时', ''),
|
|
218
|
+
helpOption('--recovery-key', '<钥匙>', '用恢复钥匙参与迁移', '已有恢复钥匙、需要恢复访问时', '恢复钥匙能重置主口令,注意不要泄漏'),
|
|
219
|
+
helpOption('--recovery-key-out', '<文件>', '把恢复钥匙写到文件', '迁移后要立刻保存恢复钥匙时', '恢复钥匙必须单独妥善保管'),
|
|
220
|
+
] },
|
|
221
|
+
{ name: 'view', usage: 'view [--port <端口>] [--host <地址>]', what: '启动用户查看平台', when: '用户本人要浏览、授权或导出记忆时', options: [
|
|
222
|
+
helpOption('--port', '<端口>', '设置查看平台端口', '默认端口被占用时', ''),
|
|
223
|
+
helpOption('--host', '<地址>', '设置监听地址', '需要只在指定网卡上开放时', '监听公网地址前先确认访问控制和防火墙'),
|
|
224
|
+
] },
|
|
225
|
+
{ name: 'reset-password', usage: 'reset-password [--password <旧口令>] [--new-password <新口令>] [--recovery-key <钥匙>]', what: '重设主口令', when: '知道旧口令想更换,或用恢复钥匙救回访问时', options: [
|
|
226
|
+
helpOption('--password', '<口令>', '提供当前主口令', '正常更换口令时', '命令行会留下痕迹;优先交互输入'),
|
|
227
|
+
helpOption('--new-password', '<口令>', '设置新主口令', '更换口令时', '新口令只保存在本地密钥材料中'),
|
|
228
|
+
helpOption('--password-stdin', '', '从标准输入读取口令', '脚本重设口令时', '只适合纯 ASCII 口令'),
|
|
229
|
+
helpOption('--recovery-key', '<钥匙>', '用恢复钥匙重设口令', '忘记主口令时', '恢复钥匙只显示一次;泄漏等于可重置口令'),
|
|
230
|
+
] },
|
|
231
|
+
{ name: 'key', usage: 'key <子命令> [选项]', what: '管理 agent_key 授权绑定', when: '用户授权智能体读取自己的私密记忆、轮换或吊销 key 时', subcommands: [
|
|
232
|
+
helpSub('list', 'list', '列出授权绑定', '盘点哪些智能体已授权时', []),
|
|
233
|
+
helpSub('bind', 'bind <id> [--password <口令> | --recovery-key <钥匙>]', '给智能体绑定 agent_key', '用户明确授权后绑定', [
|
|
234
|
+
helpOption('--password', '<口令>', '提供主口令', '绑定或轮换时', '命令行会留下痕迹;优先交互输入'),
|
|
235
|
+
helpOption('--recovery-key', '<钥匙>', '用恢复钥匙完成绑定', '忘记主口令时', '恢复钥匙必须妥善保管'),
|
|
236
|
+
]),
|
|
237
|
+
helpSub('rotate', 'rotate <id> [--password <口令> | --recovery-key <钥匙>]', '轮换 agent_key', '怀疑旧 key 泄漏或定期更换时', [
|
|
238
|
+
helpOption('--password', '<口令>', '提供主口令', '轮换时', '命令行会留下痕迹;优先交互输入'),
|
|
239
|
+
helpOption('--recovery-key', '<钥匙>', '用恢复钥匙完成轮换', '忘记主口令时', '恢复钥匙必须妥善保管'),
|
|
240
|
+
]),
|
|
241
|
+
helpSub('authorize', 'authorize <id> [--password <口令>]', '授权一个 owner', '用户查看平台不方便时,由 CLI 显式授权', [helpOption('--password', '<口令>', '提供主口令', '授权时', '这是用户侧操作,AI 不应代做')]),
|
|
242
|
+
helpSub('claim', 'claim <id> [--to <AI_HOME> | --plugin-data <目录> | --agent-key-file <文件>]', '领取待绑定的 agent_key', '用户授权后,智能体新会话领取只显示一次的 key', [
|
|
243
|
+
helpOption('--to', '<AI_HOME>', '把 key 写到指定 AI_HOME', '宿主有标准 AI_HOME 目录时', ''),
|
|
244
|
+
helpOption('--plugin-data', '<目录>', '把 key 写到插件数据目录', 'Agent Plugin 形态接入时', ''),
|
|
245
|
+
helpOption('--agent-key-file', '<文件>', '把 key 写到显式文件', '希望完全指定路径时', ''),
|
|
246
|
+
]),
|
|
247
|
+
helpSub('status', 'status <id> [--to <AI_HOME> | --plugin-data <目录> | --agent-key-file <文件>]', '检查 key 是否已领取、绑定是否有效', '新会话开始或 MCP 连不上时', [
|
|
248
|
+
helpOption('--to', '<AI_HOME>', '检查指定 AI_HOME 下的 key', '宿主有标准 AI_HOME 目录时', ''),
|
|
249
|
+
helpOption('--plugin-data', '<目录>', '检查插件数据目录下的 key', 'Agent Plugin 形态接入时', ''),
|
|
250
|
+
helpOption('--agent-key-file', '<文件>', '检查显式 key 文件', '希望完全指定路径时', ''),
|
|
251
|
+
]),
|
|
252
|
+
helpSub('revoke', 'revoke <id>', '吊销一个 agent_key', '设备丢失、泄漏或不再授权时', []),
|
|
253
|
+
] },
|
|
254
|
+
{ name: 'config', usage: 'config <get | set <键> <值>>', what: '查看或修改元忆配置', when: '调整记忆位置、embedding 命令、备份或维护阈值时', subcommands: [
|
|
255
|
+
helpSub('get', 'get', '查看当前配置', '想知道实际生效值 / 路径时', []),
|
|
256
|
+
helpSub('set', 'set <键> <值>', '修改配置键', '调整 memory_home / backup_dir / embedding_cmd / embedding_timeout / maintain_* / consolidate_* / scale_* 时', []),
|
|
257
|
+
] },
|
|
258
|
+
] },
|
|
259
|
+
{ group: '平台与服务', commands: [
|
|
260
|
+
{ name: 'runtime', usage: 'runtime <子命令> [选项]', what: '管理稳定运行时入口', when: '安装、切换、回滚或盘点元忆运行时版本时', subcommands: [
|
|
261
|
+
helpSub('install', 'install <tarball|版本> [--from-current] [--force]', '安装一个运行时版本', '升级或回退前准备版本目录时', [
|
|
262
|
+
helpOption('--from-current', '', '把当前运行目录打包安装为运行时版本', '把正在用的副本固化成稳定版本时', ''),
|
|
263
|
+
helpOption('--force', '', '允许覆盖内容不同的已安装版本', '确认要替换同版本目录时', '会覆盖已有运行时内容;先确认版本和备份'),
|
|
264
|
+
]),
|
|
265
|
+
helpSub('use', 'use <版本> [--restart]', '切换 current 到指定版本', '升级后要把 MCP 切到新版本时', [helpOption('--restart', '', '切换后重启托管服务', '希望立即生效时', '')]),
|
|
266
|
+
helpSub('rollback', 'rollback [--restart]', '回滚到上一个运行时版本', '新版本出问题时', [helpOption('--restart', '', '回滚后重启托管服务', '希望立即恢复服务时', '')]),
|
|
267
|
+
helpSub('status', 'status [--json]', '查看 current 与运行时状态', '排查版本漂移时', [helpOption('--json', '', '输出 JSON', '自动化读取运行时状态时', '')]),
|
|
268
|
+
helpSub('list', 'list [--json]', '列出已安装运行时版本', '盘点可切换版本时', [helpOption('--json', '', '输出 JSON', '自动化盘点运行时版本时', '')]),
|
|
269
|
+
] },
|
|
270
|
+
{ name: 'serve', usage: 'serve [--stdio] [--tools core|full] [--host <地址>] [--port <端口>]', what: '启动 MCP 记忆引擎', when: '宿主需要以 MCP 方式访问元忆时', options: [
|
|
271
|
+
helpOption('--stdio', '', '用本地 stdio 模式启动', '本地宿主不需要 HTTP 服务时', ''),
|
|
272
|
+
helpOption('--tools', 'core|full', '选择工具分组', '想减少工具常驻开销时', 'core 更省上下文,full 暴露全部工具'),
|
|
273
|
+
helpOption('--host', '<地址>', '设置 HTTP 监听地址', '需要远端 MCP 接入时', '监听公网前必须配置鉴权和防火墙'),
|
|
274
|
+
helpOption('--port', '<端口>', '设置 HTTP 监听端口', '默认端口被占用时', ''),
|
|
275
|
+
helpOption('--no-auth', '', '关闭 HTTP 鉴权', '只在本机临时调试时', '任何能访问端口的人都可读写;不要用于远端或正式环境'),
|
|
276
|
+
helpOption('--agent-id', '<id>', 'stdio 模式下声明智能体 id', '本地宿主用固定身份启动时', ''),
|
|
277
|
+
helpOption('--agent-key', '<key>', 'stdio 模式下直接提供 agent_key', '宿主把 key 作为启动参数传入时', '命令行参数可能被进程列表看到;优先用 key 文件'),
|
|
278
|
+
helpOption('--agent-key-file', '<文件>', 'stdio 模式下从文件读取 agent_key', '宿主使用标准 key 文件时', ''),
|
|
279
|
+
] },
|
|
280
|
+
{ name: 'lan', usage: 'lan <子命令> [选项]', what: '管理开机自启', when: '需要元忆服务随系统或登录启动时', subcommands: [
|
|
281
|
+
helpSub('enable', 'enable [--onstart]', '启用开机自启', '希望元忆服务长期可用时', [helpOption('--onstart', '', '同时启用未登录也启动', 'Linux 上希望开机即启动、不依赖登录时', 'Windows 上此选项不改变计划任务语义')]),
|
|
282
|
+
helpSub('disable', 'disable', '关闭开机自启', '不再需要常驻服务时', []),
|
|
283
|
+
helpSub('status', 'status', '查看自启状态', '确认服务是否会随系统启动时', []),
|
|
284
|
+
] },
|
|
285
|
+
{ name: '--version', usage: '--version;短写法 -v', what: '显示当前版本', when: '确认安装的是哪个版本时', options: [] },
|
|
286
|
+
] },
|
|
287
|
+
];
|
|
29
288
|
// @generated view-html:start
|
|
30
|
-
const VIEW_HTML = "<!doctype html><html lang=\"zh\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>元忆 · 用户查看平台</title><style>\r\nbody{font-family:system-ui,-apple-system,\"Microsoft YaHei\",sans-serif;max-width:1000px;margin:24px auto;padding:0 16px;color:#1f2328;background:#fafafa}\r\nh1{font-size:22px} .card{background:#fff;border:1px solid #e2e2e2;border-radius:10px;padding:16px 18px;margin:14px 0;box-shadow:0 1px 2px rgba(0,0,0,.04)}\r\nbutton{background:#2563eb;color:#fff;border:0;border-radius:6px;padding:7px 14px;cursor:pointer;margin:2px;font-size:14px}\r\nbutton.danger{background:#dc2626} button.ghost{background:#e5e7eb;color:#1f2328}\r\ninput,select{padding:8px;border:1px solid #c9c9c9;border-radius:6px;margin:2px;font-size:14px;box-sizing:border-box}\r\ntable{border-collapse:collapse;width:100%;font-size:13px} td,th{border:1px solid #ececec;padding:6px 8px;text-align:left;vertical-align:top}\r\n.owner{display:inline-flex;align-items:center;gap:6px;border:1px solid #ddd;border-radius:8px;padding:5px 10px;margin:4px 6px 4px 0;background:#f6f8fa}\r\n.entry{border-bottom:1px solid #eee;padding:8px 0} .meta{color:#8a8a8a;font-size:12px}\r\n.err{color:#dc2626;margin-top:8px} .ok{color:#16a34a;margin-top:8px}\r\n#app{display:none} code{background:#f0f0f0;padding:1px 5px;border-radius:4px;font-size:12px}\r\n</style></head><body>\r\n<h1>元忆 · 用户查看平台 <span id=\"ver\" style=\"font-size:14px;color:#888\"></span></h1>\r\n<div id=\"lock\" class=\"card\">\r\n <p><b>输入主口令解锁</b>(口令只在本地内存派生,不落盘、不发送远端)。忘口令可在 CLI 用恢复钥匙重设:<code>yotta-memory reset-password --recovery-key <钥匙></code></p>\r\n <input type=\"password\" id=\"pw\" placeholder=\"主口令\" style=\"width:260px\">\r\n <button onclick=\"unlock()\">解锁</button>\r\n <div class=\"err\" id=\"lockerr\"></div>\r\n</div>\r\n<div id=\"app\">\r\n <div class=\"card\">\r\n <b>AI 列表</b>(✅=已授权可读自己私密,🔒=未授权)\r\n <div class=\"meta\" style=\"margin-top:6px\">「授权」由你(用户)操作:确认后生成只显示一次的 agent_key,请立即单独保存;服务端同时写临时待领取文件 <code>keys/pending/<agent_id>.key</code>,供该 AI 新会话领取,领取成功后自动删除。</div>\r\n <div id=\"owners\" style=\"margin-top:8px\"></div>\r\n </div>\r\n <div class=\"card\">\r\n <b>记忆</b>\r\n <input id=\"q\" placeholder=\"搜索关键词\" style=\"width:220px\" onkeydown=\"if(event.key==='Enter'){off=0;load()}\">\r\n <button onclick=\"off=0;load()\">搜索</button>\r\n <button class=\"ghost\" onclick=\"doExport()\">导出 JSON</button>\r\n <button class=\"ghost\" onclick=\"showRk()\">显示恢复钥匙</button>\r\n <span id=\"rkout\" style=\"font-size:12px;color:#888;margin-left:8px\"></span>\r\n <div id=\"meta\" style=\"margin-top:10px;font-size:12px;color:#666\"></div>\r\n <div id=\"entries\" style=\"margin-top:6px\"></div>\r\n <div id=\"pager\" style=\"margin-top:10px\">\r\n <button class=\"ghost\" id=\"prevb\" onclick=\"prevPage()\">上一页</button>\r\n <span id=\"pageinfo\" style=\"font-size:12px;color:#888;margin:0 8px\"></span>\r\n <button class=\"ghost\" id=\"nextb\" onclick=\"nextPage()\">下一页</button>\r\n </div>\r\n </div>\r\n <div class=\"card\">\r\n <b>重设口令</b><br>\r\n <input type=\"password\" id=\"cur\" placeholder=\"当前口令\">\r\n <input type=\"password\" id=\"np1\" placeholder=\"新口令\">\r\n <input type=\"password\" id=\"np2\" placeholder=\"确认新口令\">\r\n <button onclick=\"resetPw()\">重设</button>\r\n <span id=\"pwout\"></span>\r\n </div>\r\n</div>\r\n<script>\r\nfunction esc(s){return String(s==null?'':s).replace(/[&<>\"']/g,function(c){return{'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c];});}\r\nasync function api(p,b){try{const r=await fetch(p,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b||{})});return await r.json();}catch(e){return{error:String(e)};}}\r\nasync function boot(){const s=await api('/api/status');document.getElementById('ver').textContent='v'+(s.version||'');if(s.unlocked){showApp();}}\r\nfunction showApp(){document.getElementById('lock').style.display='none';document.getElementById('app').style.display='block';loadOwners();load();}\r\nasync function unlock(){const d=await api('/api/unlock',{password:document.getElementById('pw').value});if(d.error){document.getElementById('lockerr').textContent=d.error;return;}showApp();}\r\nasync function loadOwners(){const d=await api('/api/owners');const box=document.getElementById('owners');box.innerHTML='';if(!d.owners||!d.owners.length){box.innerHTML=esc(d.hint||'(无 owner)');return;}\r\n for(const o of d.owners){const c=document.createElement('span');c.className='owner';c.innerHTML=esc(o.owner)+(o.authorized?' ✅':' 🔒')+' <button class=\"ghost\" data-a=\"'+esc(o.owner)+'\">授权</button><button class=\"danger\" data-r=\"'+esc(o.owner)+'\">吊销</button>';box.appendChild(c);}\
|
|
289
|
+
const VIEW_HTML = "<!doctype html><html lang=\"zh\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>元忆 · 用户查看平台</title><style>\r\nbody{font-family:system-ui,-apple-system,\"Microsoft YaHei\",sans-serif;max-width:1000px;margin:24px auto;padding:0 16px;color:#1f2328;background:#fafafa}\r\nh1{font-size:22px} .card{background:#fff;border:1px solid #e2e2e2;border-radius:10px;padding:16px 18px;margin:14px 0;box-shadow:0 1px 2px rgba(0,0,0,.04)}\r\nbutton{background:#2563eb;color:#fff;border:0;border-radius:6px;padding:7px 14px;cursor:pointer;margin:2px;font-size:14px}\r\nbutton.danger{background:#dc2626} button.ghost{background:#e5e7eb;color:#1f2328}\r\ninput,select{padding:8px;border:1px solid #c9c9c9;border-radius:6px;margin:2px;font-size:14px;box-sizing:border-box}\r\ntable{border-collapse:collapse;width:100%;font-size:13px} td,th{border:1px solid #ececec;padding:6px 8px;text-align:left;vertical-align:top}\r\n.owner{display:inline-flex;align-items:center;gap:6px;border:1px solid #ddd;border-radius:8px;padding:5px 10px;margin:4px 6px 4px 0;background:#f6f8fa}\r\n.entry{border-bottom:1px solid #eee;padding:8px 0} .meta{color:#8a8a8a;font-size:12px}\r\n.err{color:#dc2626;margin-top:8px} .ok{color:#16a34a;margin-top:8px}\r\n#app{display:none} code{background:#f0f0f0;padding:1px 5px;border-radius:4px;font-size:12px}\r\n</style></head><body>\r\n<h1>元忆 · 用户查看平台 <span id=\"ver\" style=\"font-size:14px;color:#888\"></span></h1>\r\n<div id=\"lock\" class=\"card\">\r\n <p><b>输入主口令解锁</b>(口令只在本地内存派生,不落盘、不发送远端)。忘口令可在 CLI 用恢复钥匙重设:<code>yotta-memory reset-password --recovery-key <钥匙></code></p>\r\n <input type=\"password\" id=\"pw\" placeholder=\"主口令\" style=\"width:260px\">\r\n <button onclick=\"unlock()\">解锁</button>\r\n <div class=\"err\" id=\"lockerr\"></div>\r\n</div>\r\n<div id=\"app\">\r\n <div class=\"card\">\r\n <b>AI 列表</b>(✅=已授权可读自己私密,🔒=未授权)\r\n <div class=\"meta\" style=\"margin-top:6px\">「授权」由你(用户)操作:确认后生成只显示一次的 agent_key,请立即单独保存;服务端同时写临时待领取文件 <code>keys/pending/<agent_id>.key</code>,供该 AI 新会话领取,领取成功后自动删除。</div>\r\n <div id=\"owners\" style=\"margin-top:8px\"></div>\r\n </div>\r\n <div class=\"card\">\r\n <b>记忆</b>\r\n <input id=\"q\" placeholder=\"搜索关键词\" style=\"width:220px\" onkeydown=\"if(event.key==='Enter'){off=0;load()}\">\r\n <button onclick=\"off=0;load()\">搜索</button>\r\n <button class=\"ghost\" onclick=\"doExport()\">导出 JSON</button>\r\n <button class=\"ghost\" onclick=\"showRk()\">显示恢复钥匙</button>\r\n <span id=\"rkout\" style=\"font-size:12px;color:#888;margin-left:8px\"></span>\r\n <div id=\"meta\" style=\"margin-top:10px;font-size:12px;color:#666\"></div>\r\n <div id=\"entries\" style=\"margin-top:6px\"></div>\r\n <div id=\"pager\" style=\"margin-top:10px\">\r\n <button class=\"ghost\" id=\"prevb\" onclick=\"prevPage()\">上一页</button>\r\n <span id=\"pageinfo\" style=\"font-size:12px;color:#888;margin:0 8px\"></span>\r\n <button class=\"ghost\" id=\"nextb\" onclick=\"nextPage()\">下一页</button>\r\n </div>\r\n </div>\r\n <div class=\"card\">\r\n <b>重设口令</b><br>\r\n <input type=\"password\" id=\"cur\" placeholder=\"当前口令\">\r\n <input type=\"password\" id=\"np1\" placeholder=\"新口令\">\r\n <input type=\"password\" id=\"np2\" placeholder=\"确认新口令\">\r\n <button onclick=\"resetPw()\">重设</button>\r\n <span id=\"pwout\"></span>\r\n </div>\r\n</div>\r\n<script>\r\nfunction esc(s){return String(s==null?'':s).replace(/[&<>\"']/g,function(c){return{'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c];});}\r\nasync function api(p,b){try{const r=await fetch(p,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b||{})});return await r.json();}catch(e){return{error:String(e)};}}\r\nasync function boot(){const s=await api('/api/status');document.getElementById('ver').textContent='v'+(s.version||'');if(s.unlocked){showApp();}}\r\nfunction showApp(){document.getElementById('lock').style.display='none';document.getElementById('app').style.display='block';loadOwners();load();}\r\nasync function unlock(){const d=await api('/api/unlock',{password:document.getElementById('pw').value});if(d.error){document.getElementById('lockerr').textContent=d.error;return;}showApp();}\r\nasync function loadOwners(){const d=await api('/api/owners');const box=document.getElementById('owners');box.innerHTML='';if(!d.owners||!d.owners.length){box.innerHTML=esc(d.hint||'(无 owner)');return;}\r\n for(const o of d.owners){const c=document.createElement('span');c.className='owner';c.innerHTML=esc(o.owner)+(o.authorized?' ✅':' 🔒')+' <button class=\"ghost\" data-a=\"'+esc(o.owner)+'\">授权</button><button class=\"danger\" data-r=\"'+esc(o.owner)+'\">吊销</button><button class=\"danger\" data-d=\"'+esc(o.owner)+'\">删除</button>';box.appendChild(c);}\n box.querySelectorAll('[data-a]').forEach(function(b){b.onclick=function(){var owner=b.getAttribute('data-a');if(!confirm('确认由你为用户授权 '+owner+' 读取其私密记忆?授权后将生成只显示一次的 agent_key,请立即保存;同时写入待领取文件供该 AI 新会话领取。AI 不应代为执行该授权操作。'))return;b.disabled=true;api('/api/authorize',{owner:owner}).then(function(d){b.disabled=false;if(!d||d.error){alert((d&&d.error)||'授权失败');loadOwners();return;}if(d.agentKey){showKey(d.agentKey);}loadOwners();});};});\r\n box.querySelectorAll('[data-r]').forEach(function(b){b.onclick=function(){if(!confirm('确认吊销 '+b.getAttribute('data-r')+' 的 agent_key?吊销后该智能体立即失去私密读写能力。'))return;api('/api/revoke',{owner:b.getAttribute('data-r')}).then(function(){loadOwners();});};});\n box.querySelectorAll('[data-d]').forEach(function(b){b.onclick=function(){var owner=b.getAttribute('data-d');var typed=prompt('彻底删除 '+owner+' 的身份与私密记忆(不可恢复;公共明文保留,其它 AI 不受影响)。请输入完整 ID 确认:');if(typed!==owner)return;b.disabled=true;api('/api/identity-remove',{owner:owner,confirm:typed}).then(function(d){b.disabled=false;if(!d||d.error){alert((d&&d.error)||'删除失败');loadOwners();return;}alert(d.text||'已删除');loadOwners();});};});\nfunction showKey(k){var ov=document.createElement('div');ov.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;z-index:99';var box=document.createElement('div');box.className='card';box.style.cssText='max-width:640px;word-break:break-all';var t=document.createElement('div');t.innerHTML='<b>agent_key(只显示一次)</b>';var hint=document.createElement('div');hint.className='meta';hint.textContent='请用户立即单独保存。AI 新会话先执行 yotta-memory key status <agent_id>,有 pending 再执行 key claim <agent_id>;默认写入 AI_HOME/.yotta-memory-agent-key,需要时用 --to 或 --agent-key-file 指定。若 key 丢失,可吊销后重新授权;旧 key 会立即校验失败。';var ta=document.createElement('textarea');ta.readOnly=true;ta.value=k;ta.style.cssText='width:100%;height:72px;margin-top:8px;font-family:monospace;font-size:12px';var close=document.createElement('button');close.textContent='我已保存,关闭';close.onclick=function(){ov.remove();};box.appendChild(t);box.appendChild(hint);box.appendChild(ta);box.appendChild(close);ov.appendChild(box);document.body.appendChild(ov);ta.focus();ta.select();}\r\n}\r\nlet off=0,PS=50;\r\nasync function load(){const d=await api('/api/entries',{query:document.getElementById('q').value,offset:off,limit:PS});const meta=document.getElementById('meta');const pg=document.getElementById('pageinfo');if(meta)meta.textContent='共 '+d.count+' 条';const lim=d.limit||PS;const totalPg=Math.max(1,Math.ceil(d.count/lim));const curPg=Math.floor((d.offset||0)/lim)+1;if(pg)pg.textContent='第 '+curPg+' / '+totalPg+' 页';const box=document.getElementById('entries');box.innerHTML='';if(d.entries)for(const e of d.entries){const div=document.createElement('div');div.className='entry';div.innerHTML='<b>['+esc(e.type)+'] '+esc(e.subject)+'</b><div>'+esc(e.statement)+'</div><div class=\"meta\">'+esc(e.file)+' · owner='+esc(e.owner||'-')+' · '+esc(e.updated||e.created||'')+'</div>';box.appendChild(div);}const pb=document.getElementById('prevb'),nb=document.getElementById('nextb');if(pb)pb.disabled=(d.offset||0)<=0;if(nb)nb.disabled=!d.hasMore;}\r\nfunction prevPage(){if(off>=PS){off-=PS;load();}}\r\nfunction nextPage(){off+=PS;load();}\r\nasync function doExport(){const d=await api('/api/export');if(d.error){alert(d.error);return;}const blob=new Blob([JSON.stringify(d,null,2)],{type:'application/json'});const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='yottamemory-view-export.json';a.click();}\r\nasync function showRk(){const d=await api('/api/recovery-key');document.getElementById('rkout').textContent=d.recoveryKey?('恢复钥匙: '+d.recoveryKey):(d.error||'');}\r\nasync function resetPw(){const np1=document.getElementById('np1').value,np2=document.getElementById('np2').value;if(np1!==np2){document.getElementById('pwout').innerHTML='<span class=\"err\">两次新口令不一致</span>';return;}\r\n const d=await api('/api/reset-password',{currentPassword:document.getElementById('cur').value,newPassword:np1});document.getElementById('pwout').innerHTML=d.error?('<span class=\"err\">'+esc(d.error)+'</span>'):('<span class=\"ok\">'+esc(d.text||'ok')+'</span>');}\r\nboot();\r\n</script></body></html>\r\n";
|
|
31
290
|
// @generated view-html:end
|
|
32
291
|
// MCP 协议(2026-07-28 无状态 + 2025-11-25 legacy 握手,dual-era)
|
|
33
292
|
const MCP_PROTOCOL_MODERN = '2026-07-28';
|
|
@@ -581,7 +840,8 @@ function runtimeRestartManagedServer() {
|
|
|
581
840
|
}
|
|
582
841
|
return { error: true, text: '未检测到可管理的 server 自启配置,未执行重启。' };
|
|
583
842
|
}
|
|
584
|
-
function today() {
|
|
843
|
+
function today(override) {
|
|
844
|
+
if (override && /^\d{4}-\d{2}-\d{2}$/.test(String(override))) return String(override);
|
|
585
845
|
const d = new Date();
|
|
586
846
|
const p = (n) => String(n).padStart(2, '0');
|
|
587
847
|
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate());
|
|
@@ -695,6 +955,40 @@ function typeSubdir(type, owner) {
|
|
|
695
955
|
const id = owner || '';
|
|
696
956
|
return path.join(PRIVATE_DIR, id, TYPE_DIRS[t]);
|
|
697
957
|
}
|
|
958
|
+
// v0.17.0 规模:新写入按 <yyyy>/<mm> 分层(公共 facts/<yyyy>/<mm>/,私密 private/<owner>/<type>/<yyyy>/<mm>/)。
|
|
959
|
+
// 只影响新写入;旧平铺文件保持原位继续可读,不自动迁移。
|
|
960
|
+
function dateSubdir(dateStr) {
|
|
961
|
+
const d = String(dateStr || '');
|
|
962
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) return '';
|
|
963
|
+
return path.join(d.slice(0, 4), d.slice(5, 7));
|
|
964
|
+
}
|
|
965
|
+
function entryWriteDir(root, type, owner, dateStr) {
|
|
966
|
+
const base = path.join(root, typeSubdir(type, owner));
|
|
967
|
+
const sub = dateSubdir(dateStr);
|
|
968
|
+
return sub ? path.join(base, sub) : base;
|
|
969
|
+
}
|
|
970
|
+
// 归档保留年/月分层(不同月份的同名文件不再互相覆盖);旧平铺文件仍按原样落到归档根。
|
|
971
|
+
const MEMORY_FILE_RE = /\.md(\.enc)?$/;
|
|
972
|
+
function entryRelParts(rel) {
|
|
973
|
+
const seg = String(rel || '').replace(/\\/g, '/').split('/').filter(Boolean);
|
|
974
|
+
if (seg[0] === PRIVATE_DIR) return { kind: 'private', owner: seg[1] || '', type: seg[2] || '' };
|
|
975
|
+
if (seg[0] === PUBLIC_DIR) return { kind: 'public', owner: '', type: 'facts' };
|
|
976
|
+
return null;
|
|
977
|
+
}
|
|
978
|
+
function archiveRelFor(root, rel, type, owner) {
|
|
979
|
+
const parts = entryRelParts(rel);
|
|
980
|
+
const t = String(type || (parts && parts.type) || 'FACT').toUpperCase();
|
|
981
|
+
const normalized = String(rel || '').replace(/\\/g, '/');
|
|
982
|
+
const seg = normalized.split('/');
|
|
983
|
+
const idx = seg.findIndex(function (s) { return /^\d{4}$/.test(s); });
|
|
984
|
+
const sub = (idx >= 0 && /^\d{2}$/.test(seg[idx + 1] || '')) ? seg[idx] + '/' + seg[idx + 1] : '';
|
|
985
|
+
const baseName = path.basename(normalized);
|
|
986
|
+
if (t === 'FACT' || (parts && parts.kind === 'public')) {
|
|
987
|
+
return path.posix.join(ARCHIVE_DIR, 'facts', sub, baseName);
|
|
988
|
+
}
|
|
989
|
+
const o = owner || (parts && parts.owner) || '';
|
|
990
|
+
return path.posix.join(ARCHIVE_DIR, PRIVATE_DIR, o, TYPE_DIRS[t] || 'facts', sub, baseName);
|
|
991
|
+
}
|
|
698
992
|
function defaultScope(type) {
|
|
699
993
|
return PRIVATE_TYPES.indexOf(String(type).toUpperCase()) === -1 ? 'public' : 'private';
|
|
700
994
|
}
|
|
@@ -1094,19 +1388,62 @@ function ensureInit(root) {
|
|
|
1094
1388
|
fs.mkdirSync(path.join(root, ARCHIVE_DIR), { recursive: true });
|
|
1095
1389
|
const readme = path.join(root, 'README.md');
|
|
1096
1390
|
if (!fs.existsSync(readme)) {
|
|
1097
|
-
fs.writeFileSync(readme, '# yotta-memory(元忆)记忆库\n\n有权限边界的文件式智能体记忆存储目录。结构:facts
|
|
1391
|
+
fs.writeFileSync(readme, '# yotta-memory(元忆)记忆库\n\n有权限边界的文件式智能体记忆存储目录。结构:facts/<年>/<月>/(公共 FACT) private/<agent_id>/{prefs,bounds,commits}/<年>/<月>/(各智能体私密,物理隔离) .archive/(归档)。v0.16 及更早的平铺文件(facts/*.md、private/<agent_id>/<type>/*.md)继续可读,不做自动迁移。\n', 'utf8');
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
// 递归收集目录下(含年/月子目录)的记忆文件路径,跳过 distills/profile.md 等非记忆文件。
|
|
1395
|
+
function walkEntryFiles(dir, out) {
|
|
1396
|
+
let names;
|
|
1397
|
+
try { names = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return; }
|
|
1398
|
+
for (const entry of names) {
|
|
1399
|
+
const fp = path.join(dir, entry.name);
|
|
1400
|
+
if (entry.isDirectory()) { walkEntryFiles(fp, out); continue; }
|
|
1401
|
+
if (!entry.isFile()) continue;
|
|
1402
|
+
if (!MEMORY_FILE_RE.test(entry.name)) continue;
|
|
1403
|
+
out.push(fp);
|
|
1098
1404
|
}
|
|
1099
1405
|
}
|
|
1100
|
-
|
|
1406
|
+
// 序号在「类型目录 + owner」范围内唯一,跨越旧平铺与新年/月分层,避免同名覆盖。
|
|
1407
|
+
function maxSeqInTree(dir) {
|
|
1101
1408
|
let max = 0;
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
}
|
|
1409
|
+
const files = [];
|
|
1410
|
+
if (fs.existsSync(dir)) walkEntryFiles(dir, files);
|
|
1411
|
+
for (const fp of files) {
|
|
1412
|
+
const f = path.basename(fp);
|
|
1413
|
+
const m = f.match(/^\d{4}-\d{2}-\d{2}-(\d{4})\.md(\.enc)?$/);
|
|
1414
|
+
if (m) max = Math.max(max, parseInt(m[1], 10));
|
|
1107
1415
|
}
|
|
1108
1416
|
return String(max + 1).padStart(4, '0');
|
|
1109
1417
|
}
|
|
1418
|
+
function nextSeq(dir) { return maxSeqInTree(dir); }
|
|
1419
|
+
// 分层写入时序号要跨该类型全部分层计算(否则同一年不同月份会重复 0001)。
|
|
1420
|
+
function nextSeqFor(typeDir, writeDir) {
|
|
1421
|
+
return maxSeqInTree(typeDir || writeDir);
|
|
1422
|
+
}
|
|
1423
|
+
// 去重只看「最近写入窗口」:当前年/月子目录 + 平铺根,避免大库每次写入都全量扫一遍。
|
|
1424
|
+
function recentlyWrittenFiles(typeDir, writeDir) {
|
|
1425
|
+
const out = [];
|
|
1426
|
+
if (path.resolve(typeDir) === path.resolve(writeDir)) {
|
|
1427
|
+
if (fs.existsSync(typeDir)) walkEntryFiles(typeDir, out);
|
|
1428
|
+
return out;
|
|
1429
|
+
}
|
|
1430
|
+
if (fs.existsSync(writeDir)) walkEntryFiles(writeDir, out);
|
|
1431
|
+
const yearDir = path.dirname(writeDir);
|
|
1432
|
+
const seen = new Set(out);
|
|
1433
|
+
if (fs.existsSync(yearDir)) {
|
|
1434
|
+
for (const fp of fs.readdirSync(yearDir)) {
|
|
1435
|
+
const full = path.join(yearDir, fp);
|
|
1436
|
+
if (fs.statSync(full).isFile() && MEMORY_FILE_RE.test(fp) && !seen.has(full)) out.push(full);
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
if (fs.existsSync(typeDir)) {
|
|
1440
|
+
for (const f of fs.readdirSync(typeDir)) {
|
|
1441
|
+
const full = path.join(typeDir, f);
|
|
1442
|
+
if (fs.statSync(full).isFile() && MEMORY_FILE_RE.test(f) && !seen.has(full)) out.push(full);
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
return out;
|
|
1446
|
+
}
|
|
1110
1447
|
|
|
1111
1448
|
|
|
1112
1449
|
// ================= v0.7.0 加密(AES-256-GCM 信封加密 + PBKDF2 主密钥 + 恢复钥匙)=================
|
|
@@ -1559,6 +1896,24 @@ const INDEX_SHARD_THRESHOLD = 5000;
|
|
|
1559
1896
|
const SHARD_RE = /^index-\d{4}\.json$/;
|
|
1560
1897
|
function isSafeShardName(name) { return typeof name === 'string' && SHARD_RE.test(name); }
|
|
1561
1898
|
function indexShardName(year) { return 'index-' + year + '.json'; }
|
|
1899
|
+
function shardYear(name) { return String(name).slice('index-'.length, -'.json'.length); }
|
|
1900
|
+
function entryYear(entry) {
|
|
1901
|
+
return String((entry && entry.created) || '').slice(0, 4) || String(today()).slice(0, 4);
|
|
1902
|
+
}
|
|
1903
|
+
// v0.17.0:--year 只接受四位年份,可重复;不传即全量(既有行为零变化)。
|
|
1904
|
+
function normalizeYearList(value) {
|
|
1905
|
+
if (value === undefined || value === null) return { years: [], error: '' };
|
|
1906
|
+
const list = Array.isArray(value) ? value : [value];
|
|
1907
|
+
const years = [];
|
|
1908
|
+
for (const raw of list) {
|
|
1909
|
+
const year = String(raw === undefined || raw === null ? '' : raw).trim();
|
|
1910
|
+
if (!/^\d{4}$/.test(year)) {
|
|
1911
|
+
return { years: [], error: '年份格式不正确: ' + String(raw) + '。--year 只接受四位年份(例如 --year 2026),可重复传多次。' };
|
|
1912
|
+
}
|
|
1913
|
+
if (years.indexOf(year) === -1) years.push(year);
|
|
1914
|
+
}
|
|
1915
|
+
return { years: years, error: '' };
|
|
1916
|
+
}
|
|
1562
1917
|
function loadIndexManifest(root) {
|
|
1563
1918
|
const p = indexPath(root);
|
|
1564
1919
|
if (!fs.existsSync(p)) return null;
|
|
@@ -1595,6 +1950,52 @@ function loadIndex(root) {
|
|
|
1595
1950
|
} catch (e) { /* ignore */ }
|
|
1596
1951
|
return null;
|
|
1597
1952
|
}
|
|
1953
|
+
// v0.17.0 B3:按年份懒加载索引——命中分片 manifest 时只读 years 覆盖的分片;
|
|
1954
|
+
// years 为空维持全量 loadIndex(逐字节行为不变)。平铺索引无法少读文件,按条目年份过滤。
|
|
1955
|
+
function loadIndexFor(root, options) {
|
|
1956
|
+
const filter = normalizeYearList(options && (options.years !== undefined ? options.years : options.year));
|
|
1957
|
+
if (filter.error) return null;
|
|
1958
|
+
const years = filter.years;
|
|
1959
|
+
if (!years.length) return loadIndex(root);
|
|
1960
|
+
const p = indexPath(root);
|
|
1961
|
+
if (!fs.existsSync(p)) return null;
|
|
1962
|
+
try {
|
|
1963
|
+
const d = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
1964
|
+
if (!d) return null;
|
|
1965
|
+
if (Array.isArray(d.entries)) {
|
|
1966
|
+
if (d.version && d.version < INDEX_VERSION) return null;
|
|
1967
|
+
const want = new Set(years);
|
|
1968
|
+
return d.entries.filter(function (e) { return want.has(entryYear(e)); });
|
|
1969
|
+
}
|
|
1970
|
+
if (Array.isArray(d.shards)) {
|
|
1971
|
+
if (d.version && d.version < INDEX_VERSION) return null;
|
|
1972
|
+
const want = new Set(years);
|
|
1973
|
+
const out = [];
|
|
1974
|
+
for (const sh of d.shards) {
|
|
1975
|
+
if (!isSafeShardName(sh)) continue;
|
|
1976
|
+
if (!want.has(shardYear(sh))) continue; // 只读取命中年份的分片文件
|
|
1977
|
+
const sp = path.join(root, sh);
|
|
1978
|
+
if (!fs.existsSync(sp)) continue;
|
|
1979
|
+
const sd = JSON.parse(fs.readFileSync(sp, 'utf8'));
|
|
1980
|
+
if (sd && Array.isArray(sd.entries)) out.push.apply(out, sd.entries);
|
|
1981
|
+
}
|
|
1982
|
+
return out;
|
|
1983
|
+
}
|
|
1984
|
+
} catch (e) { /* ignore */ }
|
|
1985
|
+
return null;
|
|
1986
|
+
}
|
|
1987
|
+
function indexEntriesFor(root, options) {
|
|
1988
|
+
const filter = normalizeYearList(options && options.years);
|
|
1989
|
+
if (filter.error) return { entries: [], error: filter.error };
|
|
1990
|
+
if (filter.years.length) {
|
|
1991
|
+
const lazy = loadIndexFor(root, { years: filter.years });
|
|
1992
|
+
if (lazy) return { entries: lazy, error: '' };
|
|
1993
|
+
}
|
|
1994
|
+
const all = ensureIndex(root);
|
|
1995
|
+
if (!filter.years.length) return { entries: all, error: '' };
|
|
1996
|
+
const want = new Set(filter.years);
|
|
1997
|
+
return { entries: all.filter(function (e) { return want.has(entryYear(e)); }), error: '' };
|
|
1998
|
+
}
|
|
1598
1999
|
function getIndex(root) { return loadIndex(root) || []; }
|
|
1599
2000
|
function saveIndex(root, entries) {
|
|
1600
2001
|
const clean = entries.map(function (e) { const c = Object.assign({}, e); delete c.meta; return c; });
|
|
@@ -1602,7 +2003,7 @@ function saveIndex(root, entries) {
|
|
|
1602
2003
|
if (clean.length > INDEX_SHARD_THRESHOLD) {
|
|
1603
2004
|
const byYear = {};
|
|
1604
2005
|
for (const e of clean) {
|
|
1605
|
-
const y =
|
|
2006
|
+
const y = entryYear(e);
|
|
1606
2007
|
(byYear[y] = byYear[y] || []).push(e);
|
|
1607
2008
|
}
|
|
1608
2009
|
const newShards = [];
|
|
@@ -1638,14 +2039,102 @@ function collectEntryFiles(root) {
|
|
|
1638
2039
|
}
|
|
1639
2040
|
}
|
|
1640
2041
|
const out = [];
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
2042
|
+
// v0.17.0:递归下探年/月子目录;旧平铺文件依旧按原路径收集。
|
|
2043
|
+
for (const dir of dirs) walkEntryFiles(dir, out);
|
|
2044
|
+
return out;
|
|
2045
|
+
}
|
|
2046
|
+
// v0.17.0:平铺 / 分层只影响存放位置,不改变条目身份。
|
|
2047
|
+
// 身份键 = 类型 + owner + 文件名(YYYY-MM-DD-NNNN.md),同身份先比较内容摘要。
|
|
2048
|
+
const LAYOUT_TYPE_BY_DIR = { facts: 'FACT', prefs: 'PREF', bounds: 'BOUND', commits: 'COMMIT' };
|
|
2049
|
+
function publicFrontmatterOwner(fp) {
|
|
2050
|
+
try {
|
|
2051
|
+
return String(parseFrontmatter(fs.readFileSync(fp, 'utf8')).meta.owner || '').trim();
|
|
2052
|
+
} catch (e) {
|
|
2053
|
+
return '';
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
function layoutEntryIdentity(root, fp) {
|
|
2057
|
+
const rel = path.relative(root, fp).replace(/\\/g, '/');
|
|
2058
|
+
const parts = entryRelParts(rel);
|
|
2059
|
+
if (!parts) return null;
|
|
2060
|
+
const type = LAYOUT_TYPE_BY_DIR[parts.type];
|
|
2061
|
+
if (!type) return null;
|
|
2062
|
+
const base = path.basename(rel).replace(/\.enc$/, '');
|
|
2063
|
+
if (!/^\d{4}-\d{2}-\d{2}-\d{4}\.md$/.test(base)) return null;
|
|
2064
|
+
const owner = parts.kind === 'public' ? publicFrontmatterOwner(fp) : parts.owner;
|
|
2065
|
+
return {
|
|
2066
|
+
key: [parts.kind, owner, type, base].join('\0'),
|
|
2067
|
+
rel: rel,
|
|
2068
|
+
owner: owner,
|
|
2069
|
+
};
|
|
2070
|
+
}
|
|
2071
|
+
function normalizedLayoutDigest(buf) {
|
|
2072
|
+
// latin1 往返保留原始字节,同时把 CRLF 归一,避免 Windows / Linux 检出差异制造假冲突。
|
|
2073
|
+
const normalized = Buffer.from(buf.toString('latin1').replace(/\r\n/g, '\n'), 'latin1');
|
|
2074
|
+
return crypto.createHash('sha256').update(normalized).digest('hex');
|
|
2075
|
+
}
|
|
2076
|
+
function layoutFileDigests(root, fp, owner) {
|
|
2077
|
+
const buf = fs.readFileSync(fp);
|
|
2078
|
+
const rawDigest = normalizedLayoutDigest(buf);
|
|
2079
|
+
if (!isEncFile(fp)) return { rawDigest: rawDigest, plainDigest: rawDigest };
|
|
2080
|
+
const key = getOwnerKeyFor(root, owner);
|
|
2081
|
+
if (key) {
|
|
2082
|
+
try {
|
|
2083
|
+
return { rawDigest: rawDigest, plainDigest: normalizedLayoutDigest(Buffer.from(decryptMemoryText(buf, key), 'utf8')) };
|
|
2084
|
+
} catch (e) { /* 密钥不可用或密文损坏时按待核处理 */ }
|
|
2085
|
+
}
|
|
2086
|
+
return { rawDigest: rawDigest, plainDigest: null };
|
|
2087
|
+
}
|
|
2088
|
+
function layoutCollisionReport(root) {
|
|
2089
|
+
const grouped = new Map();
|
|
2090
|
+
for (const fp of collectEntryFiles(root)) {
|
|
2091
|
+
const info = layoutEntryIdentity(root, fp);
|
|
2092
|
+
if (!info) continue;
|
|
2093
|
+
if (!grouped.has(info.key)) grouped.set(info.key, []);
|
|
2094
|
+
grouped.get(info.key).push({ fp: fp, rel: info.rel, owner: info.owner });
|
|
2095
|
+
}
|
|
2096
|
+
const report = { ok: true, duplicates: 0, conflicts: 0, unverified: 0, groups: [], warnings: [] };
|
|
2097
|
+
for (const items of grouped.values()) {
|
|
2098
|
+
if (items.length < 2) continue;
|
|
2099
|
+
for (const item of items) {
|
|
2100
|
+
try {
|
|
2101
|
+
const digests = layoutFileDigests(root, item.fp, item.owner);
|
|
2102
|
+
item.rawDigest = digests.rawDigest;
|
|
2103
|
+
item.plainDigest = digests.plainDigest;
|
|
2104
|
+
} catch (e) {
|
|
2105
|
+
item.rawDigest = null;
|
|
2106
|
+
item.plainDigest = null;
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
const plainDigests = items.map(function (item) { return item.plainDigest; });
|
|
2110
|
+
const rawDigests = items.map(function (item) { return item.rawDigest; });
|
|
2111
|
+
let status;
|
|
2112
|
+
if (plainDigests.every(Boolean)) status = new Set(plainDigests).size === 1 ? 'duplicate' : 'conflict';
|
|
2113
|
+
else if (rawDigests.every(Boolean) && new Set(rawDigests).size === 1) status = 'duplicate';
|
|
2114
|
+
else status = 'unverified';
|
|
2115
|
+
const sorted = items.slice().sort(function (a, b) {
|
|
2116
|
+
const depth = b.rel.split('/').length - a.rel.split('/').length;
|
|
2117
|
+
return depth !== 0 ? depth : a.rel.localeCompare(b.rel);
|
|
2118
|
+
});
|
|
2119
|
+
const paths = items.map(function (item) { return item.rel; }).sort();
|
|
2120
|
+
const group = {
|
|
2121
|
+
status: status,
|
|
2122
|
+
paths: paths,
|
|
2123
|
+
keep: sorted[0].rel,
|
|
2124
|
+
drop: sorted.slice(1).map(function (item) { return item.rel; }),
|
|
2125
|
+
};
|
|
2126
|
+
report.groups.push(group);
|
|
2127
|
+
if (status === 'duplicate') report.duplicates++;
|
|
2128
|
+
else if (status === 'conflict') {
|
|
2129
|
+
report.conflicts++;
|
|
2130
|
+
report.warnings.push('布局: 平铺/分层同序号冲突(内容不同,两份均保留可读): ' + paths.join(' ↔ '));
|
|
2131
|
+
} else {
|
|
2132
|
+
report.unverified++;
|
|
2133
|
+
report.warnings.push('布局: 平铺/分层同序号待核(加密内容当前无法比较,两份均保留): ' + paths.join(' ↔ '));
|
|
1646
2134
|
}
|
|
1647
2135
|
}
|
|
1648
|
-
|
|
2136
|
+
report.ok = report.conflicts === 0 && report.unverified === 0;
|
|
2137
|
+
return report;
|
|
1649
2138
|
}
|
|
1650
2139
|
// 迁移:把根下旧平铺 prefs|bounds|commits/*.md 按 frontmatter owner 迁入 private/<owner>/<type>/
|
|
1651
2140
|
function migrateLayout(root) {
|
|
@@ -1669,10 +2158,17 @@ function migrateLayout(root) {
|
|
|
1669
2158
|
}
|
|
1670
2159
|
function buildIndex(root) {
|
|
1671
2160
|
migrateLayout(root);
|
|
2161
|
+
const layout = layoutCollisionReport(root);
|
|
2162
|
+
const skip = new Set();
|
|
2163
|
+
for (const group of layout.groups) {
|
|
2164
|
+
if (group.status !== 'duplicate') continue;
|
|
2165
|
+
for (const rel of group.drop) skip.add(rel);
|
|
2166
|
+
}
|
|
1672
2167
|
const publicEntries = [];
|
|
1673
2168
|
const privateByOwner = {};
|
|
1674
2169
|
for (const fp of collectEntryFiles(root)) {
|
|
1675
2170
|
const rel = path.relative(root, fp).replace(/\\/g, '/');
|
|
2171
|
+
if (skip.has(rel)) continue;
|
|
1676
2172
|
if (rel.indexOf(PRIVATE_DIR + '/') === 0) {
|
|
1677
2173
|
const owner = rel.split('/')[1] || '';
|
|
1678
2174
|
if (!getOwnerKeyFor(root, owner)) continue;
|
|
@@ -1825,13 +2321,18 @@ function bumpReadMeta(root, relFiles) {
|
|
|
1825
2321
|
rewriteFrontmatter(fp, { access_count: acc, last_accessed: now }, root, owner);
|
|
1826
2322
|
}
|
|
1827
2323
|
}
|
|
1828
|
-
function touchIndex(root, relFiles) {
|
|
2324
|
+
function touchIndex(root, relFiles, options) {
|
|
1829
2325
|
const set = new Set(relFiles);
|
|
1830
2326
|
const now = today();
|
|
2327
|
+
// v0.17.0 B3:带 --year 的检索只碰命中年份的分片(命中条目必然来自这些分片),
|
|
2328
|
+
// 不传年份时行为与旧版一致(遍历全部 shards)。
|
|
2329
|
+
const filterYears = (options && Array.isArray(options.years)) ? options.years : [];
|
|
2330
|
+
const yearSet = filterYears.length ? new Set(filterYears) : null;
|
|
1831
2331
|
const manifest = loadIndexManifest(root);
|
|
1832
2332
|
if (manifest && Array.isArray(manifest.shards)) {
|
|
1833
2333
|
for (const sh of manifest.shards) {
|
|
1834
2334
|
if (!isSafeShardName(sh)) continue;
|
|
2335
|
+
if (yearSet && !yearSet.has(shardYear(sh))) continue;
|
|
1835
2336
|
const sp = path.join(root, sh);
|
|
1836
2337
|
if (!fs.existsSync(sp)) continue;
|
|
1837
2338
|
let sd = null; try { sd = JSON.parse(fs.readFileSync(sp, 'utf8')); } catch (e) { continue; }
|
|
@@ -2966,32 +3467,92 @@ function runtimeDoctorCore(opts) {
|
|
|
2966
3467
|
};
|
|
2967
3468
|
}
|
|
2968
3469
|
function storeHasMemoryData(root) {
|
|
2969
|
-
|
|
2970
|
-
if (fs.existsSync(factsDir)) {
|
|
2971
|
-
try {
|
|
2972
|
-
if (fs.readdirSync(factsDir).some(function (f) { return /\.md(\.enc)?$/.test(f); })) return true;
|
|
2973
|
-
} catch (e) {}
|
|
2974
|
-
}
|
|
3470
|
+
if (storeHasPublicFacts(root)) return true;
|
|
2975
3471
|
if (hasPlaintextPrivate(root)) return true;
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
const dir = path.join(root, PRIVATE_DIR, owner, t);
|
|
2979
|
-
if (!fs.existsSync(dir)) continue;
|
|
2980
|
-
try {
|
|
2981
|
-
if (fs.readdirSync(dir).some(function (f) { return f.endsWith(ENC_SUFFIX); })) return true;
|
|
2982
|
-
} catch (e) {}
|
|
2983
|
-
}
|
|
2984
|
-
}
|
|
3472
|
+
// v0.17.0:年/月分层后必须递归统计,否则新布局会被误判为空库。
|
|
3473
|
+
if (collectEntryFiles(root).length) return true;
|
|
2985
3474
|
return false;
|
|
2986
3475
|
}
|
|
2987
3476
|
function storeHasPublicFacts(root) {
|
|
2988
3477
|
const factsDir = path.join(root, PUBLIC_DIR);
|
|
2989
3478
|
if (!fs.existsSync(factsDir)) return false;
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
3479
|
+
const files = [];
|
|
3480
|
+
walkEntryFiles(factsDir, files);
|
|
3481
|
+
return files.length > 0;
|
|
3482
|
+
}
|
|
3483
|
+
// ---- v0.17.0 B2:doctor 规模体检(只读;阈值走 config,info / warning 两级)----
|
|
3484
|
+
const SCALE_THRESHOLD_DEFAULTS = {
|
|
3485
|
+
scale_warn_entries: 50000,
|
|
3486
|
+
scale_warn_files_per_dir: 500,
|
|
3487
|
+
scale_warn_index_bytes: 5 * 1024 * 1024,
|
|
3488
|
+
scale_warn_cold_start_ms: 2000,
|
|
3489
|
+
};
|
|
3490
|
+
function scaleThreshold(cfg, key) {
|
|
3491
|
+
const raw = cfg ? cfg[key] : undefined;
|
|
3492
|
+
if (raw === undefined || raw === null || raw === '') return SCALE_THRESHOLD_DEFAULTS[key];
|
|
3493
|
+
const n = parseFloat(raw);
|
|
3494
|
+
return (n >= 0) ? n : SCALE_THRESHOLD_DEFAULTS[key];
|
|
3495
|
+
}
|
|
3496
|
+
function maxFilesInDir(dir) {
|
|
3497
|
+
let max = 0;
|
|
3498
|
+
let names;
|
|
3499
|
+
try { names = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return 0; }
|
|
3500
|
+
let files = 0;
|
|
3501
|
+
for (const item of names) {
|
|
3502
|
+
if (item.isDirectory()) max = Math.max(max, maxFilesInDir(path.join(dir, item.name)));
|
|
3503
|
+
else if (item.isFile()) files++;
|
|
3504
|
+
}
|
|
3505
|
+
return Math.max(max, files);
|
|
3506
|
+
}
|
|
3507
|
+
function indexSizeBytes(root) {
|
|
3508
|
+
let total = 0;
|
|
3509
|
+
const manifest = indexPath(root);
|
|
3510
|
+
try { total += fs.statSync(manifest).size; } catch (e) { /* 尚未建索引 */ }
|
|
3511
|
+
for (const sh of (loadIndexManifest(root) || { shards: [] }).shards || []) {
|
|
3512
|
+
if (!isSafeShardName(sh)) continue;
|
|
3513
|
+
try { total += fs.statSync(path.join(root, sh)).size; } catch (e) { /* 分片缺失已由 doctor 其它段报出 */ }
|
|
3514
|
+
}
|
|
3515
|
+
return total;
|
|
3516
|
+
}
|
|
3517
|
+
function measureIndexColdStart(root) {
|
|
3518
|
+
const started = process.hrtime.bigint();
|
|
3519
|
+
loadIndex(root);
|
|
3520
|
+
const elapsed = Number(process.hrtime.bigint() - started) / 1e6;
|
|
3521
|
+
return Math.round(elapsed * 1000) / 1000;
|
|
3522
|
+
}
|
|
3523
|
+
function scaleReport(root, cfg) {
|
|
3524
|
+
const thresholds = {};
|
|
3525
|
+
for (const key of Object.keys(SCALE_THRESHOLD_DEFAULTS)) thresholds[key] = scaleThreshold(cfg, key);
|
|
3526
|
+
const entries = collectEntryFiles(root).length;
|
|
3527
|
+
const filesPerDir = Math.max(maxFilesInDir(path.join(root, PUBLIC_DIR)), maxFilesInDir(path.join(root, PRIVATE_DIR)));
|
|
3528
|
+
const indexBytes = indexSizeBytes(root);
|
|
3529
|
+
// 冷启动取多次测量的中位数,避免单次抖动影响只读体检结果。
|
|
3530
|
+
const samples = [];
|
|
3531
|
+
for (let i = 0; i < 3; i++) samples.push(measureIndexColdStart(root));
|
|
3532
|
+
samples.sort(function (a, b) { return a - b; });
|
|
3533
|
+
const coldStartMs = samples[Math.floor(samples.length / 2)];
|
|
3534
|
+
const warnings = [];
|
|
3535
|
+
if (entries > thresholds.scale_warn_entries) {
|
|
3536
|
+
warnings.push('规模: 记忆条数 ' + entries + ' 超过阈值 ' + thresholds.scale_warn_entries + '(考虑按年归档旧条目,或分批导出冷数据)');
|
|
2994
3537
|
}
|
|
3538
|
+
if (filesPerDir > thresholds.scale_warn_files_per_dir) {
|
|
3539
|
+
warnings.push('规模: 单个目录文件数 ' + filesPerDir + ' 超过阈值 ' + thresholds.scale_warn_files_per_dir + '(检查是否有目录未按年/月分层)');
|
|
3540
|
+
}
|
|
3541
|
+
if (indexBytes > thresholds.scale_warn_index_bytes) {
|
|
3542
|
+
warnings.push('规模: 索引总体积 ' + indexBytes + ' 字节超过阈值 ' + thresholds.scale_warn_index_bytes + '(可用 reindex 重建,或按年份分片)');
|
|
3543
|
+
}
|
|
3544
|
+
if (coldStartMs > thresholds.scale_warn_cold_start_ms) {
|
|
3545
|
+
warnings.push('规模: 索引冷启动 ' + coldStartMs + 'ms 超过阈值 ' + thresholds.scale_warn_cold_start_ms + 'ms(可用 reindex 重建索引)');
|
|
3546
|
+
}
|
|
3547
|
+
return {
|
|
3548
|
+
entries: entries,
|
|
3549
|
+
files_per_dir: filesPerDir,
|
|
3550
|
+
index_bytes: indexBytes,
|
|
3551
|
+
cold_start_ms: coldStartMs,
|
|
3552
|
+
thresholds: thresholds,
|
|
3553
|
+
level: warnings.length ? 'warning' : 'ok',
|
|
3554
|
+
warnings: warnings,
|
|
3555
|
+
};
|
|
2995
3556
|
}
|
|
2996
3557
|
// 开工可靠性检查:只读检查根目录、密钥库、索引、身份登记与最近备份。
|
|
2997
3558
|
function doctorCore(opts) {
|
|
@@ -3035,6 +3596,16 @@ function doctorCore(opts) {
|
|
|
3035
3596
|
checks.index = { exists: true, valid: true };
|
|
3036
3597
|
}
|
|
3037
3598
|
|
|
3599
|
+
// v0.17.0 B2:规模体检(只读,不产生写入)
|
|
3600
|
+
if (exists) {
|
|
3601
|
+
const scale = scaleReport(root, cfg);
|
|
3602
|
+
checks.scale = scale;
|
|
3603
|
+
for (const message of scale.warnings) warnings.push(message);
|
|
3604
|
+
const layout = layoutCollisionReport(root);
|
|
3605
|
+
checks.layout = layout;
|
|
3606
|
+
for (const message of layout.warnings) warnings.push(message);
|
|
3607
|
+
}
|
|
3608
|
+
|
|
3038
3609
|
if (!fs.existsSync(agentsPath(root))) {
|
|
3039
3610
|
checks.agents = { exists: false, valid: false };
|
|
3040
3611
|
if (hasData) warnings.push('agents.json 缺失;智能体身份登记不可用。');
|
|
@@ -3109,6 +3680,31 @@ function doctorCore(opts) {
|
|
|
3109
3680
|
for (const warning of runtimeReport.warnings || []) warnings.push(warning);
|
|
3110
3681
|
}
|
|
3111
3682
|
|
|
3683
|
+
// v0.17.0 A2:基线探针只在显式 --baseline 时运行;失败进 warnings + baselineError,并让 CLI 非零退出。
|
|
3684
|
+
let baselineReport = null;
|
|
3685
|
+
let baselineError = '';
|
|
3686
|
+
if (opts.baseline) {
|
|
3687
|
+
baselineReport = baselineCore({
|
|
3688
|
+
root: root,
|
|
3689
|
+
against: opts.against,
|
|
3690
|
+
template: opts.template,
|
|
3691
|
+
seed: opts.seed,
|
|
3692
|
+
agent: opts.agent,
|
|
3693
|
+
agentId: opts.agentId,
|
|
3694
|
+
agentKey: opts.agentKey,
|
|
3695
|
+
agentKeyFile: opts.agentKeyFile,
|
|
3696
|
+
});
|
|
3697
|
+
checks.baseline = baselineReport;
|
|
3698
|
+
if (baselineReport.error) {
|
|
3699
|
+
baselineError = baselineReport.text;
|
|
3700
|
+
warnings.push(baselineReport.text);
|
|
3701
|
+
} else {
|
|
3702
|
+
for (const probe of baselineReport.probes || []) {
|
|
3703
|
+
if (probe.status === 'fail') warnings.push('基线探针 [' + probe.id + '] 失败: ' + probe.detail);
|
|
3704
|
+
}
|
|
3705
|
+
}
|
|
3706
|
+
}
|
|
3707
|
+
|
|
3112
3708
|
const level = critical.length ? 'critical' : (warnings.length ? 'warning' : 'ok');
|
|
3113
3709
|
const agentHomeEnv = String(process.env.YOTTA_MEMORY_AGENT_HOME || '').trim();
|
|
3114
3710
|
const lines = [
|
|
@@ -3120,9 +3716,19 @@ function doctorCore(opts) {
|
|
|
3120
3716
|
'- agent home: ' + (agentHomeEnv || '(未设置 YOTTA_MEMORY_AGENT_HOME,按宿主默认检测)'),
|
|
3121
3717
|
'- 备份目录: ' + (configuredDir || (cfg.backup_setup_choice === 'manual' ? '手动模式' : '(未配置)')),
|
|
3122
3718
|
];
|
|
3719
|
+
if (checks.scale) {
|
|
3720
|
+
lines.push('- 规模: ' + checks.scale.entries + ' 条记忆 / 单目录最大 ' + checks.scale.files_per_dir + ' 个文件 / 索引 ' + checks.scale.index_bytes + ' 字节 / 冷启动 ' + checks.scale.cold_start_ms + 'ms(' + (checks.scale.level === 'warning' ? '有告警' : '正常') + ')');
|
|
3721
|
+
}
|
|
3722
|
+
if (checks.layout && (checks.layout.duplicates || checks.layout.conflicts || checks.layout.unverified)) {
|
|
3723
|
+
lines.push('- 布局: 同序号重复 ' + checks.layout.duplicates + ' 组 / 冲突 ' + checks.layout.conflicts + ' 组 / 待核 ' + checks.layout.unverified + ' 组');
|
|
3724
|
+
}
|
|
3123
3725
|
for (const message of critical) lines.push('- [严重] ' + message);
|
|
3124
3726
|
for (const message of warnings) lines.push('- [警告] ' + message);
|
|
3125
3727
|
if (!critical.length && !warnings.length) lines.push('- 检查项: 全部通过');
|
|
3728
|
+
if (baselineReport && !baselineReport.error) {
|
|
3729
|
+
lines.push('');
|
|
3730
|
+
lines.push(baselineReport.text);
|
|
3731
|
+
}
|
|
3126
3732
|
if (runtimeReport) {
|
|
3127
3733
|
lines.push('');
|
|
3128
3734
|
lines.push(runtimeReport.text);
|
|
@@ -3140,6 +3746,8 @@ function doctorCore(opts) {
|
|
|
3140
3746
|
migration_required: migration ? migration.entries : [],
|
|
3141
3747
|
critical: critical,
|
|
3142
3748
|
warnings: warnings,
|
|
3749
|
+
baseline: baselineReport,
|
|
3750
|
+
baselineError: baselineError,
|
|
3143
3751
|
checks: checks,
|
|
3144
3752
|
identity: identity,
|
|
3145
3753
|
root: root,
|
|
@@ -3455,19 +4063,46 @@ function backupDrillCore(opts) {
|
|
|
3455
4063
|
if (tempParent) {
|
|
3456
4064
|
try { fs.rmSync(tempParent, { recursive: true, force: true }); } catch (_) {}
|
|
3457
4065
|
}
|
|
3458
|
-
|
|
4066
|
+
// v0.17.0 A2:--probe 时在恢复副本上跑基线探针(探针只读;探针失败则整体演练失败,临时副本照旧清理)。
|
|
4067
|
+
let probeReport = null;
|
|
4068
|
+
if (opts.probe) {
|
|
4069
|
+
probeReport = baselineCore({
|
|
4070
|
+
root: target,
|
|
4071
|
+
against: opts.against,
|
|
4072
|
+
template: opts.template,
|
|
4073
|
+
seed: opts.seed,
|
|
4074
|
+
agent: opts.agent,
|
|
4075
|
+
agentId: opts.agentId,
|
|
4076
|
+
agentKey: opts.agentKey,
|
|
4077
|
+
agentKeyFile: opts.agentKeyFile,
|
|
4078
|
+
});
|
|
4079
|
+
if (probeReport.error || !probeReport.ok) {
|
|
4080
|
+
return fail('恢复演练失败: 基线探针未通过。\n' + probeReport.text, {
|
|
4081
|
+
manifest: true,
|
|
4082
|
+
index: true,
|
|
4083
|
+
private: privateCheck,
|
|
4084
|
+
baseline: probeReport,
|
|
4085
|
+
});
|
|
4086
|
+
}
|
|
4087
|
+
}
|
|
4088
|
+
const checks = { manifest: true, index: true, private: privateCheck, baseline: probeReport };
|
|
3459
4089
|
const lines = [
|
|
3460
4090
|
'恢复演练通过: ' + id,
|
|
3461
4091
|
'- manifest / SHA-256: 通过',
|
|
3462
4092
|
'- 恢复副本索引: 通过',
|
|
3463
4093
|
'- 测试私密解密: ' + (privateCheck.checked ? ('通过(' + privateCheck.source + ',' + privateCheck.file + ')') : '跳过(备份中没有加密私密条目)'),
|
|
3464
4094
|
];
|
|
4095
|
+
if (probeReport) {
|
|
4096
|
+
lines.push('');
|
|
4097
|
+
lines.push(probeReport.text);
|
|
4098
|
+
}
|
|
3465
4099
|
return {
|
|
3466
4100
|
error: false,
|
|
3467
4101
|
ok: true,
|
|
3468
4102
|
id: id,
|
|
3469
4103
|
restoredTo: tempParent ? '(临时副本已清理)' : target,
|
|
3470
4104
|
checks: checks,
|
|
4105
|
+
probes: probeReport ? probeReport.probes : null,
|
|
3471
4106
|
text: lines.join('\n'),
|
|
3472
4107
|
};
|
|
3473
4108
|
}
|
|
@@ -3502,17 +4137,19 @@ function rememberCore(type, subject, statement, opts) {
|
|
|
3502
4137
|
if (scope === 'private' && encrypted && !getOwnerKeyFor(root, owner)) {
|
|
3503
4138
|
return { error: true, text: '私密区已加密:当前无 ' + owner + ' 的授权密钥,请在用户平台授权(yotta-memory view → 授权本智能体)后再写私密记忆。公共 FACT 不受影响。' };
|
|
3504
4139
|
}
|
|
3505
|
-
const
|
|
4140
|
+
const typeDir = path.join(root, typeSubdir(t, owner));
|
|
4141
|
+
const writeDate = today(opts._today);
|
|
4142
|
+
const dir = entryWriteDir(root, t, owner, writeDate);
|
|
3506
4143
|
fs.mkdirSync(dir, { recursive: true });
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
4144
|
+
// 去重扫描覆盖旧平铺 + 最近写入窗口,避免同一内容按两种布局各存一份。
|
|
4145
|
+
const existingFiles = recentlyWrittenFiles(typeDir, dir);
|
|
4146
|
+
if (existingFiles.length) {
|
|
4147
|
+
for (const fp of existingFiles) {
|
|
3511
4148
|
let parsed;
|
|
3512
4149
|
try { parsed = parseFrontmatter(readMemoryText(root, fp, owner)); } catch (err) { continue; }
|
|
3513
4150
|
const meta = parsed.meta;
|
|
3514
4151
|
if ((meta.type || '').toUpperCase() === t && meta.subject === subj && meta.statement === stmt) {
|
|
3515
|
-
const patch = { updated:
|
|
4152
|
+
const patch = { updated: writeDate };
|
|
3516
4153
|
if (owner && !meta.owner) patch.owner = owner;
|
|
3517
4154
|
if (scope && !meta.scope) patch.scope = scope;
|
|
3518
4155
|
if (opts.source && !meta.source) patch.source = opts.source;
|
|
@@ -3533,12 +4170,12 @@ function rememberCore(type, subject, statement, opts) {
|
|
|
3533
4170
|
}
|
|
3534
4171
|
}
|
|
3535
4172
|
}
|
|
3536
|
-
const seq =
|
|
4173
|
+
const seq = nextSeqFor(typeDir, dir);
|
|
3537
4174
|
const suffix = (encrypted && scope === 'private') ? ENC_SUFFIX : '';
|
|
3538
|
-
const file = path.join(dir,
|
|
4175
|
+
const file = path.join(dir, writeDate + '-' + seq + '.md' + suffix);
|
|
3539
4176
|
const rec = {
|
|
3540
4177
|
type: t, subject: subj, statement: stmt,
|
|
3541
|
-
confidence: 1.0, created:
|
|
4178
|
+
confidence: 1.0, created: writeDate, updated: writeDate,
|
|
3542
4179
|
tags: [], immutable: false,
|
|
3543
4180
|
scope: scope, owner: owner,
|
|
3544
4181
|
source: opts.source || '',
|
|
@@ -3595,6 +4232,84 @@ function recallPrefilter(q) {
|
|
|
3595
4232
|
return false;
|
|
3596
4233
|
};
|
|
3597
4234
|
}
|
|
4235
|
+
// v0.17.0:命中打分与排序抽成共享原语,recall 与 bench 用同一套逻辑(评测才不会与真实检索漂移)。
|
|
4236
|
+
function scoreCandidates(entries, query, options) {
|
|
4237
|
+
options = options || {};
|
|
4238
|
+
const useSemantic = options.semantic !== false;
|
|
4239
|
+
const prefilter = options.prefilter || null;
|
|
4240
|
+
const wantExplain = !!options.wantExplain;
|
|
4241
|
+
const embeddingMap = options.embeddingMap || null;
|
|
4242
|
+
const root = options.root ? String(options.root) : '';
|
|
4243
|
+
const out = [];
|
|
4244
|
+
for (const e of entries || []) {
|
|
4245
|
+
let score = 0;
|
|
4246
|
+
let detail = null;
|
|
4247
|
+
const embeddingHit = embeddingMap ? (embeddingMap.get(root + '\0' + e.file) || null) : null;
|
|
4248
|
+
if (query) {
|
|
4249
|
+
if (useSemantic) {
|
|
4250
|
+
if (prefilter && !prefilter(e) && !embeddingHit) continue; // v0.8.1 候选预过滤:粗筛后再语义打分;embedding 命中可越过词法预过滤
|
|
4251
|
+
const m = semanticMatch(e, query, wantExplain);
|
|
4252
|
+
score = m.score;
|
|
4253
|
+
if (wantExplain && m.detail && m.detail.length) detail = m.detail;
|
|
4254
|
+
if (embeddingHit) {
|
|
4255
|
+
score = Math.max(score, embeddingHit.score);
|
|
4256
|
+
if (wantExplain && embeddingHit.detail) detail = (detail || []).concat(embeddingHit.detail);
|
|
4257
|
+
}
|
|
4258
|
+
} else {
|
|
4259
|
+
const qtoks = tokenize(query);
|
|
4260
|
+
for (const tt of qtoks) { if (e.tokens && e.tokens[tt]) score += e.tokens[tt]; }
|
|
4261
|
+
if (score === 0) {
|
|
4262
|
+
const hay = ((e.subject || '') + ' ' + (e.statement || '') + ' ' + (e.tags || []).join(' ')).toLowerCase();
|
|
4263
|
+
if (hay.indexOf(query) !== -1) score = 1;
|
|
4264
|
+
}
|
|
4265
|
+
}
|
|
4266
|
+
if (score === 0) continue;
|
|
4267
|
+
} else {
|
|
4268
|
+
score = 1;
|
|
4269
|
+
}
|
|
4270
|
+
out.push({ entry: e, score: score, detail: detail });
|
|
4271
|
+
}
|
|
4272
|
+
return out;
|
|
4273
|
+
}
|
|
4274
|
+
function rankHits(hits, options) {
|
|
4275
|
+
options = options || {};
|
|
4276
|
+
const fuse = options.fuse !== false;
|
|
4277
|
+
if (hits.length > 1 && fuse) {
|
|
4278
|
+
// v0.8.0 融合排序:0.65 × 语义分归一 + 0.35 × 效用分归一
|
|
4279
|
+
let minSem = Infinity, maxSem = -Infinity, minUtil = Infinity, maxUtil = -Infinity;
|
|
4280
|
+
for (const h of hits) {
|
|
4281
|
+
if (h.score < minSem) minSem = h.score;
|
|
4282
|
+
if (h.score > maxSem) maxSem = h.score;
|
|
4283
|
+
const u = utilityScore(h.entry);
|
|
4284
|
+
if (u < minUtil) minUtil = u;
|
|
4285
|
+
if (u > maxUtil) maxUtil = u;
|
|
4286
|
+
}
|
|
4287
|
+
const spanSem = maxSem - minSem;
|
|
4288
|
+
const spanUtil = maxUtil - minUtil;
|
|
4289
|
+
for (const h of hits) {
|
|
4290
|
+
h.semNorm = spanSem > 0 ? (h.score - minSem) / spanSem : 1;
|
|
4291
|
+
h.utilNorm = spanUtil > 0 ? (utilityScore(h.entry) - minUtil) / spanUtil : 1;
|
|
4292
|
+
h.finalScore = 0.65 * h.semNorm + 0.35 * h.utilNorm;
|
|
4293
|
+
}
|
|
4294
|
+
hits.sort(function (a, b) {
|
|
4295
|
+
if (b.finalScore !== a.finalScore) return b.finalScore - a.finalScore;
|
|
4296
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
4297
|
+
const pa = a.root === projectRoot() ? 0 : 1;
|
|
4298
|
+
const pb = b.root === projectRoot() ? 0 : 1;
|
|
4299
|
+
if (pa !== pb) return pa - pb;
|
|
4300
|
+
return String(b.entry.created).localeCompare(String(a.entry.created));
|
|
4301
|
+
});
|
|
4302
|
+
return hits;
|
|
4303
|
+
}
|
|
4304
|
+
hits.sort(function (a, b) {
|
|
4305
|
+
if (options.sortByScore && b.score !== a.score) return b.score - a.score;
|
|
4306
|
+
const pa = a.root === projectRoot() ? 0 : 1;
|
|
4307
|
+
const pb = b.root === projectRoot() ? 0 : 1;
|
|
4308
|
+
if (pa !== pb) return pa - pb;
|
|
4309
|
+
return String(b.entry.created).localeCompare(String(a.entry.created));
|
|
4310
|
+
});
|
|
4311
|
+
return hits;
|
|
4312
|
+
}
|
|
3598
4313
|
function recallCore(query, opts) {
|
|
3599
4314
|
opts = opts || {};
|
|
3600
4315
|
const roots = memoryRoots();
|
|
@@ -3629,13 +4344,16 @@ function recallCore(query, opts) {
|
|
|
3629
4344
|
const explicitCross = !!opts.all || (!!ownerFilter && ownerFilter !== agent);
|
|
3630
4345
|
const wantExplain = !!opts.explain;
|
|
3631
4346
|
const useSemantic = opts.semantic !== false;
|
|
4347
|
+
const yearFilter = normalizeYearList(opts.years);
|
|
4348
|
+
if (yearFilter.error) return { error: true, exitCode: 2, text: yearFilter.error };
|
|
4349
|
+
const years = yearFilter.years;
|
|
3632
4350
|
const prefilter = (q && useSemantic) ? recallPrefilter(q) : null;
|
|
3633
4351
|
const embeddingMap = new Map();
|
|
3634
4352
|
const embeddingCommand = effectiveEmbeddingCommand(opts);
|
|
3635
4353
|
const embeddingTimeout = effectiveEmbeddingTimeout(opts);
|
|
3636
4354
|
if (q && embeddingCommand) {
|
|
3637
4355
|
for (const root of roots) {
|
|
3638
|
-
const rootEntries =
|
|
4356
|
+
const rootEntries = indexEntriesFor(root, { years: years }).entries.filter(function (e) {
|
|
3639
4357
|
return classifyRead(e, agent, ownerFilter, allSafe, selfAgent) !== 'denied';
|
|
3640
4358
|
});
|
|
3641
4359
|
try {
|
|
@@ -3655,74 +4373,26 @@ function recallCore(query, opts) {
|
|
|
3655
4373
|
const hits = [];
|
|
3656
4374
|
let deniedCount = 0;
|
|
3657
4375
|
for (const root of roots) {
|
|
3658
|
-
const entries =
|
|
4376
|
+
const entries = indexEntriesFor(root, { years: years }).entries;
|
|
4377
|
+
const readable = [];
|
|
3659
4378
|
for (const e of entries) {
|
|
3660
4379
|
if (onlyType && e.type !== onlyType) continue;
|
|
3661
4380
|
const r = classifyRead(e, agent, ownerFilter, allSafe, selfAgent);
|
|
3662
4381
|
if (r === 'denied') { deniedCount++; continue; }
|
|
3663
|
-
|
|
3664
|
-
let detail = null;
|
|
3665
|
-
const embeddingHit = embeddingMap.get(root + '\0' + e.file) || null;
|
|
3666
|
-
if (q) {
|
|
3667
|
-
if (useSemantic) {
|
|
3668
|
-
if (prefilter && !prefilter(e) && !embeddingHit) continue; // v0.8.1 候选预过滤:粗筛后再语义打分;embedding 命中可越过词法预过滤
|
|
3669
|
-
const m = semanticMatch(e, q, wantExplain);
|
|
3670
|
-
score = m.score;
|
|
3671
|
-
if (wantExplain && m.detail && m.detail.length) detail = m.detail;
|
|
3672
|
-
if (embeddingHit) {
|
|
3673
|
-
score = Math.max(score, embeddingHit.score);
|
|
3674
|
-
if (wantExplain && embeddingHit.detail) {
|
|
3675
|
-
detail = (detail || []).concat(embeddingHit.detail);
|
|
3676
|
-
}
|
|
3677
|
-
}
|
|
3678
|
-
} else {
|
|
3679
|
-
const qtoks = tokenize(q);
|
|
3680
|
-
for (const tt of qtoks) { if (e.tokens && e.tokens[tt]) score += e.tokens[tt]; }
|
|
3681
|
-
if (score === 0) {
|
|
3682
|
-
const hay = ((e.subject || '') + ' ' + (e.statement || '') + ' ' + e.tags.join(' ')).toLowerCase();
|
|
3683
|
-
if (hay.indexOf(q) !== -1) score = 1;
|
|
3684
|
-
}
|
|
3685
|
-
}
|
|
3686
|
-
if (score === 0) continue;
|
|
3687
|
-
} else {
|
|
3688
|
-
score = 1;
|
|
3689
|
-
}
|
|
3690
|
-
hits.push({ entry: e, score: score, root: root, detail: detail });
|
|
3691
|
-
}
|
|
3692
|
-
}
|
|
3693
|
-
// v0.8.0 融合排序:0.65 × 语义分归一 + 0.35 × 效用分归一
|
|
3694
|
-
if (hits.length > 1) {
|
|
3695
|
-
let minSem = Infinity, maxSem = -Infinity, minUtil = Infinity, maxUtil = -Infinity;
|
|
3696
|
-
for (const h of hits) {
|
|
3697
|
-
if (h.score < minSem) minSem = h.score;
|
|
3698
|
-
if (h.score > maxSem) maxSem = h.score;
|
|
3699
|
-
const u = utilityScore(h.entry);
|
|
3700
|
-
if (u < minUtil) minUtil = u;
|
|
3701
|
-
if (u > maxUtil) maxUtil = u;
|
|
3702
|
-
}
|
|
3703
|
-
const spanSem = maxSem - minSem;
|
|
3704
|
-
const spanUtil = maxUtil - minUtil;
|
|
3705
|
-
for (const h of hits) {
|
|
3706
|
-
h.semNorm = spanSem > 0 ? (h.score - minSem) / spanSem : 1;
|
|
3707
|
-
h.utilNorm = spanUtil > 0 ? (utilityScore(h.entry) - minUtil) / spanUtil : 1;
|
|
3708
|
-
h.finalScore = 0.65 * h.semNorm + 0.35 * h.utilNorm;
|
|
4382
|
+
readable.push(e);
|
|
3709
4383
|
}
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
return String(b.entry.created).localeCompare(String(a.entry.created));
|
|
3717
|
-
});
|
|
3718
|
-
} else {
|
|
3719
|
-
hits.sort(function (a, b) {
|
|
3720
|
-
const pa = a.root === projectRoot() ? 0 : 1;
|
|
3721
|
-
const pb = b.root === projectRoot() ? 0 : 1;
|
|
3722
|
-
if (pa !== pb) return pa - pb;
|
|
3723
|
-
return String(b.entry.created).localeCompare(String(a.entry.created));
|
|
4384
|
+
const scored = scoreCandidates(readable, q, {
|
|
4385
|
+
semantic: useSemantic,
|
|
4386
|
+
prefilter: prefilter,
|
|
4387
|
+
wantExplain: wantExplain,
|
|
4388
|
+
embeddingMap: embeddingMap,
|
|
4389
|
+
root: root,
|
|
3724
4390
|
});
|
|
4391
|
+
for (const s of scored) {
|
|
4392
|
+
hits.push({ entry: s.entry, score: s.score, root: root, detail: s.detail });
|
|
4393
|
+
}
|
|
3725
4394
|
}
|
|
4395
|
+
rankHits(hits, {});
|
|
3726
4396
|
const shown = hits.slice(0, limit);
|
|
3727
4397
|
if (!shown.length) {
|
|
3728
4398
|
if (deniedCount > 0 && explicitCross) {
|
|
@@ -3734,7 +4404,7 @@ function recallCore(query, opts) {
|
|
|
3734
4404
|
if (touchRel.length) {
|
|
3735
4405
|
for (const root of roots) {
|
|
3736
4406
|
bumpReadMeta(root, touchRel);
|
|
3737
|
-
touchIndex(root, touchRel);
|
|
4407
|
+
touchIndex(root, touchRel, { years: years });
|
|
3738
4408
|
}
|
|
3739
4409
|
}
|
|
3740
4410
|
const lines = ['共 ' + shown.length + ' 条记忆(' + (hits.length > limit ? '前 ' + limit + ' 条' : '全部') + '):'];
|
|
@@ -3861,10 +4531,11 @@ function archiveCore(opts) {
|
|
|
3861
4531
|
if (t === 'BOUND') continue; // v0.10.0 BOUND 豁免归档(边界常驻)
|
|
3862
4532
|
// v0.8.0 统一效用分(盖棺分)替代 vitality
|
|
3863
4533
|
if (utilityScore(meta) < threshold && createdTs < cutoff) {
|
|
3864
|
-
const destDir = archiveDirFor(root, t, owner);
|
|
3865
|
-
fs.mkdirSync(destDir, { recursive: true });
|
|
3866
|
-
fs.renameSync(fp, path.join(destDir, path.basename(fp)));
|
|
3867
4534
|
const rel = relOf(root, fp);
|
|
4535
|
+
const dest = path.join(root, archiveRelFor(root, rel, t, owner));
|
|
4536
|
+
const destDir = path.dirname(dest);
|
|
4537
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
4538
|
+
fs.renameSync(fp, dest);
|
|
3868
4539
|
movedFiles.push(rel);
|
|
3869
4540
|
moved++;
|
|
3870
4541
|
}
|
|
@@ -4067,9 +4738,10 @@ function maintainCore(opts) {
|
|
|
4067
4738
|
for (const rec of toArchive) {
|
|
4068
4739
|
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 收工纪律锚点,请确认' : ''));
|
|
4069
4740
|
if (apply && !opts.dedup) {
|
|
4070
|
-
const
|
|
4741
|
+
const dest = path.join(root, archiveRelFor(root, rec.rel, rec.type, rec.owner));
|
|
4742
|
+
const destDir = path.dirname(dest);
|
|
4071
4743
|
fs.mkdirSync(destDir, { recursive: true });
|
|
4072
|
-
fs.renameSync(rec.fp,
|
|
4744
|
+
fs.renameSync(rec.fp, dest);
|
|
4073
4745
|
appendAudit(root, 'audit', { ts: new Date().toISOString(), file: rec.rel, action: 'archive', reason: 'utility<' + round3(rec.utility) + ',age>' + rec.ageDays, utility: round3(rec.utility) });
|
|
4074
4746
|
archived++;
|
|
4075
4747
|
}
|
|
@@ -4081,9 +4753,9 @@ function maintainCore(opts) {
|
|
|
4081
4753
|
for (const rec of toForget) {
|
|
4082
4754
|
lines.push('- [' + rec.type + '] ' + rec.rel + '(utility ' + round3(rec.utility) + ',' + rec.ageDays + ' 天)');
|
|
4083
4755
|
if (apply && purge && !opts.dedup) {
|
|
4084
|
-
const
|
|
4756
|
+
const dest = path.join(root, archiveRelFor(root, rec.rel, rec.type, rec.owner));
|
|
4757
|
+
const destDir = path.dirname(dest);
|
|
4085
4758
|
fs.mkdirSync(destDir, { recursive: true });
|
|
4086
|
-
const dest = path.join(destDir, path.basename(rec.fp));
|
|
4087
4759
|
fs.renameSync(rec.fp, dest);
|
|
4088
4760
|
appendAudit(root, 'audit', { ts: new Date().toISOString(), file: rec.rel, action: 'forget', reason: 'utility<' + round3(rec.utility) + ',age>' + rec.ageDays + ',purge', utility: round3(rec.utility) });
|
|
4089
4761
|
// 真删前二次确认标记:purge 已显式授权,删除 .archive 内副本前的最终动作 = 记录审计后删除
|
|
@@ -4314,6 +4986,66 @@ function promptPassword(promptText) {
|
|
|
4314
4986
|
});
|
|
4315
4987
|
}
|
|
4316
4988
|
|
|
4989
|
+
// 交互确认输入(回显明文,用于「输入 id 确认删除」这类非机密输入)。
|
|
4990
|
+
function promptLine(promptText) {
|
|
4991
|
+
return new Promise(function (resolve) {
|
|
4992
|
+
const stdin = process.stdin;
|
|
4993
|
+
if (!stdin.isTTY) { resolve(null); return; }
|
|
4994
|
+
process.stdout.write(promptText);
|
|
4995
|
+
let buf = '';
|
|
4996
|
+
function done(abort) {
|
|
4997
|
+
try { stdin.setRawMode(false); } catch (e) {}
|
|
4998
|
+
stdin.pause();
|
|
4999
|
+
stdin.removeListener('data', onData);
|
|
5000
|
+
process.stdout.write('\n');
|
|
5001
|
+
resolve(abort ? null : buf);
|
|
5002
|
+
}
|
|
5003
|
+
function onData(chunk) {
|
|
5004
|
+
const s = String(chunk);
|
|
5005
|
+
for (const c of s) {
|
|
5006
|
+
if (c === '\r' || c === '\n') { done(false); return; }
|
|
5007
|
+
if (c === '\u0003') { done(true); return; }
|
|
5008
|
+
if (c === '\u007f' || c === '\b') {
|
|
5009
|
+
if (buf.length) { buf = buf.slice(0, -1); process.stdout.write('\b \b'); }
|
|
5010
|
+
continue;
|
|
5011
|
+
}
|
|
5012
|
+
buf += c;
|
|
5013
|
+
process.stdout.write(c);
|
|
5014
|
+
}
|
|
5015
|
+
}
|
|
5016
|
+
try { stdin.setRawMode(true); } catch (e) { resolve(null); return; }
|
|
5017
|
+
stdin.resume();
|
|
5018
|
+
stdin.setEncoding('utf8');
|
|
5019
|
+
stdin.on('data', onData);
|
|
5020
|
+
});
|
|
5021
|
+
}
|
|
5022
|
+
|
|
5023
|
+
async function cmdIdentityRemove(id, opts) {
|
|
5024
|
+
opts = opts || {};
|
|
5025
|
+
const root = userRoot();
|
|
5026
|
+
if (!id) {
|
|
5027
|
+
console.error('请指定 <id>:yotta-memory identity remove <id> [--dry-run] [--yes] [--keep-memories] [--keep-identity]');
|
|
5028
|
+
process.exit(2);
|
|
5029
|
+
}
|
|
5030
|
+
let local = Object.assign({}, opts);
|
|
5031
|
+
if (!local.dryRun && !local.yes && process.stdin.isTTY) {
|
|
5032
|
+
const typed = await promptLine('将彻底删除 ' + id + ' 的身份与私密记忆(不可恢复;公共明文保留)。请输入 ' + id + ' 确认: ');
|
|
5033
|
+
if (typed !== id) {
|
|
5034
|
+
console.log('已取消:未删除任何内容。');
|
|
5035
|
+
process.exit(2);
|
|
5036
|
+
}
|
|
5037
|
+
local.yes = true;
|
|
5038
|
+
}
|
|
5039
|
+
if (!local.dryRun && isEncrypted(root) && !local.password && !local.recoveryKey && !process.env.YOTTA_MEMORY_PASS) {
|
|
5040
|
+
const got = await resolvePasswordInput(local, '主口令: ');
|
|
5041
|
+
if (got.error) { console.error(got.error); process.exit(2); }
|
|
5042
|
+
local.password = got.password;
|
|
5043
|
+
}
|
|
5044
|
+
const result = identityRemoveCore(root, id, local);
|
|
5045
|
+
console.log(result.text);
|
|
5046
|
+
if (result.error) process.exit(result.exitCode || 2);
|
|
5047
|
+
}
|
|
5048
|
+
|
|
4317
5049
|
function readPasswordFromStdin() {
|
|
4318
5050
|
try {
|
|
4319
5051
|
const text = fs.readFileSync(0, 'utf8');
|
|
@@ -4364,9 +5096,19 @@ function migrateCore(root, password, recoveryKeyIn) {
|
|
|
4364
5096
|
for (const t of PRIVATE_LEAF) {
|
|
4365
5097
|
const d = path.join(root, PRIVATE_DIR, owner, t);
|
|
4366
5098
|
if (!fs.existsSync(d)) continue;
|
|
4367
|
-
|
|
4368
|
-
|
|
4369
|
-
|
|
5099
|
+
// v0.17.0:递归下探年/月子目录,明文库迁移到加密时不能漏掉分层文件。
|
|
5100
|
+
const plaintexts = [];
|
|
5101
|
+
const collectPlaintext = function (current) {
|
|
5102
|
+
let names = [];
|
|
5103
|
+
try { names = fs.readdirSync(current, { withFileTypes: true }); } catch (e) { return; }
|
|
5104
|
+
for (const item of names) {
|
|
5105
|
+
const fp = path.join(current, item.name);
|
|
5106
|
+
if (item.isDirectory()) { collectPlaintext(fp); continue; }
|
|
5107
|
+
if (item.isFile() && /\.md$/.test(item.name) && !isEncFile(fp)) plaintexts.push(fp);
|
|
5108
|
+
}
|
|
5109
|
+
};
|
|
5110
|
+
collectPlaintext(d);
|
|
5111
|
+
for (const fp of plaintexts) {
|
|
4370
5112
|
const text = fs.readFileSync(fp, 'utf8');
|
|
4371
5113
|
fs.writeFileSync(fp + ENC_SUFFIX, encryptMemoryText(text, ok));
|
|
4372
5114
|
fs.unlinkSync(fp);
|
|
@@ -4533,12 +5275,256 @@ function keyRevokeCore(root, owner) {
|
|
|
4533
5275
|
return { error: false, text: '已吊销 ' + owner + ' 的 agent_key binding;该 agent 将无法解密其私密。' };
|
|
4534
5276
|
}
|
|
4535
5277
|
|
|
4536
|
-
|
|
4537
|
-
|
|
4538
|
-
|
|
4539
|
-
|
|
4540
|
-
|
|
4541
|
-
|
|
5278
|
+
// ---- v0.17.0:彻底删除一个 AI 身份 + 它的私密记忆(只能用户本人执行;公共明文保留)----
|
|
5279
|
+
const IDENTITY_REMOVE_STEPS = [
|
|
5280
|
+
['agents', 'agents.json 身份登记'],
|
|
5281
|
+
['owner-key', 'keys/<id>.key.enc(owner 密钥与恢复侧文件)'],
|
|
5282
|
+
['binding', 'keys/bindings/<id>.key.agent(授权绑定)'],
|
|
5283
|
+
['pending', 'keys/pending/<id>.key(待领取 key)'],
|
|
5284
|
+
['cache', 'keys/cache/<id>.key(明文缓存)'],
|
|
5285
|
+
['memories', 'private/<id>/(私密记忆 + owner 索引 + profile / distill)'],
|
|
5286
|
+
['token', '.server/tokens.json 里的 token'],
|
|
5287
|
+
['grants', 'grants.json 里的授权引用'],
|
|
5288
|
+
['reindex', '重建索引并写审计'],
|
|
5289
|
+
];
|
|
5290
|
+
function countFilesInTree(dir) {
|
|
5291
|
+
let count = 0;
|
|
5292
|
+
(function walk(current) {
|
|
5293
|
+
let items = [];
|
|
5294
|
+
try { items = fs.readdirSync(current, { withFileTypes: true }); } catch (error) { return; }
|
|
5295
|
+
for (const item of items) {
|
|
5296
|
+
const fp = path.join(current, item.name);
|
|
5297
|
+
if (item.isDirectory()) walk(fp);
|
|
5298
|
+
else if (item.isFile()) count++;
|
|
5299
|
+
}
|
|
5300
|
+
})(dir);
|
|
5301
|
+
return count;
|
|
5302
|
+
}
|
|
5303
|
+
function identityRemoveInventory(root, id) {
|
|
5304
|
+
const agents = loadAgents(root);
|
|
5305
|
+
const grants = loadGrants(root);
|
|
5306
|
+
const tokens = loadTokens(root);
|
|
5307
|
+
const grantRefs = [];
|
|
5308
|
+
for (const key of Object.keys(grants)) {
|
|
5309
|
+
if (key === id) grantRefs.push(key + ' → 其它 owner');
|
|
5310
|
+
if (Array.isArray(grants[key]) && grants[key].indexOf(id) !== -1) grantRefs.push(key + ' → ' + id);
|
|
5311
|
+
}
|
|
5312
|
+
const privateDir = path.join(root, PRIVATE_DIR, id);
|
|
5313
|
+
const hasDir = fs.existsSync(privateDir);
|
|
5314
|
+
return {
|
|
5315
|
+
registered: !!(agents.agents && agents.agents[id]),
|
|
5316
|
+
ownerKey: fs.existsSync(encOwnerKeyPath(root, id)),
|
|
5317
|
+
ownerRecovery: fs.existsSync(encOwnerRecPath(root, id)),
|
|
5318
|
+
binding: fs.existsSync(encAgentBindingPath(root, id)),
|
|
5319
|
+
pending: fs.existsSync(encPendingPath(root, id)),
|
|
5320
|
+
cache: fs.existsSync(encCachePath(root, id)),
|
|
5321
|
+
memories: hasDir,
|
|
5322
|
+
memoryFiles: hasDir ? countFilesInTree(privateDir) : 0,
|
|
5323
|
+
token: Object.prototype.hasOwnProperty.call(tokens, id),
|
|
5324
|
+
grants: grantRefs,
|
|
5325
|
+
};
|
|
5326
|
+
}
|
|
5327
|
+
function identityRemoveExists(inventory) {
|
|
5328
|
+
return !!(inventory.registered || inventory.ownerKey || inventory.ownerRecovery || inventory.binding
|
|
5329
|
+
|| inventory.pending || inventory.cache || inventory.memories || inventory.token || inventory.grants.length);
|
|
5330
|
+
}
|
|
5331
|
+
// 用户授权证明:加密库要主口令(能解开任一 owner key 或恢复材料)或恢复钥匙;
|
|
5332
|
+
// view 已用主口令解锁的会话视为用户本人;明文库无机密凭据可校验,要求显式 --agent user。AI 不得删除身份。
|
|
5333
|
+
function verifyIdentityRemoveAuthority(root, id, opts) {
|
|
5334
|
+
if (opts.userUnlocked === true) return { ok: true, mode: 'view-session' };
|
|
5335
|
+
if (isEncrypted(root)) {
|
|
5336
|
+
const candidates = [id].concat(collectOwners(root));
|
|
5337
|
+
if (opts.recoveryKey) {
|
|
5338
|
+
const rk = Buffer.from(String(opts.recoveryKey), 'base64');
|
|
5339
|
+
if (rk.length !== 32) return { ok: false, error: '恢复钥匙须为 32 字节(base64),已拒绝删除。' };
|
|
5340
|
+
for (const owner of candidates) {
|
|
5341
|
+
if (!fs.existsSync(encOwnerKeyPath(root, owner))) continue;
|
|
5342
|
+
try { if (unwrapOwnerKeyRecovery(root, owner, rk)) return { ok: true, mode: 'recovery-key' }; } catch (error) { /* 试下一个 */ }
|
|
5343
|
+
}
|
|
5344
|
+
return { ok: false, error: '恢复钥匙无法解开任何 owner key,已拒绝删除。' };
|
|
5345
|
+
}
|
|
5346
|
+
const password = String(opts.password || process.env.YOTTA_MEMORY_PASS || '');
|
|
5347
|
+
if (!password) {
|
|
5348
|
+
return { ok: false, error: '加密库删除身份必须由用户本人执行:请提供主口令(--password / YOTTA_MEMORY_PASS / 交互输入)或 --recovery-key,或用 yotta-memory view 操作。AI 不得删除身份。' };
|
|
5349
|
+
}
|
|
5350
|
+
const salt = loadSalt(root);
|
|
5351
|
+
if (!salt) return { ok: false, error: '密钥库损坏(缺 keys/salt),无法校验主口令;请改用 --recovery-key。' };
|
|
5352
|
+
const umk = deriveUmk(password, salt);
|
|
5353
|
+
for (const owner of candidates) {
|
|
5354
|
+
if (!fs.existsSync(encOwnerKeyPath(root, owner))) continue;
|
|
5355
|
+
try { if (unwrapOwnerKey(root, owner, umk)) return { ok: true, mode: 'password' }; } catch (error) { /* 试下一个 */ }
|
|
5356
|
+
}
|
|
5357
|
+
let rk = null;
|
|
5358
|
+
try { rk = unwrapRecoveryEnc(root, umk); } catch (error) { rk = null; }
|
|
5359
|
+
if (rk) return { ok: true, mode: 'password' };
|
|
5360
|
+
return { ok: false, error: '主口令不正确(用它解不开任何 owner 密钥与恢复材料),已拒绝删除;也可以改用 --recovery-key。' };
|
|
5361
|
+
}
|
|
5362
|
+
if (String(opts.agent || '').trim() !== 'user') {
|
|
5363
|
+
return { ok: false, error: '明文库没有可校验的用户凭据:请显式声明用户身份(CLI 加 --agent user,或用 yotta-memory view 操作)。AI 不得删除身份。' };
|
|
5364
|
+
}
|
|
5365
|
+
return { ok: true, mode: 'user-claim' };
|
|
5366
|
+
}
|
|
5367
|
+
function renderIdentityRemoveInventory(id, inventory, opts) {
|
|
5368
|
+
const lines = [
|
|
5369
|
+
'# 删除 AI 身份与私密记忆: ' + id,
|
|
5370
|
+
'',
|
|
5371
|
+
'将删除(私密侧;公共明文 FACT 按口径保留,其它 owner 不受影响):',
|
|
5372
|
+
'- 身份登记 agents.json: ' + (inventory.registered ? '有' : '无'),
|
|
5373
|
+
'- owner 密钥 keys/' + id + '.key.enc: ' + (inventory.ownerKey ? '有' : '无') + (inventory.ownerRecovery ? '(含恢复侧文件)' : ''),
|
|
5374
|
+
'- 授权绑定 / 待领取 key / 明文缓存: ' + [inventory.binding, inventory.pending, inventory.cache].map(function (x) { return x ? '有' : '无'; }).join(' / '),
|
|
5375
|
+
'- 私密记忆 private/' + id + '/: ' + (inventory.memories ? inventory.memoryFiles + ' 个文件' : '无'),
|
|
5376
|
+
'- token / grants 引用: ' + (inventory.token ? '有 token' : '无 token') + ' / ' + (inventory.grants.length ? inventory.grants.join(';') : '无引用'),
|
|
5377
|
+
];
|
|
5378
|
+
if (opts.keepMemories) lines.push('- 开关: --keep-memories 只删身份与授权,private/' + id + '/ 保留');
|
|
5379
|
+
if (opts.keepIdentity) lines.push('- 开关: --keep-identity 只删私密记忆与授权,agents.json 登记保留');
|
|
5380
|
+
return lines.join('\n');
|
|
5381
|
+
}
|
|
5382
|
+
function identityRemoveCore(root, id, opts) {
|
|
5383
|
+
opts = opts || {};
|
|
5384
|
+
root = path.resolve(root || userRoot());
|
|
5385
|
+
if (!isSafeAgentId(id)) {
|
|
5386
|
+
return { error: true, exitCode: 2, removed: [], steps: [], text: '非法 agent ID:只允许单段名称,禁止 /、\\、.. 和控制字符。' };
|
|
5387
|
+
}
|
|
5388
|
+
const inventory = identityRemoveInventory(root, id);
|
|
5389
|
+
if (!identityRemoveExists(inventory)) {
|
|
5390
|
+
return { error: false, exitCode: 0, removed: [], steps: [], inventory: inventory, text: id + ' 不存在(无需处理)。' };
|
|
5391
|
+
}
|
|
5392
|
+
const summary = renderIdentityRemoveInventory(id, inventory, opts);
|
|
5393
|
+
if (opts.dryRun) {
|
|
5394
|
+
return { error: false, exitCode: 0, dryRun: true, removed: [], steps: [], inventory: inventory, text: summary + '\n\n预演结束:未改动任何文件;确认后加 --yes 执行。' };
|
|
5395
|
+
}
|
|
5396
|
+
const authority = verifyIdentityRemoveAuthority(root, id, opts);
|
|
5397
|
+
if (!authority.ok) {
|
|
5398
|
+
return { error: true, exitCode: 2, removed: [], steps: [], inventory: inventory, text: authority.error + '\n\n' + summary };
|
|
5399
|
+
}
|
|
5400
|
+
if (!opts.yes) {
|
|
5401
|
+
return { error: true, exitCode: 2, removed: [], steps: [], inventory: inventory, text: summary + '\n\n需要显式确认:CLI 加 --yes,或在交互提示里输入完整 id。' };
|
|
5402
|
+
}
|
|
5403
|
+
const guard = destructiveGuardCore({
|
|
5404
|
+
root: root,
|
|
5405
|
+
action: 'identity_remove:' + id,
|
|
5406
|
+
snapshotDir: opts.snapshotDir,
|
|
5407
|
+
backupDir: opts.backupDir,
|
|
5408
|
+
allowSameVolumeForTest: !!opts.allowSameVolumeForTest,
|
|
5409
|
+
sameVolumeFn: opts.sameVolumeFn,
|
|
5410
|
+
now: opts.now,
|
|
5411
|
+
});
|
|
5412
|
+
if (guard.error) {
|
|
5413
|
+
return { error: true, exitCode: 2, removed: [], steps: [], inventory: inventory, doctor: guard.doctor, text: guard.text + '\n\n' + summary };
|
|
5414
|
+
}
|
|
5415
|
+
|
|
5416
|
+
const steps = [];
|
|
5417
|
+
function step(stepId, changed, detail) {
|
|
5418
|
+
const meta = IDENTITY_REMOVE_STEPS.filter(function (row) { return row[0] === stepId; })[0];
|
|
5419
|
+
steps.push({ id: stepId, label: meta ? meta[1] : stepId, changed: !!changed, detail: detail || '' });
|
|
5420
|
+
}
|
|
5421
|
+
|
|
5422
|
+
if (opts.keepIdentity) {
|
|
5423
|
+
step('agents', false, '按 --keep-identity 保留');
|
|
5424
|
+
} else {
|
|
5425
|
+
const data = loadAgents(root);
|
|
5426
|
+
if (data.agents && data.agents[id]) {
|
|
5427
|
+
delete data.agents[id];
|
|
5428
|
+
saveAgents(root, data);
|
|
5429
|
+
step('agents', true, '');
|
|
5430
|
+
} else {
|
|
5431
|
+
step('agents', false, '未登记');
|
|
5432
|
+
}
|
|
5433
|
+
}
|
|
5434
|
+
|
|
5435
|
+
let keyChanged = false;
|
|
5436
|
+
for (const fp of [encOwnerKeyPath(root, id), encOwnerRecPath(root, id)]) {
|
|
5437
|
+
if (!fs.existsSync(fp)) continue;
|
|
5438
|
+
fs.unlinkSync(fp);
|
|
5439
|
+
keyChanged = true;
|
|
5440
|
+
}
|
|
5441
|
+
step('owner-key', keyChanged, keyChanged ? 'owner key 已删除(密文不可再解)' : '无密钥文件');
|
|
5442
|
+
|
|
5443
|
+
const bindingPath = encAgentBindingPath(root, id);
|
|
5444
|
+
const bindingChanged = fs.existsSync(bindingPath);
|
|
5445
|
+
if (bindingChanged) fs.unlinkSync(bindingPath);
|
|
5446
|
+
step('binding', bindingChanged, '');
|
|
5447
|
+
|
|
5448
|
+
const pendingChanged = removePendingAgentKey(root, id);
|
|
5449
|
+
step('pending', pendingChanged, '');
|
|
5450
|
+
|
|
5451
|
+
const cacheChanged = revokeOwnerKeyCache(root, id);
|
|
5452
|
+
step('cache', cacheChanged, '');
|
|
5453
|
+
|
|
5454
|
+
const privateDir = path.join(root, PRIVATE_DIR, id);
|
|
5455
|
+
if (opts.keepMemories) {
|
|
5456
|
+
step('memories', false, '按 --keep-memories 保留');
|
|
5457
|
+
} else if (fs.existsSync(privateDir)) {
|
|
5458
|
+
fs.rmSync(privateDir, { recursive: true, force: true });
|
|
5459
|
+
step('memories', true, inventory.memoryFiles + ' 个文件已删除');
|
|
5460
|
+
} else {
|
|
5461
|
+
step('memories', false, '无目录');
|
|
5462
|
+
}
|
|
5463
|
+
|
|
5464
|
+
const tokens = loadTokens(root);
|
|
5465
|
+
if (Object.prototype.hasOwnProperty.call(tokens, id)) {
|
|
5466
|
+
delete tokens[id];
|
|
5467
|
+
saveTokens(root, tokens);
|
|
5468
|
+
step('token', true, '');
|
|
5469
|
+
} else {
|
|
5470
|
+
step('token', false, '无 token');
|
|
5471
|
+
}
|
|
5472
|
+
|
|
5473
|
+
const grants = loadGrants(root);
|
|
5474
|
+
let grantChanged = false;
|
|
5475
|
+
if (Object.prototype.hasOwnProperty.call(grants, id)) {
|
|
5476
|
+
delete grants[id];
|
|
5477
|
+
grantChanged = true;
|
|
5478
|
+
}
|
|
5479
|
+
for (const key of Object.keys(grants)) {
|
|
5480
|
+
if (!Array.isArray(grants[key]) || grants[key].indexOf(id) === -1) continue;
|
|
5481
|
+
grants[key] = grants[key].filter(function (x) { return x !== id; });
|
|
5482
|
+
grantChanged = true;
|
|
5483
|
+
}
|
|
5484
|
+
if (grantChanged) fs.writeFileSync(path.join(root, 'grants.json'), JSON.stringify(grants, null, 2), 'utf8');
|
|
5485
|
+
step('grants', grantChanged, '');
|
|
5486
|
+
|
|
5487
|
+
let reindexed = false;
|
|
5488
|
+
try { buildIndex(root); reindexed = true; } catch (error) { reindexed = false; }
|
|
5489
|
+
const auditOk = appendAudit(root, 'audit', {
|
|
5490
|
+
action: 'identity_remove',
|
|
5491
|
+
ts: new Date().toISOString(),
|
|
5492
|
+
transaction: guard.transaction,
|
|
5493
|
+
owner: id,
|
|
5494
|
+
snapshot: guard.snapshot ? guard.snapshot.id : '',
|
|
5495
|
+
authority: authority.mode,
|
|
5496
|
+
inventory: inventory,
|
|
5497
|
+
removed: steps.filter(function (s) { return s.changed; }).map(function (s) { return s.id; }),
|
|
5498
|
+
});
|
|
5499
|
+
step('reindex', reindexed || auditOk, 'reindex=' + (reindexed ? 'ok' : 'failed') + ' audit=' + (auditOk ? 'ok' : 'failed'));
|
|
5500
|
+
|
|
5501
|
+
const removed = steps.filter(function (s) { return s.changed; }).map(function (s) { return s.id; });
|
|
5502
|
+
const lines = [summary, '', '执行结果(' + removed.length + ' / ' + steps.length + ' 步有改动):'];
|
|
5503
|
+
for (const row of steps) {
|
|
5504
|
+
lines.push('- [' + (row.changed ? '已删除' : '未改动') + '] ' + row.label + (row.detail ? ':' + row.detail : ''));
|
|
5505
|
+
}
|
|
5506
|
+
lines.push('');
|
|
5507
|
+
lines.push('- 事务快照: ' + (guard.snapshot ? guard.snapshot.id : '(无)'));
|
|
5508
|
+
lines.push('- 公共明文 FACT 属共享事实,本命令不会删除;如需清理请单独用 forget / maintain 处理。');
|
|
5509
|
+
return {
|
|
5510
|
+
error: false,
|
|
5511
|
+
exitCode: 0,
|
|
5512
|
+
removed: removed,
|
|
5513
|
+
steps: steps,
|
|
5514
|
+
inventory: inventory,
|
|
5515
|
+
authority: authority.mode,
|
|
5516
|
+
snapshot: guard.snapshot,
|
|
5517
|
+
transaction: guard.transaction,
|
|
5518
|
+
text: lines.join('\n'),
|
|
5519
|
+
};
|
|
5520
|
+
}
|
|
5521
|
+
|
|
5522
|
+
function keyClaimCore(root, owner, opts) {
|
|
5523
|
+
if (!isSafeAgentId(owner)) return { error: true, text: '非法 agent ID:只允许单段名称,禁止 /、\\、.. 和控制字符。' };
|
|
5524
|
+
const location = resolveAgentKeyLocation(owner, opts);
|
|
5525
|
+
if (!location.ok) return { error: true, text: location.error };
|
|
5526
|
+
const pending = encPendingPath(root, owner);
|
|
5527
|
+
if (!fs.existsSync(pending)) {
|
|
4542
5528
|
return { error: true, text: '未找到待领取 agent_key:' + pending + '。请先在 yotta-memory view 中授权,或执行 yotta-memory key bind ' + owner + '。' };
|
|
4543
5529
|
}
|
|
4544
5530
|
let agentKey;
|
|
@@ -4873,6 +5859,25 @@ function viewServerCore(root, port, host, opts) {
|
|
|
4873
5859
|
json(200, { ok: true });
|
|
4874
5860
|
});
|
|
4875
5861
|
}
|
|
5862
|
+
if (req.method === 'POST' && pathname === '/api/identity-remove') {
|
|
5863
|
+
if (!session.umk) return json(401, { error: '请先解锁。' });
|
|
5864
|
+
return readBody(function (d) {
|
|
5865
|
+
const owner = String((d && d.owner) || '');
|
|
5866
|
+
const confirm = String((d && d.confirm) || '');
|
|
5867
|
+
if (!isSafeAgentId(owner)) return json(400, { error: '非法 owner ID:只允许单段名称,禁止 /、\\、.. 和控制字符。' });
|
|
5868
|
+
if (confirm !== owner) return json(400, { error: '确认串不匹配:请输入完整 agent ID 以确认删除。' });
|
|
5869
|
+
const result = identityRemoveCore(root, owner, {
|
|
5870
|
+
yes: true,
|
|
5871
|
+
userUnlocked: true,
|
|
5872
|
+
snapshotDir: opts.snapshotDir,
|
|
5873
|
+
backupDir: opts.backupDir,
|
|
5874
|
+
allowSameVolumeForTest: !!opts.allowSameVolumeForTest,
|
|
5875
|
+
now: opts.now,
|
|
5876
|
+
});
|
|
5877
|
+
if (result.error) return json(400, { error: result.text });
|
|
5878
|
+
json(200, { ok: true, removed: result.removed, steps: result.steps, text: result.text });
|
|
5879
|
+
});
|
|
5880
|
+
}
|
|
4876
5881
|
if (req.method === 'POST' && pathname === '/api/reset-password') {
|
|
4877
5882
|
if (!session.umk) return json(401, { error: '请先解锁。' });
|
|
4878
5883
|
return readBody(function (d) {
|
|
@@ -5157,6 +6162,8 @@ function cmdDoctor(opts) {
|
|
|
5157
6162
|
if (opts.json) console.log(JSON.stringify(r, null, 2));
|
|
5158
6163
|
else console.log(r.text);
|
|
5159
6164
|
if (!r.ok) process.exit(2);
|
|
6165
|
+
if (r.baselineError) process.exit(2);
|
|
6166
|
+
if (r.checks && r.checks.baseline && r.checks.baseline.error === false && r.checks.baseline.ok === false) process.exit(2);
|
|
5160
6167
|
}
|
|
5161
6168
|
function cmdReindex() {
|
|
5162
6169
|
const roots = memoryRoots();
|
|
@@ -5218,18 +6225,20 @@ function importCore(root, src) {
|
|
|
5218
6225
|
const owner = meta.owner || '';
|
|
5219
6226
|
const scope = meta.scope || defaultScope(t);
|
|
5220
6227
|
if (encrypted && scope === 'private' && !getOwnerKeyFor(root, owner)) { skippedPriv++; continue; }
|
|
5221
|
-
|
|
6228
|
+
// 导入沿用条目的 created 日期决定分层目录,避免历史记忆被塞进当前月。
|
|
6229
|
+
const importDate = /^\d{4}-\d{2}-\d{2}$/.test(String(meta.created || '')) ? String(meta.created) : today();
|
|
6230
|
+
const dir = entryWriteDir(root, t, owner, importDate);
|
|
5222
6231
|
fs.mkdirSync(dir, { recursive: true });
|
|
5223
|
-
let seq =
|
|
6232
|
+
let seq = nextSeqFor(path.join(root, typeSubdir(t, owner)), dir);
|
|
5224
6233
|
const suffix = (encrypted && scope === 'private') ? ENC_SUFFIX : '';
|
|
5225
|
-
let file = path.join(dir,
|
|
5226
|
-
while (fs.existsSync(file)) { seq = String(parseInt(seq, 10) + 1).padStart(4, '0'); file = path.join(dir,
|
|
6234
|
+
let file = path.join(dir, importDate + '-' + seq + '.md' + suffix);
|
|
6235
|
+
while (fs.existsSync(file)) { seq = String(parseInt(seq, 10) + 1).padStart(4, '0'); file = path.join(dir, importDate + '-' + seq + '.md' + suffix); }
|
|
5227
6236
|
const rec = {
|
|
5228
6237
|
type: t,
|
|
5229
6238
|
subject: meta.subject || '',
|
|
5230
6239
|
statement: meta.statement || '',
|
|
5231
6240
|
confidence: parseFloat(meta.confidence || 1.0),
|
|
5232
|
-
created:
|
|
6241
|
+
created: importDate,
|
|
5233
6242
|
updated: meta.updated || today(),
|
|
5234
6243
|
tags: parseTags(meta.tags),
|
|
5235
6244
|
immutable: meta.immutable === true || meta.immutable === 'true',
|
|
@@ -5615,6 +6624,9 @@ function contextCore(opts) {
|
|
|
5615
6624
|
const unsafe = !!opts.unsafe;
|
|
5616
6625
|
const budget = opts.budget ? parseInt(opts.budget, 10) : 0;
|
|
5617
6626
|
const focus = opts.focus ? String(opts.focus) : '';
|
|
6627
|
+
const yearFilter = normalizeYearList(opts.years);
|
|
6628
|
+
if (yearFilter.error) return { error: true, exitCode: 2, text: yearFilter.error };
|
|
6629
|
+
const years = yearFilter.years;
|
|
5618
6630
|
const explain = !!opts.explain;
|
|
5619
6631
|
const embeddingCommand = effectiveEmbeddingCommand(opts);
|
|
5620
6632
|
const embeddingTimeout = effectiveEmbeddingTimeout(opts);
|
|
@@ -5692,7 +6704,7 @@ function contextCore(opts) {
|
|
|
5692
6704
|
|
|
5693
6705
|
const readableEntries = [];
|
|
5694
6706
|
for (const r of roots) {
|
|
5695
|
-
for (const e of
|
|
6707
|
+
for (const e of indexEntriesFor(r, { years: years }).entries) {
|
|
5696
6708
|
if (classifyRead(e, owner, '', unsafe, selfAgent) === 'denied') continue;
|
|
5697
6709
|
readableEntries.push(e);
|
|
5698
6710
|
}
|
|
@@ -5716,6 +6728,7 @@ function contextCore(opts) {
|
|
|
5716
6728
|
owner: owner,
|
|
5717
6729
|
unsafe: unsafe,
|
|
5718
6730
|
selfAgent: selfAgent,
|
|
6731
|
+
years: years,
|
|
5719
6732
|
embedding: embeddingCommand,
|
|
5720
6733
|
embeddingTimeout: embeddingTimeout
|
|
5721
6734
|
});
|
|
@@ -5805,12 +6818,1100 @@ function cmdContext(opts) {
|
|
|
5805
6818
|
if (r.exitCode) process.exit(r.exitCode);
|
|
5806
6819
|
}
|
|
5807
6820
|
|
|
6821
|
+
// ---- v0.17.0 A1: bench 可复算基准评测(只读 / 本地 / 零依赖)----
|
|
6822
|
+
const BENCH_SCHEMA_VERSION = 1;
|
|
6823
|
+
const BENCH_DEFAULT_SEED = 20260925;
|
|
6824
|
+
const BENCH_DEFAULT_BOOTSTRAP = 1000;
|
|
6825
|
+
const BENCH_AUTO_QUERY_LIMIT = 20;
|
|
6826
|
+
const BENCH_GATE_METRICS = {
|
|
6827
|
+
recall: 'recallAtK',
|
|
6828
|
+
recall_at_k: 'recallAtK',
|
|
6829
|
+
mrr: 'mrr',
|
|
6830
|
+
ndcg: 'ndcgAtK',
|
|
6831
|
+
ndcg_at_k: 'ndcgAtK',
|
|
6832
|
+
hit: 'hitRate',
|
|
6833
|
+
hitrate: 'hitRate',
|
|
6834
|
+
hit_rate: 'hitRate',
|
|
6835
|
+
};
|
|
6836
|
+
// 消融四组:关键词 / 语义 × 融合(0.65 语义 + 0.35 效用)/ 纯分排序
|
|
6837
|
+
const BENCH_VARIANTS = [
|
|
6838
|
+
{ variant: 'semantic-fused', semantic: true, fuse: true },
|
|
6839
|
+
{ variant: 'lexical-fused', semantic: false, fuse: true },
|
|
6840
|
+
{ variant: 'semantic-score', semantic: true, fuse: false },
|
|
6841
|
+
{ variant: 'lexical-score', semantic: false, fuse: false },
|
|
6842
|
+
];
|
|
6843
|
+
function sha256Hex(buf) { return crypto.createHash('sha256').update(buf).digest('hex'); }
|
|
6844
|
+
function round4(n) { return Math.round((Number(n) || 0) * 10000) / 10000; }
|
|
6845
|
+
function seededRandom(seed) {
|
|
6846
|
+
let a = (Number(seed) || 0) >>> 0;
|
|
6847
|
+
if (!a) a = 1;
|
|
6848
|
+
return function () {
|
|
6849
|
+
a = (a + 0x6D2B79F5) >>> 0;
|
|
6850
|
+
let t = a;
|
|
6851
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
6852
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
6853
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
6854
|
+
};
|
|
6855
|
+
}
|
|
6856
|
+
// 索引指纹:index.json + 各分片内容一起哈希,改一条记忆就会变。
|
|
6857
|
+
function indexFingerprint(root) {
|
|
6858
|
+
const manifestPath = indexPath(root);
|
|
6859
|
+
if (!fs.existsSync(manifestPath)) return { sha256: '', bytes: 0, files: 0 };
|
|
6860
|
+
const parts = ['ytm-index-fingerprint-v1\n'];
|
|
6861
|
+
let bytes = 0;
|
|
6862
|
+
let files = 0;
|
|
6863
|
+
const manifestBytes = fs.readFileSync(manifestPath);
|
|
6864
|
+
bytes += manifestBytes.length;
|
|
6865
|
+
files++;
|
|
6866
|
+
parts.push(INDEX_FILE + '\n' + sha256Hex(manifestBytes) + '\n');
|
|
6867
|
+
let shards = [];
|
|
6868
|
+
try {
|
|
6869
|
+
const d = JSON.parse(manifestBytes.toString('utf8'));
|
|
6870
|
+
if (d && Array.isArray(d.shards)) shards = d.shards;
|
|
6871
|
+
} catch (e) { /* ignore */ }
|
|
6872
|
+
for (const sh of shards) {
|
|
6873
|
+
if (!isSafeShardName(sh)) continue;
|
|
6874
|
+
const sp = path.join(root, sh);
|
|
6875
|
+
if (!fs.existsSync(sp)) continue;
|
|
6876
|
+
const buf = fs.readFileSync(sp);
|
|
6877
|
+
bytes += buf.length;
|
|
6878
|
+
files++;
|
|
6879
|
+
parts.push(sh + '\n' + sha256Hex(buf) + '\n');
|
|
6880
|
+
}
|
|
6881
|
+
return { sha256: sha256Hex(Buffer.from(parts.join(''), 'utf8')), bytes: bytes, files: files };
|
|
6882
|
+
}
|
|
6883
|
+
function benchEvalsetDigest(queries) {
|
|
6884
|
+
return sha256Hex(Buffer.from(JSON.stringify({ version: 1, queries: queries }), 'utf8'));
|
|
6885
|
+
}
|
|
6886
|
+
function autoBenchQueries(entries, seed) {
|
|
6887
|
+
const pool = entries
|
|
6888
|
+
.filter(function (e) { return String(e.subject || '').trim().length > 0; })
|
|
6889
|
+
.sort(function (a, b) { return String(a.file).localeCompare(String(b.file)); });
|
|
6890
|
+
const count = Math.min(BENCH_AUTO_QUERY_LIMIT, pool.length);
|
|
6891
|
+
const remaining = pool.slice();
|
|
6892
|
+
const rnd = seededRandom(seed);
|
|
6893
|
+
const queries = [];
|
|
6894
|
+
for (let i = 0; i < count; i++) {
|
|
6895
|
+
const picked = remaining.splice(Math.floor(rnd() * remaining.length), 1)[0];
|
|
6896
|
+
queries.push({ query: String(picked.subject), expect: [String(picked.file)] });
|
|
6897
|
+
}
|
|
6898
|
+
return queries;
|
|
6899
|
+
}
|
|
6900
|
+
function loadBenchEvalset(file, entries, seed) {
|
|
6901
|
+
if (!file) {
|
|
6902
|
+
const queries = autoBenchQueries(entries, seed);
|
|
6903
|
+
return { source: 'auto', file: '', queries: queries, sha256: benchEvalsetDigest(queries) };
|
|
6904
|
+
}
|
|
6905
|
+
const fp = path.resolve(String(file));
|
|
6906
|
+
if (!fs.existsSync(fp)) {
|
|
6907
|
+
return { error: '找不到评测集文件: ' + fp + '。请检查路径,或省略 --evalset 用库内自动基线集。' };
|
|
6908
|
+
}
|
|
6909
|
+
let data = null;
|
|
6910
|
+
try {
|
|
6911
|
+
data = JSON.parse(fs.readFileSync(fp, 'utf8'));
|
|
6912
|
+
} catch (e) {
|
|
6913
|
+
return { error: '评测集不是合法 JSON: ' + fp + '(' + (e && e.message ? e.message : String(e)) + ')' };
|
|
6914
|
+
}
|
|
6915
|
+
if (!data || data.version !== 1) {
|
|
6916
|
+
return { error: '评测集版本不支持: ' + fp + '。bench 评测集 v1 格式为 { "version": 1, "queries": [ { "query": "...", "expect": ["<记忆 id>"] } ] }。' };
|
|
6917
|
+
}
|
|
6918
|
+
if (!Array.isArray(data.queries) || !data.queries.length) {
|
|
6919
|
+
return { error: '评测集缺少 queries: ' + fp + '。至少需要一条 { "query": "...", "expect": ["<记忆 id>"] }。' };
|
|
6920
|
+
}
|
|
6921
|
+
const queries = [];
|
|
6922
|
+
for (const row of data.queries) {
|
|
6923
|
+
const query = row && typeof row.query === 'string' ? row.query.trim() : '';
|
|
6924
|
+
const expect = row && Array.isArray(row.expect) ? row.expect.map(String).filter(Boolean) : [];
|
|
6925
|
+
if (!query) return { error: '评测集存在空 query: ' + fp + '。每条查询都需要非空 query 字符串。' };
|
|
6926
|
+
if (!expect.length) return { error: '评测集缺少 expect: ' + fp + '(query=' + query + ')。expect 必须是记忆 id 数组。' };
|
|
6927
|
+
queries.push({ query: query, expect: expect });
|
|
6928
|
+
}
|
|
6929
|
+
return { source: 'file', file: fp, queries: queries, sha256: benchEvalsetDigest(queries) };
|
|
6930
|
+
}
|
|
6931
|
+
function parseBenchGates(list) {
|
|
6932
|
+
const out = [];
|
|
6933
|
+
for (const raw of list || []) {
|
|
6934
|
+
const text = String(raw);
|
|
6935
|
+
const cut = text.indexOf('=');
|
|
6936
|
+
if (cut <= 0) {
|
|
6937
|
+
return { error: '门禁格式不正确: ' + text + '。应为 <指标>=<数值>,例如 --gate mrr=0.6;指标可选 recall / mrr / ndcg / hit。' };
|
|
6938
|
+
}
|
|
6939
|
+
const metric = text.slice(0, cut).trim().toLowerCase();
|
|
6940
|
+
const value = parseFloat(text.slice(cut + 1));
|
|
6941
|
+
if (!BENCH_GATE_METRICS[metric]) {
|
|
6942
|
+
return { error: '门禁指标不支持: ' + text.slice(0, cut).trim() + '。可选指标: recall / mrr / ndcg / hit。' };
|
|
6943
|
+
}
|
|
6944
|
+
if (!isFinite(value)) {
|
|
6945
|
+
return { error: '门禁数值不正确: ' + text + '。门禁需要可比较的数值,例如 --gate mrr=0.6。' };
|
|
6946
|
+
}
|
|
6947
|
+
out.push({ metric: metric, key: BENCH_GATE_METRICS[metric], value: value });
|
|
6948
|
+
}
|
|
6949
|
+
return { gates: out, error: '' };
|
|
6950
|
+
}
|
|
6951
|
+
function entryMatchesExpect(entry, expectSet) {
|
|
6952
|
+
const ref = String((entry && entry.file) || '');
|
|
6953
|
+
return expectSet.has(ref) || expectSet.has(path.basename(ref));
|
|
6954
|
+
}
|
|
6955
|
+
function benchQueryMetrics(hits, expect, k) {
|
|
6956
|
+
const expectSet = new Set((expect || []).map(String));
|
|
6957
|
+
const top = hits.slice(0, k);
|
|
6958
|
+
let found = 0;
|
|
6959
|
+
let rr = 0;
|
|
6960
|
+
let dcg = 0;
|
|
6961
|
+
for (let i = 0; i < top.length; i++) {
|
|
6962
|
+
if (!entryMatchesExpect(top[i].entry, expectSet)) continue;
|
|
6963
|
+
found++;
|
|
6964
|
+
if (!rr) rr = 1 / (i + 1);
|
|
6965
|
+
dcg += 1 / Math.log2(i + 2);
|
|
6966
|
+
}
|
|
6967
|
+
let idcg = 0;
|
|
6968
|
+
const idealCount = Math.min(k, expectSet.size);
|
|
6969
|
+
for (let i = 0; i < idealCount; i++) idcg += 1 / Math.log2(i + 2);
|
|
6970
|
+
return {
|
|
6971
|
+
recall: expectSet.size ? Math.min(1, found / expectSet.size) : 0,
|
|
6972
|
+
mrr: rr,
|
|
6973
|
+
ndcg: idcg > 0 ? Math.min(1, dcg / idcg) : 0,
|
|
6974
|
+
hit: found ? 1 : 0,
|
|
6975
|
+
};
|
|
6976
|
+
}
|
|
6977
|
+
function bootstrapCI(values, seed, rounds) {
|
|
6978
|
+
if (!values.length) return [0, 0];
|
|
6979
|
+
const mean = values.reduce(function (s, v) { return s + v; }, 0) / values.length;
|
|
6980
|
+
const n = parseInt(rounds, 10) || 0;
|
|
6981
|
+
if (n <= 0) return [round4(mean), round4(mean)];
|
|
6982
|
+
const rnd = seededRandom(seed);
|
|
6983
|
+
const means = [];
|
|
6984
|
+
for (let b = 0; b < n; b++) {
|
|
6985
|
+
let sum = 0;
|
|
6986
|
+
for (let i = 0; i < values.length; i++) sum += values[Math.floor(rnd() * values.length)];
|
|
6987
|
+
means.push(sum / values.length);
|
|
6988
|
+
}
|
|
6989
|
+
means.sort(function (a, b) { return a - b; });
|
|
6990
|
+
const pick = function (p) {
|
|
6991
|
+
const idx = Math.min(means.length - 1, Math.max(0, Math.floor(p * (means.length - 1))));
|
|
6992
|
+
return round4(means[idx]);
|
|
6993
|
+
};
|
|
6994
|
+
return [pick(0.025), pick(0.975)];
|
|
6995
|
+
}
|
|
6996
|
+
function benchEvaluate(corpusByRoot, queries, options) {
|
|
6997
|
+
const perQuery = [];
|
|
6998
|
+
for (const row of queries) {
|
|
6999
|
+
const prefilter = options.semantic ? recallPrefilter(row.query) : null;
|
|
7000
|
+
const hits = [];
|
|
7001
|
+
for (const group of corpusByRoot) {
|
|
7002
|
+
const scored = scoreCandidates(group.entries, row.query, { semantic: options.semantic, prefilter: prefilter });
|
|
7003
|
+
for (const s of scored) hits.push({ entry: s.entry, score: s.score, root: group.root, detail: s.detail });
|
|
7004
|
+
}
|
|
7005
|
+
rankHits(hits, { fuse: options.fuse !== false, sortByScore: options.fuse === false });
|
|
7006
|
+
perQuery.push(benchQueryMetrics(hits, row.expect, options.k));
|
|
7007
|
+
}
|
|
7008
|
+
const mean = function (pick) {
|
|
7009
|
+
if (!perQuery.length) return 0;
|
|
7010
|
+
return perQuery.reduce(function (s, row) { return s + pick(row); }, 0) / perQuery.length;
|
|
7011
|
+
};
|
|
7012
|
+
const metrics = {
|
|
7013
|
+
recallAtK: round4(mean(function (r) { return r.recall; })),
|
|
7014
|
+
mrr: round4(mean(function (r) { return r.mrr; })),
|
|
7015
|
+
ndcgAtK: round4(mean(function (r) { return r.ndcg; })),
|
|
7016
|
+
hitRate: round4(mean(function (r) { return r.hit; })),
|
|
7017
|
+
};
|
|
7018
|
+
const ci95 = {
|
|
7019
|
+
recallAtK: bootstrapCI(perQuery.map(function (r) { return r.recall; }), options.seed, options.bootstrap),
|
|
7020
|
+
mrr: bootstrapCI(perQuery.map(function (r) { return r.mrr; }), options.seed, options.bootstrap),
|
|
7021
|
+
ndcgAtK: bootstrapCI(perQuery.map(function (r) { return r.ndcg; }), options.seed, options.bootstrap),
|
|
7022
|
+
hitRate: bootstrapCI(perQuery.map(function (r) { return r.hit; }), options.seed, options.bootstrap),
|
|
7023
|
+
};
|
|
7024
|
+
return { metrics: metrics, ci95: ci95, perQuery: perQuery.length };
|
|
7025
|
+
}
|
|
7026
|
+
function benchCore(opts) {
|
|
7027
|
+
opts = opts || {};
|
|
7028
|
+
const k = parseInt(opts.k, 10) > 0 ? Math.min(50, parseInt(opts.k, 10)) : 5;
|
|
7029
|
+
const seed = opts.seed === undefined || opts.seed === null || isNaN(parseInt(opts.seed, 10)) ? BENCH_DEFAULT_SEED : parseInt(opts.seed, 10);
|
|
7030
|
+
if (seed < 0) return { error: true, exitCode: 2, text: '随机种子必须是非负整数: ' + opts.seed };
|
|
7031
|
+
const bootstrap = opts.bootstrap === undefined || opts.bootstrap === null || isNaN(parseInt(opts.bootstrap, 10)) ? BENCH_DEFAULT_BOOTSTRAP : parseInt(opts.bootstrap, 10);
|
|
7032
|
+
if (bootstrap < 0) return { error: true, exitCode: 2, text: 'bootstrap 次数必须是非负整数: ' + opts.bootstrap };
|
|
7033
|
+
const yearFilter = normalizeYearList(opts.years);
|
|
7034
|
+
if (yearFilter.error) return { error: true, exitCode: 2, text: yearFilter.error };
|
|
7035
|
+
const years = yearFilter.years;
|
|
7036
|
+
const gates = parseBenchGates(opts.gate);
|
|
7037
|
+
if (gates.error) return { error: true, exitCode: 2, text: gates.error };
|
|
7038
|
+
|
|
7039
|
+
const roots = memoryRoots();
|
|
7040
|
+
if (!roots.length) return { error: true, exitCode: 2, text: '记忆库不存在,请先运行: yotta-memory init' };
|
|
7041
|
+
const ident = resolveIdentity(opts);
|
|
7042
|
+
if (ident.error) return { error: true, exitCode: 3, text: ident.error };
|
|
7043
|
+
const selfAgent = ident.id;
|
|
7044
|
+
|
|
7045
|
+
const corpusByRoot = [];
|
|
7046
|
+
const rootReports = [];
|
|
7047
|
+
let denied = 0;
|
|
7048
|
+
let privateSkipped = 0;
|
|
7049
|
+
for (const root of roots) {
|
|
7050
|
+
const pub = loadIndexFor(root, { years: years });
|
|
7051
|
+
if (!pub) {
|
|
7052
|
+
return { error: true, exitCode: 2, text: '索引缺失或版本过旧(' + root + '):请先运行 yotta-memory reindex,再执行 bench。' };
|
|
7053
|
+
}
|
|
7054
|
+
const entries = [];
|
|
7055
|
+
for (const e of pub) {
|
|
7056
|
+
if (classifyRead(e, selfAgent, '', false, selfAgent) === 'denied') { denied++; continue; }
|
|
7057
|
+
entries.push(e);
|
|
7058
|
+
}
|
|
7059
|
+
if (isEncrypted(root)) {
|
|
7060
|
+
for (const owner of collectOwners(root)) {
|
|
7061
|
+
const key = getOwnerKeyFor(root, owner, { id: ident.id, agentKey: ident.agentKey });
|
|
7062
|
+
if (!key) { privateSkipped++; continue; }
|
|
7063
|
+
let idx = [];
|
|
7064
|
+
try { idx = loadOwnerIndex(root, owner, key); } catch (err) { idx = []; }
|
|
7065
|
+
for (const e of idx) {
|
|
7066
|
+
if (years.length && years.indexOf(entryYear(e)) === -1) continue;
|
|
7067
|
+
if (classifyRead(e, selfAgent, '', false, selfAgent) === 'denied') { denied++; continue; }
|
|
7068
|
+
entries.push(e);
|
|
7069
|
+
}
|
|
7070
|
+
}
|
|
7071
|
+
}
|
|
7072
|
+
corpusByRoot.push({ root: root, entries: entries });
|
|
7073
|
+
rootReports.push({ path: root, entries: entries.length, index: indexFingerprint(root) });
|
|
7074
|
+
}
|
|
7075
|
+
const corpusEntries = [];
|
|
7076
|
+
for (const group of corpusByRoot) for (const e of group.entries) corpusEntries.push(e);
|
|
7077
|
+
|
|
7078
|
+
const evalset = loadBenchEvalset(opts.evalset, corpusEntries, seed);
|
|
7079
|
+
if (evalset.error) return { error: true, exitCode: 2, text: evalset.error };
|
|
7080
|
+
|
|
7081
|
+
const baseOptions = { k: k, seed: seed, bootstrap: bootstrap, semantic: true, fuse: true };
|
|
7082
|
+
let timing = null;
|
|
7083
|
+
if (opts.timing) {
|
|
7084
|
+
const samples = [];
|
|
7085
|
+
for (const row of evalset.queries) {
|
|
7086
|
+
const started = process.hrtime.bigint();
|
|
7087
|
+
benchEvaluate(corpusByRoot, [row], baseOptions);
|
|
7088
|
+
samples.push(Number(process.hrtime.bigint() - started) / 1e6);
|
|
7089
|
+
}
|
|
7090
|
+
samples.sort(function (a, b) { return a - b; });
|
|
7091
|
+
const pick = function (p) { return round4(samples[Math.min(samples.length - 1, Math.max(0, Math.round(p * (samples.length - 1))))]); };
|
|
7092
|
+
timing = { p50Ms: pick(0.5), p95Ms: pick(0.95) };
|
|
7093
|
+
}
|
|
7094
|
+
const baseline = benchEvaluate(corpusByRoot, evalset.queries, baseOptions);
|
|
7095
|
+
const gateResults = (gates.gates || []).map(function (g) {
|
|
7096
|
+
const actual = baseline.metrics[g.key];
|
|
7097
|
+
return { metric: g.metric, value: g.value, actual: actual, pass: actual >= g.value };
|
|
7098
|
+
});
|
|
7099
|
+
const level = gateResults.every(function (g) { return g.pass; }) ? 'ok' : 'fail';
|
|
7100
|
+
const report = {
|
|
7101
|
+
schemaVersion: BENCH_SCHEMA_VERSION,
|
|
7102
|
+
command: 'bench',
|
|
7103
|
+
version: VERSION,
|
|
7104
|
+
params: { k: k, seed: seed, bootstrap: bootstrap },
|
|
7105
|
+
corpus: {
|
|
7106
|
+
entries: corpusEntries.length,
|
|
7107
|
+
denied: denied,
|
|
7108
|
+
privateSkipped: privateSkipped,
|
|
7109
|
+
roots: rootReports,
|
|
7110
|
+
},
|
|
7111
|
+
evalset: {
|
|
7112
|
+
source: evalset.source,
|
|
7113
|
+
file: evalset.file || '',
|
|
7114
|
+
sha256: evalset.sha256,
|
|
7115
|
+
queries: evalset.queries.length,
|
|
7116
|
+
},
|
|
7117
|
+
metrics: Object.assign({}, baseline.metrics, { ci95: baseline.ci95 }),
|
|
7118
|
+
gates: gateResults,
|
|
7119
|
+
embedding: {
|
|
7120
|
+
status: 'not-run',
|
|
7121
|
+
reason: 'bench 只跑本地确定性检索(关键词 / 语义),不调用外部 embedding 插件,保证可复算与只读',
|
|
7122
|
+
},
|
|
7123
|
+
reproducible: !opts.timing,
|
|
7124
|
+
level: level,
|
|
7125
|
+
};
|
|
7126
|
+
if (years.length) report.years = years.slice();
|
|
7127
|
+
if (opts.ablate) {
|
|
7128
|
+
report.ablation = BENCH_VARIANTS.map(function (v) {
|
|
7129
|
+
const result = benchEvaluate(corpusByRoot, evalset.queries, { k: k, seed: seed, bootstrap: bootstrap, semantic: v.semantic, fuse: v.fuse });
|
|
7130
|
+
return { variant: v.variant, metrics: result.metrics };
|
|
7131
|
+
});
|
|
7132
|
+
}
|
|
7133
|
+
if (timing) report.timing = timing;
|
|
7134
|
+
return { error: false, exitCode: level === 'fail' ? 1 : 0, text: renderBenchText(report), report: report };
|
|
7135
|
+
}
|
|
7136
|
+
function renderBenchText(report) {
|
|
7137
|
+
const lines = [];
|
|
7138
|
+
const m = report.metrics;
|
|
7139
|
+
lines.push('# 元忆基准评测(bench)');
|
|
7140
|
+
lines.push('');
|
|
7141
|
+
lines.push('- 记忆库: ' + report.corpus.roots.map(function (r) { return r.path; }).join('、'));
|
|
7142
|
+
lines.push('- 条目数: ' + report.corpus.entries +
|
|
7143
|
+
';索引指纹: ' + report.corpus.roots.map(function (r) { return String(r.index.sha256).slice(0, 12) + '…'; }).join('、') +
|
|
7144
|
+
'(' + report.corpus.roots.map(function (r) { return r.index.files + ' 个文件 / ' + r.index.bytes + ' 字节'; }).join(';') + ')');
|
|
7145
|
+
lines.push('- 评测集: ' + (report.evalset.source === 'auto' ? '库内自动基线集(确定性抽样)' : report.evalset.file) +
|
|
7146
|
+
';sha256 ' + report.evalset.sha256.slice(0, 12) + '…;' + report.evalset.queries + ' 条查询');
|
|
7147
|
+
lines.push('- 参数: k=' + report.params.k + ';seed=' + report.params.seed + ';bootstrap=' + report.params.bootstrap +
|
|
7148
|
+
(report.years && report.years.length ? ';年份=' + report.years.join(',') : ''));
|
|
7149
|
+
lines.push('');
|
|
7150
|
+
lines.push('检索质量:');
|
|
7151
|
+
lines.push('- Recall@' + report.params.k + ': ' + m.recallAtK + '(95% CI ' + m.ci95.recallAtK[0] + ' - ' + m.ci95.recallAtK[1] + ')');
|
|
7152
|
+
lines.push('- MRR: ' + m.mrr + '(95% CI ' + m.ci95.mrr[0] + ' - ' + m.ci95.mrr[1] + ')');
|
|
7153
|
+
lines.push('- nDCG@' + report.params.k + ': ' + m.ndcgAtK + '(95% CI ' + m.ci95.ndcgAtK[0] + ' - ' + m.ci95.ndcgAtK[1] + ')');
|
|
7154
|
+
lines.push('- HitRate: ' + m.hitRate + '(95% CI ' + m.ci95.hitRate[0] + ' - ' + m.ci95.hitRate[1] + ')');
|
|
7155
|
+
if (report.ablation && report.ablation.length) {
|
|
7156
|
+
lines.push('');
|
|
7157
|
+
lines.push('消融对比:');
|
|
7158
|
+
for (const row of report.ablation) {
|
|
7159
|
+
lines.push('- ' + row.variant + ': Recall@' + report.params.k + ' ' + row.metrics.recallAtK +
|
|
7160
|
+
';MRR ' + row.metrics.mrr + ';nDCG@' + report.params.k + ' ' + row.metrics.ndcgAtK + ';HitRate ' + row.metrics.hitRate);
|
|
7161
|
+
}
|
|
7162
|
+
lines.push('- 口径: 关键词 / 语义 × 融合排序(0.65 语义 + 0.35 效用)/ 纯分排序;embedding 插件不参与。');
|
|
7163
|
+
}
|
|
7164
|
+
if (report.timing) {
|
|
7165
|
+
lines.push('');
|
|
7166
|
+
lines.push('检索耗时(--timing,非可复算项):');
|
|
7167
|
+
lines.push('- p50: ' + report.timing.p50Ms + ' ms;p95: ' + report.timing.p95Ms + ' ms');
|
|
7168
|
+
}
|
|
7169
|
+
lines.push('');
|
|
7170
|
+
lines.push('门禁:');
|
|
7171
|
+
if (!report.gates.length) {
|
|
7172
|
+
lines.push('- 未设置(--gate <指标>=<数值> 可接入 CI)');
|
|
7173
|
+
} else {
|
|
7174
|
+
for (const g of report.gates) {
|
|
7175
|
+
lines.push('- ' + g.metric + ' >= ' + g.value + ':' + (g.pass ? '通过' : '未通过') + '(实际 ' + g.actual + ')');
|
|
7176
|
+
}
|
|
7177
|
+
}
|
|
7178
|
+
if (report.corpus.denied) lines.push('- 已跳过 ' + report.corpus.denied + ' 条当前身份不可读的记忆');
|
|
7179
|
+
lines.push('');
|
|
7180
|
+
lines.push('复算口径:库指纹 + 评测集指纹 + 参数一致时必须同输出;默认报告不含墙钟时间,只读、不写生产库。');
|
|
7181
|
+
return lines.join('\n');
|
|
7182
|
+
}
|
|
7183
|
+
function cmdBench(opts) {
|
|
7184
|
+
const r = benchCore(opts || {});
|
|
7185
|
+
if (r.error) {
|
|
7186
|
+
console.error(r.text);
|
|
7187
|
+
process.exit(r.exitCode || 2);
|
|
7188
|
+
}
|
|
7189
|
+
const json = JSON.stringify(r.report, null, 2);
|
|
7190
|
+
if (opts && opts.out) {
|
|
7191
|
+
const target = path.resolve(String(opts.out));
|
|
7192
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
7193
|
+
fs.writeFileSync(target, json + '\n', 'utf8');
|
|
7194
|
+
}
|
|
7195
|
+
console.log(opts && opts.json ? json : r.text);
|
|
7196
|
+
if (r.exitCode) process.exit(r.exitCode);
|
|
7197
|
+
}
|
|
7198
|
+
|
|
7199
|
+
// ---- v0.17.0 A2:恢复 / 迁移基线探针(只读 / 本地 / 零依赖)----
|
|
7200
|
+
const BASELINE_SCHEMA_VERSION = 1;
|
|
7201
|
+
const BASELINE_DEFAULT_SEED = 20260925;
|
|
7202
|
+
const BASELINE_MISSING_LIMIT = 20;
|
|
7203
|
+
const CJK_RE = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/;
|
|
7204
|
+
// 探针只读遍历记忆文件(不建索引、不碰访问计数):恢复 / 迁移场景要验的是文件本身,而不是可能过期的索引。
|
|
7205
|
+
function baselineReadEntries(root) {
|
|
7206
|
+
const entries = [];
|
|
7207
|
+
const unreadable = [];
|
|
7208
|
+
for (const fp of collectEntryFiles(root)) {
|
|
7209
|
+
const rel = relOf(root, fp);
|
|
7210
|
+
const owner = ownerFromPrivatePath(root, fp);
|
|
7211
|
+
if (isEncFile(fp) && !getOwnerKeyFor(root, owner)) {
|
|
7212
|
+
unreadable.push({ file: rel, reason: 'encrypted', owner: owner });
|
|
7213
|
+
continue;
|
|
7214
|
+
}
|
|
7215
|
+
try {
|
|
7216
|
+
entries.push(readEntry(fp, root));
|
|
7217
|
+
} catch (error) {
|
|
7218
|
+
const message = error && error.message ? error.message : String(error);
|
|
7219
|
+
// .enc 后缀但内容是明文(历史恢复残留)与「密钥在位但解不开」要分开报,修法完全不同。
|
|
7220
|
+
const reason = isEncFile(fp) ? (message.indexOf('非密文文件') !== -1 ? 'mislabelled' : 'undecryptable') : 'unreadable';
|
|
7221
|
+
unreadable.push({ file: rel, reason: reason, owner: owner, error: message });
|
|
7222
|
+
}
|
|
7223
|
+
}
|
|
7224
|
+
return { entries: entries, unreadable: unreadable };
|
|
7225
|
+
}
|
|
7226
|
+
// 条目身份键与布局无关(旧平铺 facts/x.md 与新年/月分层指向同一条记忆时键相同)。
|
|
7227
|
+
function baselineEntryKey(entry) {
|
|
7228
|
+
return String(entry.type || 'FACT') + '/' + String(entry.owner || 'public') + '/' + path.basename(String(entry.file || ''));
|
|
7229
|
+
}
|
|
7230
|
+
function baselineSample(pool, seed, count) {
|
|
7231
|
+
const list = pool.slice().sort(function (a, b) { return String(a.file).localeCompare(String(b.file)); });
|
|
7232
|
+
const rnd = seededRandom(seed);
|
|
7233
|
+
const picked = [];
|
|
7234
|
+
const remaining = list.slice();
|
|
7235
|
+
for (let i = 0; i < Math.min(count, remaining.length); i++) {
|
|
7236
|
+
picked.push(remaining.splice(Math.floor(rnd() * remaining.length), 1)[0]);
|
|
7237
|
+
}
|
|
7238
|
+
return picked;
|
|
7239
|
+
}
|
|
7240
|
+
function baselineRecall(root, entries, query, limit) {
|
|
7241
|
+
const prefilter = query ? recallPrefilter(query) : null;
|
|
7242
|
+
const hits = [];
|
|
7243
|
+
for (const scored of scoreCandidates(entries, query, { semantic: true, prefilter: prefilter, root: root })) {
|
|
7244
|
+
hits.push({ entry: scored.entry, score: scored.score, root: root, detail: scored.detail });
|
|
7245
|
+
}
|
|
7246
|
+
rankHits(hits, { fuse: true });
|
|
7247
|
+
return hits.slice(0, limit || 5);
|
|
7248
|
+
}
|
|
7249
|
+
function loadBaselineTemplate(file) {
|
|
7250
|
+
const fp = path.resolve(String(file));
|
|
7251
|
+
if (!fs.existsSync(fp)) {
|
|
7252
|
+
return { error: '找不到基线模板: ' + fp + '。请检查路径,或省略 --template 用库内确定性探针。' };
|
|
7253
|
+
}
|
|
7254
|
+
let data = null;
|
|
7255
|
+
try {
|
|
7256
|
+
data = JSON.parse(fs.readFileSync(fp, 'utf8'));
|
|
7257
|
+
} catch (error) {
|
|
7258
|
+
return { error: '基线模板不是合法 JSON: ' + fp + '(' + (error && error.message ? error.message : String(error)) + ')' };
|
|
7259
|
+
}
|
|
7260
|
+
if (!data || data.version !== 1) {
|
|
7261
|
+
return { error: '基线模板版本不支持: ' + fp + '。v1 格式为 { "version": 1, "expect_owners": [], "expect_min_entries": 0, "expect_types": {}, "queries": [] }。' };
|
|
7262
|
+
}
|
|
7263
|
+
const out = { version: 1, file: fp, expect_owners: [], expect_min_entries: 0, expect_types: {}, queries: [] };
|
|
7264
|
+
if (data.expect_owners !== undefined) {
|
|
7265
|
+
if (!Array.isArray(data.expect_owners)) return { error: '基线模板 expect_owners 必须是数组: ' + fp };
|
|
7266
|
+
out.expect_owners = data.expect_owners.map(String).filter(Boolean);
|
|
7267
|
+
}
|
|
7268
|
+
if (data.expect_min_entries !== undefined) {
|
|
7269
|
+
const n = parseInt(data.expect_min_entries, 10);
|
|
7270
|
+
if (!isFinite(n) || n < 0) return { error: '基线模板 expect_min_entries 必须是非负整数: ' + fp };
|
|
7271
|
+
out.expect_min_entries = n;
|
|
7272
|
+
}
|
|
7273
|
+
if (data.expect_types !== undefined) {
|
|
7274
|
+
if (!data.expect_types || typeof data.expect_types !== 'object' || Array.isArray(data.expect_types)) {
|
|
7275
|
+
return { error: '基线模板 expect_types 必须是 { "类型": 最小条数 } 对象: ' + fp };
|
|
7276
|
+
}
|
|
7277
|
+
for (const key of Object.keys(data.expect_types)) {
|
|
7278
|
+
const type = String(key).toUpperCase();
|
|
7279
|
+
if (TYPES.indexOf(type) === -1) {
|
|
7280
|
+
return { error: '基线模板 expect_types 含未知类型 ' + key + '(可选: ' + TYPES.join(' / ') + '): ' + fp };
|
|
7281
|
+
}
|
|
7282
|
+
const n = parseInt(data.expect_types[key], 10);
|
|
7283
|
+
if (!isFinite(n) || n < 0) return { error: '基线模板 expect_types.' + key + ' 必须是非负整数: ' + fp };
|
|
7284
|
+
out.expect_types[type] = n;
|
|
7285
|
+
}
|
|
7286
|
+
}
|
|
7287
|
+
if (data.queries !== undefined) {
|
|
7288
|
+
if (!Array.isArray(data.queries)) return { error: '基线模板 queries 必须是数组: ' + fp };
|
|
7289
|
+
for (const row of data.queries) {
|
|
7290
|
+
const query = row && typeof row.query === 'string' ? row.query.trim() : '';
|
|
7291
|
+
const expect = row && Array.isArray(row.expect) ? row.expect.map(String).filter(Boolean) : [];
|
|
7292
|
+
if (!query) return { error: '基线模板存在空 query: ' + fp };
|
|
7293
|
+
if (!expect.length) return { error: '基线模板缺少 expect(query=' + query + '): ' + fp };
|
|
7294
|
+
out.queries.push({ id: String((row && row.id) || query), query: query, expect: expect });
|
|
7295
|
+
}
|
|
7296
|
+
}
|
|
7297
|
+
return out;
|
|
7298
|
+
}
|
|
7299
|
+
// 六类固定探针:身份 / 近期 / 仅源库独有 / CJK / 操作规则 / owner 范围;模板可在其上追加显式期望。
|
|
7300
|
+
function baselineCore(opts) {
|
|
7301
|
+
opts = opts || {};
|
|
7302
|
+
const root = path.resolve(opts.root || userRoot());
|
|
7303
|
+
const seed = parseInt(opts.seed, 10) || BASELINE_DEFAULT_SEED;
|
|
7304
|
+
const empty = { schemaVersion: BASELINE_SCHEMA_VERSION, error: true, exitCode: 2, ok: false, level: 'fail', root: root, against: '', template: '', seed: seed, counts: {}, probes: [], unreadable: [] };
|
|
7305
|
+
let template = null;
|
|
7306
|
+
if (opts.template) {
|
|
7307
|
+
template = loadBaselineTemplate(opts.template);
|
|
7308
|
+
if (template.error) return Object.assign({}, empty, { text: template.error });
|
|
7309
|
+
}
|
|
7310
|
+
const against = opts.against ? path.resolve(String(opts.against)) : '';
|
|
7311
|
+
if (against && !fs.existsSync(against)) {
|
|
7312
|
+
return Object.assign({}, empty, { against: against, text: '找不到对比库: ' + against + '。--against 需要指向另一个记忆库根目录(通常是迁移前的源库)。' });
|
|
7313
|
+
}
|
|
7314
|
+
const ident = resolveIdentity(opts);
|
|
7315
|
+
const selfAgent = (ident && !ident.error && ident.id) ? ident.id : '';
|
|
7316
|
+
const read = baselineReadEntries(root);
|
|
7317
|
+
const entries = read.entries;
|
|
7318
|
+
const readable = entries.filter(function (e) { return classifyRead(e, selfAgent, '', false, selfAgent) !== 'denied'; });
|
|
7319
|
+
const probes = [];
|
|
7320
|
+
const baselineCounts = {};
|
|
7321
|
+
function record(id, title, status, detail) {
|
|
7322
|
+
probes.push({ id: id, title: title, status: status, detail: detail });
|
|
7323
|
+
}
|
|
7324
|
+
function recallProbe(id, title, pick) {
|
|
7325
|
+
const query = String(pick.subject || '').trim() || String(pick.statement || '').trim().slice(0, 24);
|
|
7326
|
+
const hits = baselineRecall(root, readable, query, 5);
|
|
7327
|
+
const expect = new Set([String(pick.file)]);
|
|
7328
|
+
if (query && hits.some(function (h) { return entryMatchesExpect(h.entry, expect); })) {
|
|
7329
|
+
record(id, title, 'pass', '可召回: ' + pick.file);
|
|
7330
|
+
} else {
|
|
7331
|
+
record(id, title, 'fail', '无法召回: ' + pick.file + '(按该条目自己的主题检索未命中)');
|
|
7332
|
+
}
|
|
7333
|
+
}
|
|
7334
|
+
|
|
7335
|
+
// ① 身份:agents.json / iam 在位
|
|
7336
|
+
const expectedOwners = template ? template.expect_owners : [];
|
|
7337
|
+
const agentsFile = agentsPath(root);
|
|
7338
|
+
if (!fs.existsSync(agentsFile)) {
|
|
7339
|
+
if (entries.length) record('identity', '身份登记', 'fail', '缺少 agents.json(智能体身份登记);恢复后请用 yotta-memory iam <id> 重新登记。');
|
|
7340
|
+
else record('identity', '身份登记', 'skip', '库内没有记忆条目且没有 agents.json,暂不判定。');
|
|
7341
|
+
} else {
|
|
7342
|
+
let data = null;
|
|
7343
|
+
let parseError = '';
|
|
7344
|
+
try { data = JSON.parse(fs.readFileSync(agentsFile, 'utf8')); } catch (error) { parseError = error && error.message ? error.message : String(error); }
|
|
7345
|
+
const registered = data && data.agents && typeof data.agents === 'object' ? Object.keys(data.agents) : [];
|
|
7346
|
+
if (parseError) {
|
|
7347
|
+
record('identity', '身份登记', 'fail', 'agents.json 无法解析(' + parseError + ');请先恢复身份登记。');
|
|
7348
|
+
} else if (!registered.length) {
|
|
7349
|
+
record('identity', '身份登记', 'fail', 'agents.json 没有任何智能体登记;恢复后请用 yotta-memory iam <id> 重新登记。');
|
|
7350
|
+
} else {
|
|
7351
|
+
const missingOwners = expectedOwners.filter(function (o) { return registered.indexOf(o) === -1; });
|
|
7352
|
+
if (missingOwners.length) {
|
|
7353
|
+
record('identity', '身份登记', 'fail', 'agents.json 缺少模板要求的 owner: ' + missingOwners.join(', ') + '(已登记: ' + registered.join(', ') + ')');
|
|
7354
|
+
} else {
|
|
7355
|
+
record('identity', '身份登记', 'pass', 'agents.json 已登记 ' + registered.join(', '));
|
|
7356
|
+
}
|
|
7357
|
+
}
|
|
7358
|
+
}
|
|
7359
|
+
|
|
7360
|
+
// ② 近期:最近条目可召回
|
|
7361
|
+
const byRecency = readable.slice().sort(function (a, b) {
|
|
7362
|
+
return String(b.created || '').localeCompare(String(a.created || '')) || String(b.file).localeCompare(String(a.file));
|
|
7363
|
+
});
|
|
7364
|
+
if (!byRecency.length) record('recent', '近期条目', 'skip', '没有当前身份可读的记忆条目。');
|
|
7365
|
+
else recallProbe('recent', '近期条目', byRecency[0]);
|
|
7366
|
+
|
|
7367
|
+
// ③ 仅源库独有:--against 差集条目必须已在目标库
|
|
7368
|
+
if (!against) {
|
|
7369
|
+
record('source-only', '对比库差集', 'skip', '未提供 --against 对比库,跳过迁移差集校验。');
|
|
7370
|
+
} else {
|
|
7371
|
+
const sourceRead = baselineReadEntries(against);
|
|
7372
|
+
const targetKeys = new Set(entries.map(baselineEntryKey));
|
|
7373
|
+
const sourceKeys = new Set();
|
|
7374
|
+
const missing = [];
|
|
7375
|
+
for (const e of sourceRead.entries) {
|
|
7376
|
+
const key = baselineEntryKey(e);
|
|
7377
|
+
if (sourceKeys.has(key)) continue;
|
|
7378
|
+
sourceKeys.add(key);
|
|
7379
|
+
if (!targetKeys.has(key)) missing.push(e);
|
|
7380
|
+
}
|
|
7381
|
+
let targetOnly = 0;
|
|
7382
|
+
for (const key of targetKeys) if (!sourceKeys.has(key)) targetOnly++;
|
|
7383
|
+
const counts = { source_entries: sourceRead.entries.length, missing: missing.length, target_only: targetOnly };
|
|
7384
|
+
if (missing.length) {
|
|
7385
|
+
const shown = missing.slice(0, BASELINE_MISSING_LIMIT).map(function (e) { return e.file; });
|
|
7386
|
+
record('source-only', '对比库差集', 'fail', '相对对比库缺失 ' + missing.length + ' 条: ' + shown.join(', ') + (missing.length > shown.length ? ' …' : ''));
|
|
7387
|
+
} else {
|
|
7388
|
+
record('source-only', '对比库差集', 'pass', '对比库 ' + sourceRead.entries.length + ' 条全部在目标库命中' + (targetOnly ? '(目标库另有 ' + targetOnly + ' 条新增)' : ''));
|
|
7389
|
+
}
|
|
7390
|
+
baselineCounts.sourceOnly = counts;
|
|
7391
|
+
}
|
|
7392
|
+
|
|
7393
|
+
// ④ CJK:中文条目可召回
|
|
7394
|
+
const cjkPool = readable.filter(function (e) { return CJK_RE.test(String(e.subject || '') + String(e.statement || '')); });
|
|
7395
|
+
if (!cjkPool.length) record('cjk', '中文条目', 'skip', '没有当前身份可读的中文条目。');
|
|
7396
|
+
else recallProbe('cjk', '中文条目', baselineSample(cjkPool, seed, 1)[0]);
|
|
7397
|
+
|
|
7398
|
+
// ⑤ 操作规则:BOUND 类可读
|
|
7399
|
+
const boundPool = entries.filter(function (e) { return e.type === 'BOUND'; });
|
|
7400
|
+
if (!boundPool.length) {
|
|
7401
|
+
record('rules', '操作规则', 'skip', '库内没有 BOUND 操作规则条目。');
|
|
7402
|
+
} else {
|
|
7403
|
+
const pick = boundPool.slice().sort(function (a, b) { return String(a.file).localeCompare(String(b.file)); })[0];
|
|
7404
|
+
const fp = path.join(root, String(pick.file));
|
|
7405
|
+
try {
|
|
7406
|
+
readEntry(fp, root);
|
|
7407
|
+
record('rules', '操作规则', 'pass', 'BOUND 规则可读: ' + pick.file);
|
|
7408
|
+
} catch (error) {
|
|
7409
|
+
const owner = ownerFromPrivatePath(root, fp);
|
|
7410
|
+
if (isEncFile(fp) && !getOwnerKeyFor(root, owner)) {
|
|
7411
|
+
record('rules', '操作规则', 'skip', 'BOUND 规则为加密私密条目,缺少 ' + owner + ' 的授权密钥,未做可读性验证。');
|
|
7412
|
+
} else {
|
|
7413
|
+
record('rules', '操作规则', 'fail', 'BOUND 规则不可读: ' + pick.file + '(' + (error && error.message ? error.message : String(error)) + ')');
|
|
7414
|
+
}
|
|
7415
|
+
}
|
|
7416
|
+
}
|
|
7417
|
+
|
|
7418
|
+
// ⑥ owner 范围:各 owner 计数 + 模板条数期望
|
|
7419
|
+
const ownerCounts = {};
|
|
7420
|
+
for (const fp of collectEntryFiles(root)) {
|
|
7421
|
+
const owner = ownerFromPrivatePath(root, fp) || 'public';
|
|
7422
|
+
ownerCounts[owner] = (ownerCounts[owner] || 0) + 1;
|
|
7423
|
+
}
|
|
7424
|
+
baselineCounts.owners = ownerCounts;
|
|
7425
|
+
const ownerNames = Object.keys(ownerCounts).sort();
|
|
7426
|
+
const corrupted = read.unreadable.filter(function (u) { return u.reason !== 'encrypted'; });
|
|
7427
|
+
if (!ownerNames.length) {
|
|
7428
|
+
record('owner', 'owner 范围', 'skip', '库内没有记忆文件。');
|
|
7429
|
+
} else if (corrupted.length) {
|
|
7430
|
+
const labels = { undecryptable: '密钥在位但解不开', mislabelled: '.enc 后缀但内容是明文', unreadable: '读取失败' };
|
|
7431
|
+
const reasonCounts = {};
|
|
7432
|
+
for (const item of corrupted) reasonCounts[item.reason] = (reasonCounts[item.reason] || 0) + 1;
|
|
7433
|
+
const summary = Object.keys(reasonCounts).map(function (reason) {
|
|
7434
|
+
return (labels[reason] || reason) + ' ' + reasonCounts[reason] + ' 个';
|
|
7435
|
+
}).join(';');
|
|
7436
|
+
record('owner', 'owner 范围', 'fail', '存在无法作为记忆读取的文件: ' + summary + ';例如 ' + corrupted.slice(0, 5).map(function (u) { return u.file; }).join(', '));
|
|
7437
|
+
} else if (template && template.expect_min_entries && entries.length < template.expect_min_entries) {
|
|
7438
|
+
const parts = ownerNames.map(function (o) { return o + '=' + ownerCounts[o]; });
|
|
7439
|
+
record('owner', 'owner 范围', 'fail', '记忆条数 ' + entries.length + ' 少于模板要求的 ' + template.expect_min_entries + ':缺少 ' + (template.expect_min_entries - entries.length) + ' 条(owner: ' + parts.join(', ') + ')');
|
|
7440
|
+
} else {
|
|
7441
|
+
const typeCounts = {};
|
|
7442
|
+
for (const e of entries) typeCounts[e.type] = (typeCounts[e.type] || 0) + 1;
|
|
7443
|
+
const shortTypes = [];
|
|
7444
|
+
const expectedTypes = template ? template.expect_types : {};
|
|
7445
|
+
for (const type of Object.keys(expectedTypes)) {
|
|
7446
|
+
const got = typeCounts[type] || 0;
|
|
7447
|
+
if (got < expectedTypes[type]) shortTypes.push(type + ' 缺少 ' + (expectedTypes[type] - got) + ' 条');
|
|
7448
|
+
}
|
|
7449
|
+
const parts = ownerNames.map(function (o) { return o + '=' + ownerCounts[o]; });
|
|
7450
|
+
if (shortTypes.length) {
|
|
7451
|
+
record('owner', 'owner 范围', 'fail', '模板要求的类型条数不足: ' + shortTypes.join(';') + '(owner: ' + parts.join(', ') + ')');
|
|
7452
|
+
} else {
|
|
7453
|
+
record('owner', 'owner 范围', 'pass', 'owner 计数: ' + parts.join(', ') + (read.unreadable.length ? '(另有 ' + read.unreadable.length + ' 个加密条目未读取)' : ''));
|
|
7454
|
+
}
|
|
7455
|
+
}
|
|
7456
|
+
|
|
7457
|
+
// 模板显式查询(可选追加探针,不替代上面六类)
|
|
7458
|
+
if (template && template.queries.length) {
|
|
7459
|
+
const failed = [];
|
|
7460
|
+
for (const row of template.queries) {
|
|
7461
|
+
const hits = baselineRecall(root, readable, row.query, 5);
|
|
7462
|
+
const expect = new Set(row.expect);
|
|
7463
|
+
if (!hits.some(function (h) { return entryMatchesExpect(h.entry, expect); })) failed.push(row.id);
|
|
7464
|
+
}
|
|
7465
|
+
if (failed.length) record('template-queries', '模板查询', 'fail', '模板查询未命中: ' + failed.join(', ') + '(共 ' + template.queries.length + ' 条)');
|
|
7466
|
+
else record('template-queries', '模板查询', 'pass', '模板查询全部命中(共 ' + template.queries.length + ' 条)');
|
|
7467
|
+
}
|
|
7468
|
+
|
|
7469
|
+
const fails = probes.filter(function (p) { return p.status === 'fail'; });
|
|
7470
|
+
const skips = probes.filter(function (p) { return p.status === 'skip'; });
|
|
7471
|
+
const counts = {
|
|
7472
|
+
entries: entries.length,
|
|
7473
|
+
readable: readable.length,
|
|
7474
|
+
unreadable: read.unreadable.length,
|
|
7475
|
+
owners: baselineCounts.owners,
|
|
7476
|
+
missing: baselineCounts.sourceOnly ? baselineCounts.sourceOnly.missing : 0,
|
|
7477
|
+
target_only: baselineCounts.sourceOnly ? baselineCounts.sourceOnly.target_only : 0,
|
|
7478
|
+
source_entries: baselineCounts.sourceOnly ? baselineCounts.sourceOnly.source_entries : 0,
|
|
7479
|
+
};
|
|
7480
|
+
const report = {
|
|
7481
|
+
schemaVersion: BASELINE_SCHEMA_VERSION,
|
|
7482
|
+
error: false,
|
|
7483
|
+
ok: fails.length === 0,
|
|
7484
|
+
level: fails.length ? 'fail' : 'pass',
|
|
7485
|
+
root: root,
|
|
7486
|
+
against: against,
|
|
7487
|
+
template: template ? template.file : '',
|
|
7488
|
+
seed: seed,
|
|
7489
|
+
counts: counts,
|
|
7490
|
+
probes: probes,
|
|
7491
|
+
unreadable: read.unreadable,
|
|
7492
|
+
};
|
|
7493
|
+
const statusLabel = { pass: '通过', fail: '失败', skip: '跳过' };
|
|
7494
|
+
const lines = [
|
|
7495
|
+
'# 元忆基线探针(恢复 / 迁移)',
|
|
7496
|
+
'',
|
|
7497
|
+
'- 记忆库: ' + root,
|
|
7498
|
+
'- 对比库: ' + (against || '(未提供,跳过差集校验)'),
|
|
7499
|
+
'- 条目: ' + counts.entries + ' 条可读 / ' + counts.readable + ' 条当前身份可读 / ' + counts.unreadable + ' 个未读取',
|
|
7500
|
+
'- 结果: ' + (report.ok ? '通过' : '失败') + '(通过 ' + (probes.length - fails.length - skips.length) + ' / 失败 ' + fails.length + ' / 跳过 ' + skips.length + ')',
|
|
7501
|
+
];
|
|
7502
|
+
for (const probe of probes) {
|
|
7503
|
+
lines.push('- [' + statusLabel[probe.status] + '] ' + probe.title + ' · ' + probe.detail);
|
|
7504
|
+
}
|
|
7505
|
+
if (fails.length) lines.push('- 缺失项清单: ' + fails.map(function (p) { return p.id; }).join(', '));
|
|
7506
|
+
report.text = lines.join('\n');
|
|
7507
|
+
return report;
|
|
7508
|
+
}
|
|
7509
|
+
|
|
7510
|
+
// ---- v0.17.0 A4:记忆库安全扫描(默认只读 / 本地 / 零依赖)----
|
|
7511
|
+
// 分工口径:元钥扫源码仓库、元信扫技能包、元忆扫记忆库。规则词表不另起一套——
|
|
7512
|
+
// 每条规则的 ref 指向家族规则表里的原始规则 id(<规则 id>@<技能 slug>),
|
|
7513
|
+
// test/memory-scan.test.js 会在源码仓库内逐条回查这些规则 id 在对应技能里是否仍存在。
|
|
7514
|
+
const SCAN_SCHEMA_VERSION = 1;
|
|
7515
|
+
const SCAN_CLASSES = ['malicious-instruction', 'prompt-injection', 'credential-leak', 'data-exfiltration', 'guardrail-bypass', 'behavior-manipulation', 'privilege-escalation'];
|
|
7516
|
+
const SCAN_SEVERITIES = ['safe', 'low', 'medium', 'high', 'critical'];
|
|
7517
|
+
const SCAN_MAX_FINDINGS = 500;
|
|
7518
|
+
const SCAN_MAX_FILES = 20000;
|
|
7519
|
+
const SCAN_QUARANTINE_DIR = '.memory-scan';
|
|
7520
|
+
const MEMORY_SCAN_RULES = [
|
|
7521
|
+
// ── 恶意指令(元安 DownloadExec / 元盾危险命令)──
|
|
7522
|
+
{ id: 'YTM-MAL-001', class: 'malicious-instruction', severity: 'critical', ref: 'DEX-001@yotta-security-audit', title: 'curl 下载内容交给 shell 执行', mask: false, re: /\bcurl\b[^\n|;]{0,120}\|\s*(?:ba)?sh\b/i },
|
|
7523
|
+
{ id: 'YTM-MAL-002', class: 'malicious-instruction', severity: 'critical', ref: 'CMD-PIPE-WGET@yotta-guardian', title: 'wget 下载内容交给 shell 执行', mask: false, re: /\bwget\b[^\n|;]{0,120}\|\s*(?:ba)?sh\b/i },
|
|
7524
|
+
{ id: 'YTM-MAL-003', class: 'malicious-instruction', severity: 'critical', ref: 'CMD-REV-BASH@yotta-guardian', title: '反弹 shell', mask: false, re: /\b(?:bash\s+-i\s*>&?\s*\/dev\/tcp|nc\s+-e\s+\/bin\/(?:ba)?sh)\b/i },
|
|
7525
|
+
{ id: 'YTM-MAL-004', class: 'malicious-instruction', severity: 'high', ref: 'CMD-PS-ENCODED@yotta-guardian', title: '编码后的 PowerShell 命令', mask: false, re: /powershell(?:\.exe)?[^\n]{0,40}\s-(?:enc|e|encodedcommand)\b/i },
|
|
7526
|
+
// ── Prompt 注入(元信 PIJ)──
|
|
7527
|
+
{ id: 'YTM-PIJ-001', class: 'prompt-injection', severity: 'high', ref: 'PIJ-001@yotta-verify', title: '要求忽略之前的指令', mask: false, re: /(?:ignore|disregard|forget|overlook|skip)\s+(?:all\s+|any\s+|the\s+|previous\s+)*(?:previous\s+|earlier\s+)*(?:instructions?|prompts?|directives?|guidelines?|rules?|context|messages?)/i },
|
|
7528
|
+
{ id: 'YTM-PIJ-002', class: 'prompt-injection', severity: 'high', ref: 'PIJ-002@yotta-verify', title: '要求忽略之前指令(中文)', mask: false, re: /(?:忽略|无视|忘记|不要理会|别管|忘掉)(?:之前|以上|前面|所有|一切)?(?:的)?(?:指令|提示|设定|规则|上下文|内容)/ },
|
|
7529
|
+
{ id: 'YTM-PIJ-003', class: 'prompt-injection', severity: 'high', ref: 'PIJ-009@yotta-verify', title: '伪系统消息', mask: false, re: /(?:以下内容|下面这段|注意).{0,30}(?:系统消息|系统指令|来自系统|这是系统)/ },
|
|
7530
|
+
{ id: 'YTM-PIJ-004', class: 'prompt-injection', severity: 'medium', ref: 'PIJ-014@yotta-verify', title: '伪系统标签', mask: false, re: /<\s*(?:system|sysadmin)\s*(?:message|prompt|instruction|role)?\s*>/i },
|
|
7531
|
+
{ id: 'YTM-PIJ-005', class: 'prompt-injection', severity: 'high', ref: 'PIJ-015@yotta-verify', title: '要求泄露系统提示词', mask: false, re: /(?:reveal|show|print|output|display)\s+(?:me\s+|your\s+)?(?:system\s+)?(?:prompt|instructions?|system\s+message)/i },
|
|
7532
|
+
{ id: 'YTM-PIJ-006', class: 'prompt-injection', severity: 'high', ref: 'PIJ-016@yotta-verify', title: '要求泄露系统提示词(中文)', mask: false, re: /(?:输出|显示|打印|告诉我).{0,20}(?:你的|系统)?(?:系统提示词|系统指令|内部指令)/ },
|
|
7533
|
+
{ id: 'YTM-PIJ-007', class: 'prompt-injection', severity: 'medium', ref: 'PIJ-017@yotta-verify', title: '条件触发注入', mask: false, re: /when\s+(?:i|the\s+user)\s+(?:say|type|send|input)\s+[^\n]{0,40}\s*(?:then|you\s+will|ignore|do)/i },
|
|
7534
|
+
// ── 凭证泄漏(元钥;命中片段一律打码,避免二次泄露)──
|
|
7535
|
+
{ id: 'YTM-SEC-001', class: 'credential-leak', severity: 'critical', ref: 'github@yotta-secret', title: 'GitHub Token', mask: true, re: /\bgh[pousr]_[0-9A-Za-z]{36,}\b|\bgithub_pat_[0-9A-Za-z_]{20,}\b/ },
|
|
7536
|
+
{ id: 'YTM-SEC-002', class: 'credential-leak', severity: 'critical', ref: 'aws_secret@yotta-secret', title: 'AWS 秘密访问密钥', mask: true, re: /aws[_-]?secret[_-]?access[_-]?key\b\s*[=:]\s*["']?[A-Za-z0-9/+=]{40}/i },
|
|
7537
|
+
{ id: 'YTM-SEC-003', class: 'credential-leak', severity: 'high', ref: 'openai@yotta-secret', title: 'OpenAI API Key', mask: true, re: /\bsk-(?!ant-)[A-Za-z0-9_-]{20,}\b/ },
|
|
7538
|
+
{ id: 'YTM-SEC-004', class: 'credential-leak', severity: 'critical', ref: 'pem_private@yotta-secret', title: 'PEM 私钥块', mask: true, re: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/ },
|
|
7539
|
+
{ id: 'YTM-SEC-005', class: 'credential-leak', severity: 'high', ref: 'credential@yotta-secret', title: '凭据赋值', mask: true, re: /(?:password|passwd|pwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token|client[_-]?secret|private[_-]?key|token)\s*[:=]\s*["']?[^\s"',;]{6,}/i },
|
|
7540
|
+
{ id: 'YTM-SEC-006', class: 'credential-leak', severity: 'high', ref: 'url_userinfo@yotta-secret', title: 'URL 内嵌账号密码', mask: true, re: /\b(?:https?|ftp|smtp|mongodb(?:\+srv)?|redis|mysql|postgres(?:ql)?):\/\/[^/\s:@]+:[^/\s@]+@/i },
|
|
7541
|
+
// ── 数据外泄(元信 PIJ 外传指令 / 元安 Exfiltration)──
|
|
7542
|
+
{ id: 'YTM-EXF-001', class: 'data-exfiltration', severity: 'high', ref: 'PIJ-011@yotta-verify', title: '把上下文/密钥发送到远端', mask: false, re: /(?:send|upload|post|exfiltrate|transmit)\s+(?:the|all|your|any)?\s*(?:contents?|data|files?|env|environment|keys?|secrets?|memory|context|conversation|prompts?)\s*(?:to|via|using)\s*(?:this\s+)?(?:url|endpoint|server|http)/i },
|
|
7543
|
+
{ id: 'YTM-EXF-002', class: 'data-exfiltration', severity: 'high', ref: 'PIJ-012@yotta-verify', title: '把上下文/密钥发送到远端(中文)', mask: false, re: /(?:把|将|请将)(?:你的|所有|全部)?(?:上下文|记忆|环境变量|密钥|文件|对话|提示词).{0,40}(?:发送|上传|提交|传给|发给)/ },
|
|
7544
|
+
{ id: 'YTM-EXF-003', class: 'data-exfiltration', severity: 'high', ref: 'EXF-001@yotta-security-audit', title: '命令行上传数据到远端', mask: false, re: /\b(?:curl|wget)\b[^\n]{0,80}(?:--data\b|--upload-file\b|-d\s|-T\s)/i },
|
|
7545
|
+
// ── 护栏绕过(元信 PIJ 隐藏意图 / 覆盖护栏)──
|
|
7546
|
+
{ id: 'YTM-GRD-001', class: 'guardrail-bypass', severity: 'high', ref: 'PIJ-004@yotta-verify', title: '要求覆盖/绕过安全护栏', mask: false, re: /(?:override|disregard|bypass)\s+(?:all\s+)?(?:previous\s+)?(?:instructions?|rules?|safety|guardrails?|security)/i },
|
|
7547
|
+
{ id: 'YTM-GRD-002', class: 'guardrail-bypass', severity: 'high', ref: 'PIJ-022@yotta-verify', title: '禁止告知用户(中文)', mask: false, re: /(?:不要告诉用户|别告诉用户|无需告知用户|不用告诉用户|不要让用户知道)/ },
|
|
7548
|
+
{ id: 'YTM-GRD-003', class: 'guardrail-bypass', severity: 'high', ref: 'PIJ-021@yotta-verify', title: '禁止告知用户', mask: false, re: /(?:do not|don'?t|never)\s+(?:tell|inform|notify|mention|reveal)\s+(?:the\s+)?user/i },
|
|
7549
|
+
{ id: 'YTM-GRD-004', class: 'guardrail-bypass', severity: 'high', ref: 'PIJ-026@yotta-verify', title: '要求不经用户同意直接执行(中文)', mask: false, re: /(?:不要问用户|无需询问|不用问|不需要征求|别问用户)/ },
|
|
7550
|
+
{ id: 'YTM-GRD-005', class: 'guardrail-bypass', severity: 'high', ref: 'PIJ-025@yotta-verify', title: '要求不经用户同意直接执行', mask: false, re: /(?:without|no\s+need\s+to)\s+(?:telling|informing|asking|consulting)\s+(?:the\s+)?user/i },
|
|
7551
|
+
// ── 行为操纵(元信 PIJ 角色伪造)──
|
|
7552
|
+
{ id: 'YTM-BHV-001', class: 'behavior-manipulation', severity: 'medium', ref: 'PIJ-007@yotta-verify', title: '宣称新角色(中文)', mask: false, re: /(?:你现在是|从现在起你是|你的新角色是|从此刻起你是|你正在扮演)/ },
|
|
7553
|
+
{ id: 'YTM-BHV-002', class: 'behavior-manipulation', severity: 'medium', ref: 'PIJ-005@yotta-verify', title: '角色伪造:冒充系统/管理员', mask: false, re: /\byou\s+are\s+now\b[^\n]{0,60}(?:mode|role|system|admin|root|developer|assistant)/i },
|
|
7554
|
+
{ id: 'YTM-BHV-003', class: 'behavior-manipulation', severity: 'medium', ref: 'PIJ-006@yotta-verify', title: '角色伪造:扮演系统/管理员', mask: false, re: /(?:act|behave|pretend)\s+as\s+(?:a\s+|an\s+)?(?:system|admin|root|god\s+mode|developer)/i },
|
|
7555
|
+
{ id: 'YTM-BHV-004', class: 'behavior-manipulation', severity: 'high', ref: 'PIJ-003@yotta-verify', title: '从现在起重定向行为', mask: false, re: /from\s+now\s+on[^\n]{0,40}(?:follow|obey|you\s+are|act)/i },
|
|
7556
|
+
{ id: 'YTM-BHV-005', class: 'behavior-manipulation', severity: 'high', ref: 'PIJ-024@yotta-verify', title: '要求只回复确认词(中文)', mask: false, re: /(?:只回复|仅回复|直接回复)\s*(?:OK|ok|收到|好|是)/ },
|
|
7557
|
+
// ── 权限提升(元信 PIJ 全权暗示 / 元盾 setuid / 元安 PrivilegeEscalation)──
|
|
7558
|
+
{ id: 'YTM-PRV-001', class: 'privilege-escalation', severity: 'high', ref: 'PIJ-008@yotta-verify', title: '以全权/管理员权限执行', mask: false, re: /with\s+(?:full|super|root|admin|system|unrestricted|unlimited)\s+(?:privileges|access|permissions?|power)/i },
|
|
7559
|
+
{ id: 'YTM-PRV-002', class: 'privilege-escalation', severity: 'critical', ref: 'CMD-CHMOD-SETUID@yotta-guardian', title: '设置 setuid 权限', mask: false, re: /\bchmod\s+(?:[ug]\+s|[0-7]{0,3}4[0-7]{3})\b/ },
|
|
7560
|
+
{ id: 'YTM-PRV-003', class: 'privilege-escalation', severity: 'high', ref: 'PRI-001@yotta-security-audit', title: '提权到 shell', mask: false, re: /\bsudo\s+(?:su|bash|sh|-i)\b/ },
|
|
7561
|
+
{ id: 'YTM-PRV-004', class: 'privilege-escalation', severity: 'high', ref: 'PIJ-027@yotta-verify', title: '采集键盘/凭据', mask: false, re: /(?:capture|record|log|monitor)\s+(?:all\s+)?(?:keystrokes|input|credentials?|passwords?|everything\s+the\s+user)/i },
|
|
7562
|
+
{ id: 'YTM-PRV-005', class: 'privilege-escalation', severity: 'high', ref: 'PIJ-028@yotta-verify', title: '采集键盘/凭据(中文)', mask: false, re: /(?:记录|收集|监控|窃取)(?:用户)?(?:输入|键盘|密码|凭据|按键)/ },
|
|
7563
|
+
{ id: 'YTM-PRV-006', class: 'privilege-escalation', severity: 'high', ref: 'PIJ-020@yotta-verify', title: '要求执行下载内容(中文)', mask: false, re: /(?:执行|运行|下载并运行|安装).{0,40}(?:curl|wget|下载).{0,60}(?:然后|并)?(?:执行|运行)/ },
|
|
7564
|
+
];
|
|
7565
|
+
function scanSeverityRank(severity) { return SCAN_SEVERITIES.indexOf(String(severity || '').toLowerCase()); }
|
|
7566
|
+
function scanEmptySummary() {
|
|
7567
|
+
const byClass = {};
|
|
7568
|
+
for (const cls of SCAN_CLASSES) byClass[cls] = 0;
|
|
7569
|
+
const bySeverity = {};
|
|
7570
|
+
for (const sev of SCAN_SEVERITIES) bySeverity[sev] = 0;
|
|
7571
|
+
return { files: 0, encrypted: 0, mislabelled: 0, scanned: 0, hits: 0, byClass: byClass, bySeverity: bySeverity, maxSeverity: 'safe', truncated: false };
|
|
7572
|
+
}
|
|
7573
|
+
function isSafeRelPath(rel) {
|
|
7574
|
+
const value = String(rel || '');
|
|
7575
|
+
if (!value || value.indexOf('\u0000') !== -1) return false;
|
|
7576
|
+
if (path.isAbsolute(value)) return false;
|
|
7577
|
+
const norm = path.normalize(value);
|
|
7578
|
+
if (norm === '..' || norm.indexOf('..' + path.sep) === 0 || norm.indexOf('/') === 0) return false;
|
|
7579
|
+
return true;
|
|
7580
|
+
}
|
|
7581
|
+
// 命中片段一律做凭证打码:规则本身命中的密钥不会出现在报告里,同一行里的其它密钥也不会被顺带泄露。
|
|
7582
|
+
function scrubSecrets(line) {
|
|
7583
|
+
let out = String(line);
|
|
7584
|
+
for (const rule of MEMORY_SCAN_RULES) {
|
|
7585
|
+
if (!rule.mask) continue;
|
|
7586
|
+
const re = new RegExp(rule.re.source, rule.re.flags.indexOf('g') === -1 ? rule.re.flags + 'g' : rule.re.flags);
|
|
7587
|
+
out = out.replace(re, '[已打码]');
|
|
7588
|
+
}
|
|
7589
|
+
return out;
|
|
7590
|
+
}
|
|
7591
|
+
function scanTargetFiles(root) {
|
|
7592
|
+
// 递归扫记忆库内的 .md / .md.enc(含 facts、private、.archive、distills、profile);
|
|
7593
|
+
// 跳过 .git / node_modules / 隔离目录本身,避免把隔离副本再扫一遍。
|
|
7594
|
+
const skip = ['.git', 'node_modules', SCAN_QUARANTINE_DIR];
|
|
7595
|
+
const out = [];
|
|
7596
|
+
(function walk(dir) {
|
|
7597
|
+
if (out.length >= SCAN_MAX_FILES) return;
|
|
7598
|
+
let names;
|
|
7599
|
+
try { names = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return; }
|
|
7600
|
+
for (const item of names) {
|
|
7601
|
+
const fp = path.join(dir, item.name);
|
|
7602
|
+
if (item.isDirectory()) {
|
|
7603
|
+
if (skip.indexOf(item.name) !== -1) continue;
|
|
7604
|
+
walk(fp);
|
|
7605
|
+
continue;
|
|
7606
|
+
}
|
|
7607
|
+
if (!item.isFile()) continue;
|
|
7608
|
+
if (!MEMORY_FILE_RE.test(item.name)) continue;
|
|
7609
|
+
out.push(fp);
|
|
7610
|
+
if (out.length >= SCAN_MAX_FILES) return;
|
|
7611
|
+
}
|
|
7612
|
+
})(root);
|
|
7613
|
+
return Array.from(new Set(out)).sort();
|
|
7614
|
+
}
|
|
7615
|
+
function scanQuarantineCore(root, findings) {
|
|
7616
|
+
if (!findings.length) return { error: false, batch: '', files: 0, lines: 0, text: '没有命中项,无需隔离。' };
|
|
7617
|
+
const batch = 'scan-' + Date.now() + '-' + crypto.randomBytes(2).toString('hex');
|
|
7618
|
+
const batchDir = path.join(root, SCAN_QUARANTINE_DIR, 'quarantine', batch);
|
|
7619
|
+
const byFile = new Map();
|
|
7620
|
+
for (const finding of findings) {
|
|
7621
|
+
if (!byFile.has(finding.file)) byFile.set(finding.file, []);
|
|
7622
|
+
byFile.get(finding.file).push(finding);
|
|
7623
|
+
}
|
|
7624
|
+
const manifest = { version: 1, batch: batch, created: new Date().toISOString(), root: root, restored: false, files: [] };
|
|
7625
|
+
let lineCount = 0;
|
|
7626
|
+
for (const rel of Array.from(byFile.keys()).sort()) {
|
|
7627
|
+
if (!isSafeRelPath(rel)) return { error: true, text: '拒绝隔离: 非法相对路径 ' + rel };
|
|
7628
|
+
const src = path.join(root, rel);
|
|
7629
|
+
if (!fs.existsSync(src)) continue;
|
|
7630
|
+
const backup = path.join(batchDir, rel);
|
|
7631
|
+
fs.mkdirSync(path.dirname(backup), { recursive: true });
|
|
7632
|
+
fs.copyFileSync(src, backup);
|
|
7633
|
+
const lineMap = new Map();
|
|
7634
|
+
for (const hit of byFile.get(rel)) if (!lineMap.has(hit.line)) lineMap.set(hit.line, hit);
|
|
7635
|
+
const lines = fs.readFileSync(src, 'utf8').split(/\r?\n/);
|
|
7636
|
+
for (const lineNo of Array.from(lineMap.keys()).sort(function (a, b) { return a - b; })) {
|
|
7637
|
+
if (lineNo < 1 || lineNo > lines.length) continue;
|
|
7638
|
+
lines[lineNo - 1] = '[已隔离: ' + lineMap.get(lineNo).rule + ' ' + lineMap.get(lineNo).class + ']';
|
|
7639
|
+
lineCount++;
|
|
7640
|
+
}
|
|
7641
|
+
fs.writeFileSync(src, lines.join('\n'), 'utf8');
|
|
7642
|
+
manifest.files.push({
|
|
7643
|
+
file: rel,
|
|
7644
|
+
lines: Array.from(lineMap.keys()).sort(function (a, b) { return a - b; }),
|
|
7645
|
+
rules: Array.from(new Set(byFile.get(rel).map(function (h) { return h.rule; }))),
|
|
7646
|
+
});
|
|
7647
|
+
}
|
|
7648
|
+
fs.writeFileSync(path.join(batchDir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8');
|
|
7649
|
+
return {
|
|
7650
|
+
error: false,
|
|
7651
|
+
batch: batch,
|
|
7652
|
+
files: manifest.files.length,
|
|
7653
|
+
lines: lineCount,
|
|
7654
|
+
text: '已隔离 ' + lineCount + ' 行(' + manifest.files.length + ' 个文件)到 ' + path.join(SCAN_QUARANTINE_DIR, 'quarantine', batch) + ';原文件命中行已替换为占位符,可用 scan --restore 还原。',
|
|
7655
|
+
};
|
|
7656
|
+
}
|
|
7657
|
+
function scanRestoreCore(root, opts) {
|
|
7658
|
+
const base = path.join(root, SCAN_QUARANTINE_DIR, 'quarantine');
|
|
7659
|
+
const report = { schemaVersion: SCAN_SCHEMA_VERSION, error: true, exitCode: 2, ok: false, level: 'safe', root: root, findings: [], summary: scanEmptySummary() };
|
|
7660
|
+
if (!fs.existsSync(base)) return Object.assign(report, { text: '没有可还原的隔离批次: ' + base + '。' });
|
|
7661
|
+
const wanted = opts && opts.id ? String(opts.id) : '';
|
|
7662
|
+
const batches = fs.readdirSync(base).filter(function (name) {
|
|
7663
|
+
return fs.existsSync(path.join(base, name, 'manifest.json'));
|
|
7664
|
+
}).sort();
|
|
7665
|
+
const chosen = wanted
|
|
7666
|
+
? (batches.indexOf(wanted) === -1 ? null : wanted)
|
|
7667
|
+
: (batches.length ? batches[batches.length - 1] : null);
|
|
7668
|
+
if (!chosen) return Object.assign(report, { text: wanted ? ('找不到隔离批次: ' + wanted + '。') : '没有可还原的隔离批次。' });
|
|
7669
|
+
const batchDir = path.join(base, chosen);
|
|
7670
|
+
let manifest = null;
|
|
7671
|
+
try {
|
|
7672
|
+
manifest = JSON.parse(fs.readFileSync(path.join(batchDir, 'manifest.json'), 'utf8'));
|
|
7673
|
+
} catch (error) {
|
|
7674
|
+
return Object.assign(report, { text: '隔离批次 manifest 无法解析: ' + chosen + '(' + (error && error.message ? error.message : String(error)) + ')' });
|
|
7675
|
+
}
|
|
7676
|
+
if (manifest && manifest.restored) {
|
|
7677
|
+
return Object.assign(report, { text: '隔离批次 ' + chosen + ' 已还原过,不会重复还原。' });
|
|
7678
|
+
}
|
|
7679
|
+
let restored = 0;
|
|
7680
|
+
for (const item of (manifest && manifest.files) || []) {
|
|
7681
|
+
if (!isSafeRelPath(item.file)) return Object.assign(report, { text: '拒绝还原: 非法相对路径 ' + item.file });
|
|
7682
|
+
const backup = path.join(batchDir, item.file);
|
|
7683
|
+
if (!fs.existsSync(backup)) continue;
|
|
7684
|
+
const target = path.join(root, item.file);
|
|
7685
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
7686
|
+
fs.copyFileSync(backup, target);
|
|
7687
|
+
restored++;
|
|
7688
|
+
}
|
|
7689
|
+
manifest.restored = true;
|
|
7690
|
+
manifest.restored_at = new Date().toISOString();
|
|
7691
|
+
fs.writeFileSync(path.join(batchDir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8');
|
|
7692
|
+
return {
|
|
7693
|
+
schemaVersion: SCAN_SCHEMA_VERSION,
|
|
7694
|
+
error: false,
|
|
7695
|
+
exitCode: 0,
|
|
7696
|
+
ok: true,
|
|
7697
|
+
level: 'safe',
|
|
7698
|
+
root: root,
|
|
7699
|
+
batch: chosen,
|
|
7700
|
+
files: restored,
|
|
7701
|
+
findings: [],
|
|
7702
|
+
summary: scanEmptySummary(),
|
|
7703
|
+
text: '已还原 ' + restored + ' 个文件(批次 ' + chosen + ')。',
|
|
7704
|
+
};
|
|
7705
|
+
}
|
|
7706
|
+
function renderScanText(report) {
|
|
7707
|
+
const label = { safe: '安全', low: '低危', medium: '中危', high: '高危', critical: '严重' };
|
|
7708
|
+
const lines = [
|
|
7709
|
+
'# 元忆记忆库安全扫描(scan)',
|
|
7710
|
+
'',
|
|
7711
|
+
'- 扫描路径: ' + report.root,
|
|
7712
|
+
'- 扫描文件: ' + report.summary.files + '(加密跳过 ' + report.summary.encrypted + (report.summary.mislabelled ? ';.enc 后缀但内容是明文 ' + report.summary.mislabelled + ' 个已按文本扫描' : '') + ')',
|
|
7713
|
+
'- 结果: ' + label[report.level] + ' · 命中 ' + report.summary.hits + ' 条' + (report.summary.truncated ? '(已达上限,结果被截断)' : ''),
|
|
7714
|
+
'- 分级: ' + SCAN_SEVERITIES.map(function (s) { return s + '=' + report.summary.bySeverity[s]; }).join(' / '),
|
|
7715
|
+
'- 类别: ' + SCAN_CLASSES.map(function (c) { return c + '=' + report.summary.byClass[c]; }).join(' / '),
|
|
7716
|
+
];
|
|
7717
|
+
if (report.gate) lines.push('- 门禁: 命中 ' + report.gate + ' 及以上即 exit 1(当前 ' + report.level + ')');
|
|
7718
|
+
if (!report.findings.length) lines.push('- 未发现可疑内容。');
|
|
7719
|
+
for (const finding of report.findings) {
|
|
7720
|
+
lines.push('- [' + label[finding.severity] + '] ' + finding.evidence + ' ' + finding.rule + ' ' + finding.title + '(' + finding.class + ' / ' + finding.ref + ')');
|
|
7721
|
+
lines.push(' 片段: ' + finding.snippet);
|
|
7722
|
+
}
|
|
7723
|
+
if (report.quarantine) {
|
|
7724
|
+
lines.push('');
|
|
7725
|
+
lines.push(report.quarantine.text);
|
|
7726
|
+
}
|
|
7727
|
+
if (report.restore) {
|
|
7728
|
+
lines.push('');
|
|
7729
|
+
lines.push(report.restore.text);
|
|
7730
|
+
}
|
|
7731
|
+
return lines.join('\n');
|
|
7732
|
+
}
|
|
7733
|
+
function scanCore(opts) {
|
|
7734
|
+
opts = opts || {};
|
|
7735
|
+
const root = path.resolve(opts.path || opts.root || userRoot());
|
|
7736
|
+
const gateRaw = Array.isArray(opts.gate) ? opts.gate[opts.gate.length - 1] : opts.gate;
|
|
7737
|
+
let gate = '';
|
|
7738
|
+
if (gateRaw !== undefined && gateRaw !== null && String(gateRaw) !== '') {
|
|
7739
|
+
gate = String(gateRaw).trim().toLowerCase();
|
|
7740
|
+
if (SCAN_SEVERITIES.indexOf(gate) === -1) {
|
|
7741
|
+
const bad = Object.assign({ schemaVersion: SCAN_SCHEMA_VERSION, error: true, exitCode: 2, ok: false, level: 'safe', root: root, findings: [], summary: scanEmptySummary() }, {});
|
|
7742
|
+
bad.text = '安全级别不支持: ' + gateRaw + '。门禁可选安全级别: safe / low / medium / high / critical(例如 --gate high)。';
|
|
7743
|
+
return bad;
|
|
7744
|
+
}
|
|
7745
|
+
}
|
|
7746
|
+
if (!fs.existsSync(root)) {
|
|
7747
|
+
const missing = Object.assign({ schemaVersion: SCAN_SCHEMA_VERSION, error: true, exitCode: 2, ok: false, level: 'safe', root: root, findings: [], summary: scanEmptySummary() }, {});
|
|
7748
|
+
missing.text = '扫描路径不存在: ' + root + '。请检查 --path 指向的记忆库目录。';
|
|
7749
|
+
return missing;
|
|
7750
|
+
}
|
|
7751
|
+
if (opts.restore) return scanRestoreCore(root, opts);
|
|
7752
|
+
|
|
7753
|
+
const findings = [];
|
|
7754
|
+
const byClass = {};
|
|
7755
|
+
for (const cls of SCAN_CLASSES) byClass[cls] = 0;
|
|
7756
|
+
const bySeverity = {};
|
|
7757
|
+
for (const sev of SCAN_SEVERITIES) bySeverity[sev] = 0;
|
|
7758
|
+
let files = 0;
|
|
7759
|
+
let encrypted = 0;
|
|
7760
|
+
let mislabelled = 0;
|
|
7761
|
+
let truncated = false;
|
|
7762
|
+
for (const fp of scanTargetFiles(root)) {
|
|
7763
|
+
files++;
|
|
7764
|
+
if (isEncFile(fp)) {
|
|
7765
|
+
// 历史恢复残留:.enc 后缀但内容其实是明文(缺 YTMENC1 头)→ 按文本继续扫,否则等于漏扫。
|
|
7766
|
+
let head = Buffer.alloc(0);
|
|
7767
|
+
try { head = fs.readFileSync(fp).slice(0, ENC_MAGIC.length); } catch (error) { encrypted++; continue; }
|
|
7768
|
+
if (head.toString('utf8') !== ENC_MAGIC) mislabelled++;
|
|
7769
|
+
else { encrypted++; continue; }
|
|
7770
|
+
}
|
|
7771
|
+
let text = '';
|
|
7772
|
+
try { text = fs.readFileSync(fp, 'utf8'); } catch (error) { continue; }
|
|
7773
|
+
const rel = relOf(root, fp);
|
|
7774
|
+
const lines = text.split(/\r?\n/);
|
|
7775
|
+
for (let i = 0; i < lines.length; i++) {
|
|
7776
|
+
const line = lines[i];
|
|
7777
|
+
if (!line.trim()) continue;
|
|
7778
|
+
for (const rule of MEMORY_SCAN_RULES) {
|
|
7779
|
+
rule.re.lastIndex = 0;
|
|
7780
|
+
const match = rule.re.exec(line);
|
|
7781
|
+
if (!match) continue;
|
|
7782
|
+
if (findings.length >= SCAN_MAX_FINDINGS) { truncated = true; break; }
|
|
7783
|
+
// 片段给命中位置前后各 40 字符(不是行首),否则长行会看起来「没有命中内容」。
|
|
7784
|
+
const start = Math.max(0, match.index - 40);
|
|
7785
|
+
const end = Math.min(line.length, match.index + match[0].length + 40);
|
|
7786
|
+
const snippet = scrubSecrets((start > 0 ? '…' : '') + line.slice(start, end) + (end < line.length ? '…' : '')).slice(0, 200);
|
|
7787
|
+
findings.push({
|
|
7788
|
+
file: rel,
|
|
7789
|
+
line: i + 1,
|
|
7790
|
+
evidence: rel + ':' + (i + 1),
|
|
7791
|
+
rule: rule.id,
|
|
7792
|
+
class: rule.class,
|
|
7793
|
+
severity: rule.severity,
|
|
7794
|
+
title: rule.title,
|
|
7795
|
+
ref: rule.ref,
|
|
7796
|
+
match: scrubSecrets(match[0]).slice(0, 80),
|
|
7797
|
+
snippet: snippet,
|
|
7798
|
+
});
|
|
7799
|
+
byClass[rule.class]++;
|
|
7800
|
+
bySeverity[rule.severity]++;
|
|
7801
|
+
}
|
|
7802
|
+
if (truncated) break;
|
|
7803
|
+
}
|
|
7804
|
+
if (truncated) break;
|
|
7805
|
+
}
|
|
7806
|
+
const hitSeverities = SCAN_SEVERITIES.filter(function (sev) { return bySeverity[sev] > 0; });
|
|
7807
|
+
const level = hitSeverities.length ? hitSeverities[hitSeverities.length - 1] : 'safe';
|
|
7808
|
+
const summary = {
|
|
7809
|
+
files: files,
|
|
7810
|
+
encrypted: encrypted,
|
|
7811
|
+
mislabelled: mislabelled,
|
|
7812
|
+
scanned: files - encrypted,
|
|
7813
|
+
hits: findings.length,
|
|
7814
|
+
byClass: byClass,
|
|
7815
|
+
bySeverity: bySeverity,
|
|
7816
|
+
maxSeverity: level,
|
|
7817
|
+
truncated: truncated,
|
|
7818
|
+
};
|
|
7819
|
+
const report = {
|
|
7820
|
+
schemaVersion: SCAN_SCHEMA_VERSION,
|
|
7821
|
+
error: false,
|
|
7822
|
+
exitCode: 0,
|
|
7823
|
+
ok: level === 'safe',
|
|
7824
|
+
level: level,
|
|
7825
|
+
root: root,
|
|
7826
|
+
gate: gate,
|
|
7827
|
+
findings: findings,
|
|
7828
|
+
summary: summary,
|
|
7829
|
+
quarantine: null,
|
|
7830
|
+
restore: null,
|
|
7831
|
+
};
|
|
7832
|
+
if (opts.quarantine) {
|
|
7833
|
+
if (!opts.yes) {
|
|
7834
|
+
report.error = true;
|
|
7835
|
+
report.exitCode = 2;
|
|
7836
|
+
report.text = '拒绝: --quarantine 会改写记忆文件,需要显式确认。确认后重试:yotta-memory scan --quarantine --yes;未确认时不会改动任何文件。';
|
|
7837
|
+
return report;
|
|
7838
|
+
}
|
|
7839
|
+
const quarantined = scanQuarantineCore(root, findings);
|
|
7840
|
+
if (quarantined.error) {
|
|
7841
|
+
report.error = true;
|
|
7842
|
+
report.exitCode = 2;
|
|
7843
|
+
report.text = quarantined.text;
|
|
7844
|
+
return report;
|
|
7845
|
+
}
|
|
7846
|
+
report.quarantine = quarantined;
|
|
7847
|
+
}
|
|
7848
|
+
report.text = renderScanText(report);
|
|
7849
|
+
if (gate) {
|
|
7850
|
+
const gateRank = scanSeverityRank(gate);
|
|
7851
|
+
const levelRank = scanSeverityRank(level);
|
|
7852
|
+
const failed = gate === 'safe' ? level !== 'safe' : levelRank >= gateRank;
|
|
7853
|
+
if (failed) report.exitCode = 1;
|
|
7854
|
+
}
|
|
7855
|
+
return report;
|
|
7856
|
+
}
|
|
7857
|
+
// 交互确认:非 TTY 一律返回 null(由调用方给 --yes 指引),避免自动化里静默改写记忆。
|
|
7858
|
+
function promptYesNo(promptText) {
|
|
7859
|
+
return new Promise(function (resolve) {
|
|
7860
|
+
const stdin = process.stdin;
|
|
7861
|
+
if (!stdin.isTTY) { resolve(null); return; }
|
|
7862
|
+
process.stdout.write(promptText + ' [y/N] ');
|
|
7863
|
+
let buf = '';
|
|
7864
|
+
function done(abort) {
|
|
7865
|
+
try { stdin.setRawMode(false); } catch (e) {}
|
|
7866
|
+
stdin.pause();
|
|
7867
|
+
stdin.removeListener('data', onData);
|
|
7868
|
+
process.stdout.write('\n');
|
|
7869
|
+
resolve(abort ? null : /^y(es)?$/i.test(buf.trim()));
|
|
7870
|
+
}
|
|
7871
|
+
function onData(chunk) {
|
|
7872
|
+
const s = String(chunk);
|
|
7873
|
+
for (const c of s) {
|
|
7874
|
+
if (c === '\r' || c === '\n') { done(false); return; }
|
|
7875
|
+
if (c === '\u0003') { done(true); return; }
|
|
7876
|
+
if (c === '\u007f' || c === '\b') buf = buf.slice(0, -1);
|
|
7877
|
+
else buf += c;
|
|
7878
|
+
}
|
|
7879
|
+
}
|
|
7880
|
+
try { stdin.setRawMode(true); } catch (e) { resolve(null); return; }
|
|
7881
|
+
stdin.resume();
|
|
7882
|
+
stdin.setEncoding('utf8');
|
|
7883
|
+
stdin.on('data', onData);
|
|
7884
|
+
});
|
|
7885
|
+
}
|
|
7886
|
+
async function cmdScan(opts) {
|
|
7887
|
+
opts = opts || {};
|
|
7888
|
+
if (opts.quarantine && !opts.yes) {
|
|
7889
|
+
const preview = scanCore(Object.assign({}, opts, { quarantine: false }));
|
|
7890
|
+
if (preview.error) {
|
|
7891
|
+
console.log(opts.json ? JSON.stringify(preview, null, 2) : preview.text);
|
|
7892
|
+
process.exit(2);
|
|
7893
|
+
}
|
|
7894
|
+
const answer = await promptYesNo('即将隔离 ' + preview.summary.hits + ' 处命中(原文件先备份到 ' + SCAN_QUARANTINE_DIR + '/quarantine)');
|
|
7895
|
+
if (answer !== true) {
|
|
7896
|
+
console.log('已取消:未确认时不会改动记忆文件。确认后请重试:yotta-memory scan --quarantine --yes');
|
|
7897
|
+
process.exit(2);
|
|
7898
|
+
}
|
|
7899
|
+
opts = Object.assign({}, opts, { yes: true });
|
|
7900
|
+
}
|
|
7901
|
+
const r = scanCore(opts);
|
|
7902
|
+
console.log(opts.json ? JSON.stringify(r, null, 2) : r.text);
|
|
7903
|
+
if (r.error) process.exit(r.exitCode || 2);
|
|
7904
|
+
if (r.exitCode) process.exit(r.exitCode);
|
|
7905
|
+
}
|
|
7906
|
+
|
|
5808
7907
|
// ---- config 命令 ----
|
|
5809
7908
|
// v0.10.0:maintain_* / consolidate_* 数值键纳入 config set/get(此前只支持 3 个键但文档已写可调)
|
|
5810
|
-
function isNumericConfigKey(key) { return /^(maintain_|consolidate_|backup_max_age_hours$)/.test(key); }
|
|
7909
|
+
function isNumericConfigKey(key) { return /^(maintain_|consolidate_|scale_|backup_max_age_hours$)/.test(key); }
|
|
7910
|
+
// v0.17.0 B2:doctor 规模体检阈值键(0 表示只要超过 0 就告警,便于压测与演练)
|
|
7911
|
+
const SCALE_CONFIG_KEYS = ['scale_warn_entries', 'scale_warn_files_per_dir', 'scale_warn_index_bytes', 'scale_warn_cold_start_ms'];
|
|
5811
7912
|
function cmdConfigSet(key, value) {
|
|
5812
|
-
const known = ['memory_home', 'embedding_cmd', 'embedding_timeout', 'backup_dir', 'backup_enabled', 'backup_schedule', 'backup_time', 'backup_max_age_hours', 'backup_setup_choice', 'maintain_archived_utility', 'maintain_archived_age', 'maintain_forget_utility', 'maintain_forget_age', 'maintain_decay_halflife_FACT', 'maintain_decay_halflife_PREF', 'maintain_decay_halflife_COMMIT', 'consolidate_min_age', 'consolidate_min_idle', 'consolidate_max_utility', 'consolidate_min_group', 'consolidate_period'];
|
|
5813
|
-
if (known.indexOf(key) === -1) { console.error('未知配置项: ' + key + '(可用: memory_home / backup_dir / embedding_cmd / embedding_timeout / maintain_* / consolidate_*)'); process.exit(2); }
|
|
7913
|
+
const known = ['memory_home', 'embedding_cmd', 'embedding_timeout', 'backup_dir', 'backup_enabled', 'backup_schedule', 'backup_time', 'backup_max_age_hours', 'backup_setup_choice', 'maintain_archived_utility', 'maintain_archived_age', 'maintain_forget_utility', 'maintain_forget_age', 'maintain_decay_halflife_FACT', 'maintain_decay_halflife_PREF', 'maintain_decay_halflife_COMMIT', 'consolidate_min_age', 'consolidate_min_idle', 'consolidate_max_utility', 'consolidate_min_group', 'consolidate_period'].concat(SCALE_CONFIG_KEYS);
|
|
7914
|
+
if (known.indexOf(key) === -1) { console.error('未知配置项: ' + key + '(可用: memory_home / backup_dir / embedding_cmd / embedding_timeout / maintain_* / consolidate_* / scale_*)'); process.exit(2); }
|
|
5814
7915
|
if (value === undefined || value === null || value === '') { console.error('缺少值: config set ' + key + ' <值>'); process.exit(2); }
|
|
5815
7916
|
const cfg = loadConfig();
|
|
5816
7917
|
if (key === 'memory_home') cfg.memory_home = value;
|
|
@@ -5843,7 +7944,7 @@ function cmdConfigGet(opts) {
|
|
|
5843
7944
|
console.log('backup_time: ' + (cfg.backup_time || '(未设置)'));
|
|
5844
7945
|
console.log('embedding_cmd: ' + (cfg.embedding_cmd || '(未设置)'));
|
|
5845
7946
|
console.log('embedding_timeout: ' + (cfg.embedding_timeout || 3000));
|
|
5846
|
-
const keys = ['memory_home', 'backup_dir', 'backup_enabled', 'backup_schedule', 'backup_time', 'backup_max_age_hours', 'backup_setup_choice', 'embedding_cmd', 'embedding_timeout', 'maintain_archived_utility', 'maintain_archived_age', 'maintain_forget_utility', 'maintain_forget_age', 'maintain_decay_halflife_FACT', 'maintain_decay_halflife_PREF', 'maintain_decay_halflife_COMMIT', 'consolidate_min_age', 'consolidate_min_idle', 'consolidate_max_utility', 'consolidate_min_group', 'consolidate_period'];
|
|
7947
|
+
const keys = ['memory_home', 'backup_dir', 'backup_enabled', 'backup_schedule', 'backup_time', 'backup_max_age_hours', 'backup_setup_choice', 'embedding_cmd', 'embedding_timeout', 'maintain_archived_utility', 'maintain_archived_age', 'maintain_forget_utility', 'maintain_forget_age', 'maintain_decay_halflife_FACT', 'maintain_decay_halflife_PREF', 'maintain_decay_halflife_COMMIT', 'consolidate_min_age', 'consolidate_min_idle', 'consolidate_max_utility', 'consolidate_min_group', 'consolidate_period'].concat(SCALE_CONFIG_KEYS);
|
|
5847
7948
|
for (const k of keys) {
|
|
5848
7949
|
if (k === 'memory_home' || k === 'backup_dir' || k === 'backup_enabled' || k === 'backup_schedule' || k === 'backup_time' || k === 'embedding_cmd' || k === 'embedding_timeout') continue;
|
|
5849
7950
|
if (cfg[k] !== undefined) console.log(k + ': ' + cfg[k]);
|
|
@@ -7194,68 +9295,89 @@ function cmdRuntimeStatus() {
|
|
|
7194
9295
|
function cmdRuntimeList() {
|
|
7195
9296
|
console.log(runtimeListCore().text);
|
|
7196
9297
|
}
|
|
7197
|
-
function
|
|
7198
|
-
|
|
7199
|
-
const
|
|
7200
|
-
|
|
7201
|
-
|
|
7202
|
-
|
|
7203
|
-
|
|
7204
|
-
|
|
7205
|
-
|
|
7206
|
-
|
|
7207
|
-
|
|
7208
|
-
|
|
7209
|
-
|
|
7210
|
-
|
|
7211
|
-
|
|
7212
|
-
|
|
7213
|
-
|
|
7214
|
-
|
|
7215
|
-
|
|
7216
|
-
|
|
7217
|
-
|
|
7218
|
-
|
|
7219
|
-
|
|
7220
|
-
|
|
7221
|
-
|
|
7222
|
-
|
|
7223
|
-
|
|
7224
|
-
|
|
7225
|
-
|
|
7226
|
-
|
|
7227
|
-
|
|
7228
|
-
|
|
7229
|
-
|
|
7230
|
-
|
|
7231
|
-
|
|
7232
|
-
|
|
7233
|
-
|
|
7234
|
-
|
|
7235
|
-
|
|
7236
|
-
|
|
9298
|
+
function helpDisplayWidth(text) {
|
|
9299
|
+
let width = 0;
|
|
9300
|
+
for (const ch of String(text)) {
|
|
9301
|
+
const cp = ch.codePointAt(0);
|
|
9302
|
+
width += (
|
|
9303
|
+
(cp >= 0x1100 && cp <= 0x115f) ||
|
|
9304
|
+
(cp >= 0x2e80 && cp <= 0xa4cf) ||
|
|
9305
|
+
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
9306
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
9307
|
+
(cp >= 0xfe30 && cp <= 0xfe6f) ||
|
|
9308
|
+
(cp >= 0xff00 && cp <= 0xff60) ||
|
|
9309
|
+
(cp >= 0xffe0 && cp <= 0xffe6)
|
|
9310
|
+
) ? 2 : 1;
|
|
9311
|
+
}
|
|
9312
|
+
return width;
|
|
9313
|
+
}
|
|
9314
|
+
function helpPad(text, width) {
|
|
9315
|
+
return text + ' '.repeat(Math.max(0, width - helpDisplayWidth(text)));
|
|
9316
|
+
}
|
|
9317
|
+
function helpText(item) {
|
|
9318
|
+
const parts = [];
|
|
9319
|
+
if (item.what) parts.push('做什么:' + item.what);
|
|
9320
|
+
if (item.when) parts.push('什么时候用:' + item.when);
|
|
9321
|
+
if (item.caution) parts.push('注意:' + item.caution);
|
|
9322
|
+
return parts.join(';');
|
|
9323
|
+
}
|
|
9324
|
+
function helpOptionLabel(option) {
|
|
9325
|
+
return option.flag + (option.arg ? ' ' + option.arg : '');
|
|
9326
|
+
}
|
|
9327
|
+
function renderHelp(model) {
|
|
9328
|
+
const commands = [];
|
|
9329
|
+
for (const group of model) for (const command of group.commands) commands.push(command);
|
|
9330
|
+
|
|
9331
|
+
let optionCol = 0;
|
|
9332
|
+
for (const command of commands) {
|
|
9333
|
+
for (const option of command.options || []) optionCol = Math.max(optionCol, helpDisplayWidth(helpOptionLabel(option)));
|
|
9334
|
+
for (const sub of command.subcommands || []) {
|
|
9335
|
+
for (const option of sub.options || []) optionCol = Math.max(optionCol, helpDisplayWidth(helpOptionLabel(option)));
|
|
9336
|
+
}
|
|
9337
|
+
}
|
|
9338
|
+
optionCol += 2;
|
|
9339
|
+
|
|
9340
|
+
const lines = [
|
|
9341
|
+
'yotta-memory v' + VERSION + ' — 元忆:有权限边界的文件式智能体记忆',
|
|
9342
|
+
'',
|
|
9343
|
+
'用法:',
|
|
9344
|
+
' yotta-memory <命令> [选项]',
|
|
9345
|
+
' yotta-memory --help 显示这份完整帮助',
|
|
9346
|
+
' yotta-memory --version 显示版本',
|
|
9347
|
+
'',
|
|
7237
9348
|
];
|
|
7238
|
-
|
|
7239
|
-
|
|
7240
|
-
|
|
7241
|
-
|
|
7242
|
-
|
|
7243
|
-
|
|
7244
|
-
|
|
9349
|
+
for (const group of model) {
|
|
9350
|
+
lines.push(group.group + ':');
|
|
9351
|
+
for (const command of group.commands) {
|
|
9352
|
+
lines.push(' ' + command.name + ' ' + helpText(command));
|
|
9353
|
+
lines.push(' 用法: yotta-memory ' + command.usage);
|
|
9354
|
+
for (const sub of command.subcommands || []) {
|
|
9355
|
+
lines.push(' ' + (sub.usage || sub.name) + ' ' + helpText(sub));
|
|
9356
|
+
for (const option of sub.options || []) {
|
|
9357
|
+
lines.push(' ' + helpPad(helpOptionLabel(option), optionCol) + helpText(option));
|
|
9358
|
+
}
|
|
9359
|
+
}
|
|
9360
|
+
for (const option of command.options || []) {
|
|
9361
|
+
lines.push(' ' + helpPad(helpOptionLabel(option), optionCol) + helpText(option));
|
|
9362
|
+
}
|
|
9363
|
+
}
|
|
7245
9364
|
lines.push('');
|
|
7246
9365
|
}
|
|
7247
9366
|
lines.push('类型: FACT(公共共享) / PREF(偏好) / BOUND(边界) / COMMIT(承诺)');
|
|
7248
9367
|
lines.push('环境变量: YOTTA_MEMORY_HOME 临时覆盖用户级位置; 身份不再读取 env;CLI 用 --agent + --agent-key/--agent-key-file,stdio MCP 用 --agent-id + --agent-key-file,HTTP MCP 用请求头');
|
|
7249
9368
|
lines.push('隔离: 公共 FACT 在 facts/;私密 PREF/BOUND/COMMIT 物理分目录 private/<agent_id>/<type>/,禁止 shell 直读写记忆库,一律走本命令');
|
|
7250
9369
|
lines.push('远端接入: MCP url http://<主机IP>:8787/mcp;请求头 Authorization: Bearer <token> + X-Agent-Id: <id> + X-Agent-Key: <agent_key>');
|
|
7251
|
-
|
|
9370
|
+
return lines.join('\n');
|
|
9371
|
+
}
|
|
9372
|
+
function usage() {
|
|
9373
|
+
console.log(renderHelp(HELP_MODEL));
|
|
7252
9374
|
}
|
|
7253
9375
|
async function main() {
|
|
7254
9376
|
const args = process.argv.slice(2);
|
|
7255
9377
|
if (!args.length) { usage(); return; }
|
|
7256
9378
|
const opts = {};
|
|
7257
9379
|
const positional = [];
|
|
7258
|
-
const valueOpts =
|
|
9380
|
+
const valueOpts = CLI_VALUE_OPTS;
|
|
7259
9381
|
for (let i = 0; i < args.length; i++) {
|
|
7260
9382
|
const a = args[i];
|
|
7261
9383
|
if (a === '--version' || a === '-v') { console.log(VERSION); return; }
|
|
@@ -7290,6 +9412,15 @@ async function main() {
|
|
|
7290
9412
|
else if (a === '--explain') opts.explain = true;
|
|
7291
9413
|
else if (a === '--semantic') opts.semantic = true;
|
|
7292
9414
|
else if (a === '--runtime') opts.runtime = true;
|
|
9415
|
+
else if (a === '--ablate') opts.ablate = true;
|
|
9416
|
+
else if (a === '--timing') opts.timing = true;
|
|
9417
|
+
else if (a === '--baseline') opts.baseline = true;
|
|
9418
|
+
else if (a === '--probe') opts.probe = true;
|
|
9419
|
+
else if (a === '--quarantine') opts.quarantine = true;
|
|
9420
|
+
else if (a === '--restore') opts.restore = true;
|
|
9421
|
+
else if (a === '--yes') opts.yes = true;
|
|
9422
|
+
else if (a === '--keep-memories') opts.keepMemories = true;
|
|
9423
|
+
else if (a === '--keep-identity') opts.keepIdentity = true;
|
|
7293
9424
|
else if (valueOpts.has(a)) {
|
|
7294
9425
|
const v = args[++i];
|
|
7295
9426
|
if (a === '--type') opts.type = v;
|
|
@@ -7335,6 +9466,15 @@ async function main() {
|
|
|
7335
9466
|
else if (a === '--tools') opts.toolProfile = v;
|
|
7336
9467
|
else if (a === '--mcp-config') opts.mcpConfigPaths = (opts.mcpConfigPaths || []).concat(v);
|
|
7337
9468
|
else if (a === '--skill-dir') opts.skillDirs = (opts.skillDirs || []).concat(v);
|
|
9469
|
+
else if (a === '--year') opts.years = (opts.years || []).concat(v);
|
|
9470
|
+
else if (a === '--evalset') opts.evalset = v;
|
|
9471
|
+
else if (a === '--k') opts.k = parseInt(v, 10);
|
|
9472
|
+
else if (a === '--seed') opts.seed = parseInt(v, 10);
|
|
9473
|
+
else if (a === '--bootstrap') opts.bootstrap = parseInt(v, 10);
|
|
9474
|
+
else if (a === '--gate') opts.gate = (opts.gate || []).concat(v);
|
|
9475
|
+
else if (a === '--against') opts.against = v;
|
|
9476
|
+
else if (a === '--template') opts.template = v;
|
|
9477
|
+
else if (a === '--path') opts.path = v;
|
|
7338
9478
|
} else if (a.startsWith('--')) {
|
|
7339
9479
|
if (a === '--query') {
|
|
7340
9480
|
console.error('未知选项: --query。recall/search 的关键词是位置参数:yotta-memory recall [关键词]');
|
|
@@ -7369,6 +9509,12 @@ async function main() {
|
|
|
7369
9509
|
case 'init': await cmdInit(opts); break;
|
|
7370
9510
|
case 'whoami': cmdWhoami(opts); break;
|
|
7371
9511
|
case 'iam': cmdIam(rest[0], opts); break;
|
|
9512
|
+
case 'identity': {
|
|
9513
|
+
const sub = rest[0];
|
|
9514
|
+
if (sub === 'remove') await cmdIdentityRemove(rest[1], opts);
|
|
9515
|
+
else { console.error('identity 子命令: remove <id> [--dry-run] [--yes] [--keep-memories] [--keep-identity] [--password <口令> | --recovery-key <钥匙>]'); process.exit(2); }
|
|
9516
|
+
break;
|
|
9517
|
+
}
|
|
7372
9518
|
case 'config': {
|
|
7373
9519
|
const sub = rest[0];
|
|
7374
9520
|
if (sub === 'set') cmdConfigSet(rest[1], rest[2]);
|
|
@@ -7381,6 +9527,8 @@ async function main() {
|
|
|
7381
9527
|
case 'search': cmdRecall(rest[0] || null, opts); break;
|
|
7382
9528
|
case 'feedback': cmdFeedback(rest[0], opts); break;
|
|
7383
9529
|
case 'explain': cmdExplain(rest[0], opts); break;
|
|
9530
|
+
case 'bench': cmdBench(opts); break;
|
|
9531
|
+
case 'scan': await cmdScan(opts); break;
|
|
7384
9532
|
case 'maintain': cmdMaintain(opts); break;
|
|
7385
9533
|
case 'distill': cmdDistill(opts); break;
|
|
7386
9534
|
case 'consolidate': if (opts.undo === true && rest.length) opts.undo = rest[0]; cmdConsolidate(opts); break;
|
|
@@ -7459,6 +9607,10 @@ async function main() {
|
|
|
7459
9607
|
if (require.main === module) { main().catch(function (e) { console.error('错误: ' + (e && e.message ? e.message : String(e))); console.error('修复建议: 若与记忆库/密钥/权限有关,请检查 memory_home 路径、主口令与恢复钥匙,或运行 yotta-memory config get 确认位置;仍无法解决请把上面的错误信息反馈给开发者。'); process.exit(2); }); }
|
|
7460
9608
|
module.exports = {
|
|
7461
9609
|
VERSION: VERSION,
|
|
9610
|
+
HELP_MODEL: HELP_MODEL,
|
|
9611
|
+
CLI_FLAG_OPTS: CLI_FLAG_OPTS,
|
|
9612
|
+
CLI_VALUE_OPTS: CLI_VALUE_OPTS,
|
|
9613
|
+
renderHelp: renderHelp,
|
|
7462
9614
|
userRoot: userRoot,
|
|
7463
9615
|
projectRoot: projectRoot,
|
|
7464
9616
|
memoryRoots: memoryRoots,
|
|
@@ -7484,8 +9636,32 @@ module.exports = {
|
|
|
7484
9636
|
loadTokens: loadTokens,
|
|
7485
9637
|
saveTokens: saveTokens,
|
|
7486
9638
|
collectEntryFiles: collectEntryFiles,
|
|
9639
|
+
walkEntryFiles: walkEntryFiles,
|
|
7487
9640
|
migrateLayout: migrateLayout,
|
|
9641
|
+
loadIndexFor: loadIndexFor,
|
|
9642
|
+
indexEntriesFor: indexEntriesFor,
|
|
9643
|
+
normalizeYearList: normalizeYearList,
|
|
9644
|
+
indexFingerprint: indexFingerprint,
|
|
9645
|
+
benchCore: benchCore,
|
|
9646
|
+
baselineCore: baselineCore,
|
|
9647
|
+
loadBaselineTemplate: loadBaselineTemplate,
|
|
9648
|
+
scanCore: scanCore,
|
|
9649
|
+
scanQuarantineCore: scanQuarantineCore,
|
|
9650
|
+
scanRestoreCore: scanRestoreCore,
|
|
9651
|
+
promptYesNo: promptYesNo,
|
|
9652
|
+
MEMORY_SCAN_RULES: MEMORY_SCAN_RULES,
|
|
9653
|
+
SCAN_CLASSES: SCAN_CLASSES,
|
|
9654
|
+
SCAN_SEVERITIES: SCAN_SEVERITIES,
|
|
9655
|
+
SCAN_QUARANTINE_DIR: SCAN_QUARANTINE_DIR,
|
|
9656
|
+
exportCore: exportCore,
|
|
9657
|
+
importCore: importCore,
|
|
7488
9658
|
typeSubdir: typeSubdir,
|
|
9659
|
+
dateSubdir: dateSubdir,
|
|
9660
|
+
entryWriteDir: entryWriteDir,
|
|
9661
|
+
archiveRelFor: archiveRelFor,
|
|
9662
|
+
SCALE_CONFIG_KEYS: SCALE_CONFIG_KEYS,
|
|
9663
|
+
SCALE_THRESHOLD_DEFAULTS: SCALE_THRESHOLD_DEFAULTS,
|
|
9664
|
+
scaleReport: scaleReport,
|
|
7489
9665
|
runtimeRoot: runtimeRoot,
|
|
7490
9666
|
runtimeManifestPath: runtimeManifestPath,
|
|
7491
9667
|
runtimeVersionsDir: runtimeVersionsDir,
|
|
@@ -7592,6 +9768,7 @@ module.exports = {
|
|
|
7592
9768
|
|
|
7593
9769
|
loadIndex: loadIndex,
|
|
7594
9770
|
saveIndex: saveIndex,
|
|
9771
|
+
buildIndex: buildIndex,
|
|
7595
9772
|
getIndex: getIndex,
|
|
7596
9773
|
touchIndex: touchIndex,
|
|
7597
9774
|
loadIndexManifest: loadIndexManifest,
|
|
@@ -7607,6 +9784,10 @@ module.exports = {
|
|
|
7607
9784
|
keyBindCore: keyBindCore,
|
|
7608
9785
|
keyRevokeCore: keyRevokeCore,
|
|
7609
9786
|
keyClaimCore: keyClaimCore,
|
|
9787
|
+
identityRemoveCore: identityRemoveCore,
|
|
9788
|
+
identityRemoveInventory: identityRemoveInventory,
|
|
9789
|
+
verifyIdentityRemoveAuthority: verifyIdentityRemoveAuthority,
|
|
9790
|
+
IDENTITY_REMOVE_STEPS: IDENTITY_REMOVE_STEPS,
|
|
7610
9791
|
keyStatusCore: keyStatusCore,
|
|
7611
9792
|
writeAgentBinding: writeAgentBinding,
|
|
7612
9793
|
unwrapAgentBinding: unwrapAgentBinding,
|