@yottameta/yotta-memory 0.16.7 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,9 +25,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.16.7';
28
+ const VERSION = '0.17.1';
29
+ const CLI_VALUE_OPTS = new Set(['--type', '--limit', '--days', '--out', '--owner', '--agent', '--agent-id', '--agent-key', '--agent-key-file', '--plugin-data', '--threshold', '--scope', '--host', '--port', '--dir', '--name', '--user', '--relationship', '--source', '--weight', '--budget', '--password', '--new-password', '--recovery-key', '--recovery-key-out', '--reason', '--merge', '--model', '--subject', '--embedding', '--focus', '--embedding-timeout', '--min-age', '--min-idle', '--max-utility', '--min-group', '--period', '--to', '--id', '--time', '--tools', '--mcp-config', '--skill-dir', '--year', '--evalset', '--k', '--seed', '--bootstrap', '--gate', '--against', '--template', '--path']);
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 &lt;钥匙&gt;</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/&lt;agent_id&gt;.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{'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[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);}\r\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();});};});\r\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";
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 &lt;钥匙&gt;</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/&lt;agent_id&gt;.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{'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[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===null)return;if(typed!==owner){alert('ID 不匹配,请输入完整 agent ID:'+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/(公共 FACT) private/<agent_id>/{prefs,bounds,commits}/(各智能体私密,物理隔离) .archive/(归档)。\n', 'utf8');
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
- function nextSeq(dir) {
1406
+ // 序号在「类型目录 + owner」范围内唯一,跨越旧平铺与新年/月分层,避免同名覆盖。
1407
+ function maxSeqInTree(dir) {
1101
1408
  let max = 0;
1102
- if (fs.existsSync(dir)) {
1103
- for (const f of fs.readdirSync(dir)) {
1104
- const m = f.match(/^\d{4}-\d{2}-\d{2}-(\d{4})\.md(\.enc)?$/);
1105
- if (m) max = Math.max(max, parseInt(m[1], 10));
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 = String(e.created || '').slice(0, 4) || String(today()).slice(0, 4);
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
- for (const dir of dirs) {
1642
- for (const f of fs.readdirSync(dir)) {
1643
- if (!/\.md(\.enc)?$/.test(f)) continue;
1644
- const fp = path.join(dir, f);
1645
- if (fs.statSync(fp).isFile()) out.push(fp);
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
- return out;
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
- const factsDir = path.join(root, PUBLIC_DIR);
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
- for (const owner of collectOwners(root)) {
2977
- for (const t of PRIVATE_LEAF) {
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
- try {
2991
- return fs.readdirSync(factsDir).some(function (f) { return /\.md(\.enc)?$/.test(f); });
2992
- } catch (e) {
2993
- return false;
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
- const checks = { manifest: true, index: true, private: privateCheck };
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 dir = path.join(root, typeSubdir(t, owner));
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
- if (fs.existsSync(dir)) {
3508
- for (const f of fs.readdirSync(dir)) {
3509
- const fp = path.join(dir, f);
3510
- if (!fs.statSync(fp).isFile()) continue;
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: today() };
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 = nextSeq(dir);
4173
+ const seq = nextSeqFor(typeDir, dir);
3537
4174
  const suffix = (encrypted && scope === 'private') ? ENC_SUFFIX : '';
3538
- const file = path.join(dir, today() + '-' + seq + '.md' + suffix);
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: today(), updated: today(),
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 = ensureIndex(root).filter(function (e) {
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 = ensureIndex(root);
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
- let score = 0;
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
- hits.sort(function (a, b) {
3711
- if (b.finalScore !== a.finalScore) return b.finalScore - a.finalScore;
3712
- if (b.score !== a.score) return b.score - a.score;
3713
- const pa = a.root === projectRoot() ? 0 : 1;
3714
- const pb = b.root === projectRoot() ? 0 : 1;
3715
- if (pa !== pb) return pa - pb;
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 destDir = archiveDirFor(root, rec.type, rec.owner);
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, path.join(destDir, path.basename(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 destDir = archiveDirFor(root, rec.type, rec.owner);
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
- for (const f of fs.readdirSync(d)) {
4368
- if (!f.endsWith('.md')) continue;
4369
- const fp = path.join(d, f);
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
- function keyClaimCore(root, owner, opts) {
4537
- if (!isSafeAgentId(owner)) return { error: true, text: '非法 agent ID:只允许单段名称,禁止 /、\\、.. 和控制字符。' };
4538
- const location = resolveAgentKeyLocation(owner, opts);
4539
- if (!location.ok) return { error: true, text: location.error };
4540
- const pending = encPendingPath(root, owner);
4541
- if (!fs.existsSync(pending)) {
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
- const dir = path.join(root, typeSubdir(t, owner));
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 = nextSeq(dir);
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, today() + '-' + seq + '.md' + suffix);
5226
- while (fs.existsSync(file)) { seq = String(parseInt(seq, 10) + 1).padStart(4, '0'); file = path.join(dir, today() + '-' + seq + '.md' + suffix); }
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: meta.created || today(),
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 ensureIndex(r)) {
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 usage() {
7198
- const banner = 'yotta-memory v' + VERSION + ' — 元忆:有权限边界的文件式智能体记忆';
7199
- const sections = [
7200
- ['核心记忆', [
7201
- ['init', '初始化记忆库(新建默认加密:需主口令 + 恢复钥匙;--attach 接入已有库;--no-encrypt 降级明文;非 TTY 用 --password-stdin;--recovery-key-out <文件> 写恢复钥匙)'],
7202
- ['remember <type> <subject> <statement>', '写入记忆(--source 来源;--weight 权重;--verify 写后回读校验;--no-hint 关启发)'],
7203
- ['recall [关键词]', '检索记忆(--type/--limit/--agent/--owner/--all/--unsafe)'],
7204
- ['forget', '删除一条记忆'],
7205
- ['archive', '归档(--days/--threshold 盖棺分+年龄)'],
7206
- ['backup', '备份记忆库(volumes / setup / status / ensure-daily / schedule / create / list / doctor / restore <id> --to <目录> / drill)'],
7207
- ['doctor', '开工可靠性检查(根目录/密钥库/索引/身份/最近备份;全新空库缺失 index/agents 降为 info;输出 YOTTA_MEMORY_AGENT_HOME 提示;--runtime 加查 CLI/current/MCP/进程/技能副本漂移)'],
7208
- ['maintain', '记忆自组织(归档/遗忘候选/去重/合并;默认 dry-run;--dedup 查重+置信度,--dedup --apply 自动合并高置信组)'],
7209
- ['consolidate', '周期摘要压缩(默认 dry-run;--apply 执行;--undo <batch> 回滚;--batches 查批次;候选=超龄+闲置+低效用,immutable/BOUND 豁免)'],
7210
- ['distill', '心理日志蒸馏(统计摘要/主题画像/知识地图;--model 可选外部模型)'],
7211
- ['feedback', '使用反馈(--useful/--useless/--undo)'],
7212
- ['explain', '查看单条记忆效用分项与归档/遗忘状态判定'],
7213
- ['reindex', '重建索引'],
7214
- ['export', '导出全部记忆(--out 文件.json)'],
7215
- ['import', '导入记忆(<文件.json>)']
7216
- ]],
7217
- ['身份与画像', [
7218
- ['iam', '登记本智能体唯一身份(--name/--user/--relationship;agents.json)'],
7219
- ['whoami', '查看当前智能体身份与登记状态'],
7220
- ['profile', '生成用户画像(聚合 private/<owner>/ 原文,零推断)'],
7221
- ['context', '生成开工上下文包(--limit/--owner/--budget)'],
7222
- ['token', '生成/列出/吊销访问 token(new --agent / list / revoke --agent)']
7223
- ]],
7224
- ['加密与安全', [
7225
- ['migrate', '把明文库迁移为密文(需主口令;空明文库同样可用;--password-stdin 仅用于纯 ASCII 管道;--recovery-key-out <文件> 写恢复钥匙;首次迁移推荐交互式:yotta-memory migrate --recovery-key-out <文件>;非 ASCII 口令请交互输入,勿用 echo 中文管道;迁移后授权二选一:推荐 yotta-memory view,等价 CLI 为 yotta-memory key bind <id>)'],
7226
- ['view', '启动用户查看平台(--port/--host;复用前校验 memory_home 指纹,跨库或旧版无指纹服务会拒绝复用;空加密库可用恢复钥匙校验主口令)'],
7227
- ['reset-password', '重设主口令(忘口令用恢复钥匙)'],
7228
- ['key', '管理 agent_key binding(list / bind <id> / rotate <id> / claim <id> [--to <AI_HOME> | --plugin-data <PLUGIN_DATA> | --agent-key-file <文件>] / status <id> [--to <AI_HOME> | --plugin-data <PLUGIN_DATA> | --agent-key-file <文件>] / revoke <id>;bind/rotate 需主口令或恢复钥匙;--plugin-data 供 Agent Plugin 一条命令绑定插件身份)'],
7229
- ['config', '查看/设置配置(get;set memory_home <目录> / backup_dir <目录> / embedding_cmd <命令> / embedding_timeout <毫秒> / maintain_* 阈值与半衰 / consolidate_* 参数)']
7230
- ]],
7231
- ['平台与服务', [
7232
- ['serve', '启动 MCP 记忆引擎(streamable HTTP;--stdio 本地零进程;--tools core|full 控制工具分组)'],
7233
- ['runtime', '运行时稳定入口(list / install <tarball|版本> [--from-current] [--force] / use <版本> [--restart] / rollback [--restart] / status)'],
7234
- ['lan', '开机自启管理(enable/disable/status;Windows 计划任务 / Linux systemd/crontab)'],
7235
- ['--version', '版本']
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
- let col = 0;
7239
- for (const s of sections) for (const r of s[1]) if (r[0].length > col) col = r[0].length;
7240
- col += 2;
7241
- const lines = [banner, '', '用法:', ' yotta-memory <命令> [选项]', ''];
7242
- for (const s of sections) {
7243
- lines.push(s[0] + ':');
7244
- for (const r of s[1]) lines.push(' ' + r[0].padEnd(col) + r[1]);
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
- console.log(lines.join('\n'));
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 = 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']);
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,