@yottameta/yotta-memory 0.16.1 → 0.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,21 @@
1
+ ## v0.16.2 (2026-09-21)
2
+
3
+ **首启修复:空加密库可解锁、非 TTY 可自举、缺 key 降级未授权**
4
+
5
+ - 修复空加密库 `view` 授权死锁:`/api/unlock` 在无 owner key 时用恢复钥匙校验主口令,空库可进入授权平台;无 owner 时页面提示先执行 `yotta-memory iam <id>`。
6
+ - 非 TTY 环境不再静默「已取消」:`init --encrypt` / `migrate` / `reset-password` / `key bind` 给出可操作错误;新增 `--password-stdin`(管道读口令,不进 argv)。
7
+ - `--agent-key-file` 指向不存在的文件时降级为未授权模式:公共 FACT 可读,私密操作 fail-closed 并提示 `key bind`;文件存在但为空 / 不可读仍报错。
8
+ - 空加密库 `iam` 先登记身份、不因自我档案写失败而退出;授权后带 `--agent-key-file` 重新执行 `iam --force` 写入加密档案。
9
+ - `migrate` 支持空明文库直接启用加密(创建 salt + recovery,不创建 owner key),迁移后提示 `key bind`。
10
+ - 新增 `--recovery-key-out <文件>`:恢复钥匙写文件(`init` / `migrate`),不在 stdout 打印完整钥匙,适配 GUI 宿主 stdout 不可捕获场景。
11
+ - `init --attach` / `init --no-encrypt` 明确输出当前模式(明文 / 加密)与下一步。
12
+ - `isExistingStore` 不再把 `agents.json` 单独视为现有库;`key list` 合并 `keys/*.key.enc`,显示仅有钥、尚未写记忆的 owner。
13
+ - `doctor` 对全新空库的缺失 index / agents 降为 info(不再误报);只有私密数据、没有公共 FACT 的库不再误报公共索引缺失;输出 agent home 发现规则与 `YOTTA_MEMORY_AGENT_HOME` 提示。
14
+ - `view` 启动前做端口健康检查:已在运行则复用并打印 URL;端口被占用给明确错误;`server.on('error')` 不再抛未处理异常。
15
+ - 明文库 `view` 提示改写 stdout,GUI 宿主可感知。
16
+ - 新增 `test/firstboot-0.16.2.test.js`(15 项首启回归);全量 `npm test` 148/148 PASS。
17
+ - 版本对齐:package.json / SKILL.md frontmatter / skill-manifest.json / CHANGELOG / 引擎 VERSION = 0.16.2。
18
+
1
19
  ## v0.16.1 (2026-09-19)
2
20
 
3
21
  维护性重发(无功能变更):
package/README.md CHANGED
@@ -23,6 +23,7 @@
23
23
 
24
24
  > 📖 The user-facing operations manual lives in [USER_GUIDE.md](USER_GUIDE.md).
25
25
 
26
+ > 🆕 **v0.16.2 (first-boot fixes)**: an empty encrypted store can unlock `view` with the recovery key; non-TTY hosts can use `--password-stdin`; recovery keys can be written with `--recovery-key-out <file>`; a missing `--agent-key-file` degrades to unauthenticated public-only mode; an empty plaintext store can be migrated to encryption; `view` reports port reuse/conflicts clearly.
26
27
  > 🆕 **v0.16.0 (identity model + stable runtime)**: identity is no longer read from environment variables. HTTP / remote MCP uses request headers `Authorization` + `X-Agent-Id` + `X-Agent-Key`; stdio MCP uses explicit `--agent-id` + `--agent-key-file`; CLI uses `--agent` + `--agent-key` / `--agent-key-file`. The new `runtime install --from-current` / `use` / `rollback` / `status` commands create a stable `<runtimeRoot>/current` launcher so managed MCP, autostart and backup tasks do not pin a version directory. `doctor --runtime` checks CLI / current / MCP config / running server / skill-copy drift, and MCP `serverInfo` returns `runtimePath` / `identityMode` / `toolProfile`.
27
28
  > 🆕 **v0.15.0 (MCP tool profiles)**: `serve --tools core|full` controls the exposed MCP surface. `core` keeps `context / recall / search / remember` resident; `full` keeps the existing 16 tools for diagnostics and maintenance. Omitting the flag keeps the previous `full` behavior for compatibility.
28
29
 
package/README.zh-CN.md CHANGED
@@ -23,6 +23,7 @@
23
23
 
24
24
  > 📖 面向用户的操作手册见 [USER_GUIDE.md](USER_GUIDE.md)。
25
25
 
26
+ > 🆕 **v0.16.2(首启修复)**:空加密库 `view` 可用恢复钥匙解锁;非 TTY 支持 `--password-stdin`;恢复钥匙支持 `--recovery-key-out <文件>`;`--agent-key-file` 不存在时降级未授权(公共 FACT 可读、私密 fail-closed);空明文库可直接 `migrate` 启用加密;`view` 端口占用给明确提示。
26
27
  > 🆕 **v0.16.0(身份模型 + 稳定运行时)**:身份不再从环境变量读取。HTTP / 远程 MCP 用请求头 `Authorization` + `X-Agent-Id` + `X-Agent-Key`;stdio MCP 用显式参数 `--agent-id` + `--agent-key-file`;CLI 用 `--agent` + `--agent-key` / `--agent-key-file`。新增 `runtime install --from-current` / `use` / `rollback` / `status`,创建 `<runtimeRoot>/current` 稳定入口,受管 MCP、自启与备份任务不再写死版本目录。`doctor --runtime` 检查 CLI / current / MCP 配置 / 运行中 server / 技能副本漂移;MCP `serverInfo` 返回 `runtimePath` / `identityMode` / `toolProfile`。
27
28
  > 🆕 **v0.15.0(MCP 工具分组)**:`serve --tools core|full` 控制 MCP 工具暴露面。`core` 常驻 `context / recall / search / remember`;`full` 保留现有 16 个诊断与维护工具。未指定参数时默认 `full`,保持现有配置兼容。
28
29
 
package/SKILL.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: yotta-memory
3
3
  description: 元忆 —— 有权限边界的文件式智能体记忆。文件式、零依赖、可 diff/可回滚:让任何 AI 智能体活过会话,开工 recall 恢复上下文、重要信息 remember 落盘、收工归档。类型体系 FACT(公共共享)/ PREF / BOUND / COMMIT(私密隔离)。触发:记住、别忘了、记一笔、记忆、remember、recall、跨会话、上次说到、续测、交接、归档、记忆盘、共享记忆、局域网记忆、画像、开工上下文、长期理解摘要、近期走廊、会话闭环、记忆守则、profile、context、越用越懂、语义检索、反馈、维护、蒸馏、feedback、maintain、distill、explain、自我学习、自我进化、自我提升、查看平台分页、recall 候选预过滤、任务相关记忆、--focus、--embedding、压缩遗忘、consolidate、周期摘要、自动合并、分类型衰减、回滚、备份、backup、防误删、doctor、事务快照
4
- version: 0.16.1
4
+ version: 0.16.2
5
5
  license: MIT
6
6
  ---
7
7
 
@@ -264,11 +264,11 @@ yotta-memory doctor --json
264
264
 
265
265
  | 命令 | 作用 |
266
266
  |---|---|
267
- | `yotta-memory init [--project] [--dir <目录>] [--attach] [--encrypt|--no-encrypt]` | 初始化(**新建默认加密**:设主口令 + 抄下恢复钥匙;已有库必须用 `--attach`,默认拒绝覆盖;`--no-encrypt` 降级明文;老明文库用 `migrate`)|
268
- | `yotta-memory migrate` | 明文私密区 → 密文迁移(**由用户执行**;需主口令;迁移后打印恢复钥匙;不写明文授权缓存,授权由用户在 `view` 平台完成)|
269
- | `yotta-memory view [--port 8788] [--host 127.0.0.1]` | 用户查看平台(本机 Web:口令解锁浏览 / 搜索 / 导出全部 AI 记忆 + 授权 / 吊销 AI + 重设口令 + 显示恢复钥匙)|
267
+ | `yotta-memory init [--project] [--dir <目录>] [--attach] [--encrypt|--no-encrypt] [--password-stdin] [--recovery-key-out <文件>]` | 初始化(**新建默认加密**:设主口令 + 抄下恢复钥匙;已有库必须用 `--attach`,默认拒绝覆盖;`--no-encrypt` 降级明文;老明文库用 `migrate`;非 TTY 用 `--password-stdin`;恢复钥匙可写文件)|
268
+ | `yotta-memory migrate [--password-stdin] [--recovery-key-out <文件>]` | 明文库 → 密文迁移(**由用户执行**;需主口令;空明文库同样可启用加密;迁移后打印/写文件恢复钥匙;不写明文授权缓存,授权由用户在 `view` 平台完成)|
269
+ | `yotta-memory view [--port 8788] [--host 127.0.0.1]` | 用户查看平台(本机 Web:口令解锁浏览 / 搜索 / 导出全部 AI 记忆 + 授权 / 吊销 AI + 重设口令 + 显示恢复钥匙;已在运行则复用 URL,端口占用给明确提示)|
270
270
  | `yotta-memory reset-password [--password <当前> | --recovery-key <钥匙>] [--new-password <新>]` | 重设主口令(忘口令用恢复钥匙)|
271
- | `yotta-memory key list / bind <id> / rotate <id> / claim <id> [--to <AI_HOME> | --agent-key-file <文件>] / status <id> [--to <AI_HOME> | --agent-key-file <文件>] / revoke <id>` | 管理 agent_key binding(**bind/rotate 由用户执行**,需主口令或恢复钥匙;claim/status 由 AI 读取 pending 并落到宿主目录,按同一 AI_HOME 发现规则;revoke 立即吊销该 AI 解密能力,旧 key 随即校验失败;`key list` 输出 `[YTM_MIGRATION_REQUIRED]` 时提醒用户走 `view` 重新授权)|
271
+ | `yotta-memory key list / bind <id> / rotate <id> / claim <id> [--to <AI_HOME> | --agent-key-file <文件>] / status <id> [--to <AI_HOME> | --agent-key-file <文件>] / revoke <id>` | 管理 agent_key binding(**bind/rotate 由用户执行**,需主口令或恢复钥匙;claim/status 由 AI 读取 pending 并落到宿主目录,按同一 AI_HOME 发现规则;revoke 立即吊销该 AI 解密能力,旧 key 随即校验失败;`key list` 合并 `keys/*.key.enc`,显示仅有钥、尚未写记忆的 owner;输出 `[YTM_MIGRATION_REQUIRED]` 时提醒用户走 `view` 重新授权)|
272
272
  | `yotta-memory remember <type> <subject> <statement> [--owner <id>] [--source <来源>] [--weight <0..>] [--verify] [--no-hint]` | 写入(同 subject+statement 自动更新;--owner 标注归属;--source 记录来源;--weight 重要性权重默认 1.0、去重取 max;--verify 写后回读校验;--no-hint 关闭类型启发式提示)|
273
273
  | `yotta-memory recall [关键词] [--type T] [--limit N] [--agent <id>] [--owner <id>] [--all] [--unsafe] [--explain] [--semantic] [--embedding <command>] [--embedding-timeout N]` | 检索(v0.8.0 默认语义检索:同义词 / 拼音全拼+首字母 / 字段加权 / 模糊匹配 + 效用分融合排序;v0.9.0 支持可选本地 embedding 插件,失败自动降级;`--explain` 显示命中理由与效用分项;`--semantic` 显式开启;读取分区过滤;越界读其它智能体私密默认拒绝,需 grant / identity=user / `--unsafe`;`--agent <其它>` 只作身份声明/展示,不授予跨读——读他人私密同样要授权;项目级优先)|
274
274
  | `yotta-memory profile [--owner <id>]` | 生成用户画像(聚合 `private/<owner>/` 原文,零推断,写 `profile.md`;跨 owner 默认拒绝)|
@@ -276,7 +276,7 @@ yotta-memory doctor --json
276
276
  | `yotta-memory forget <文件>` | 移入 `.trash/<时间>/` 回收区并写审计(v0.12.0;不再物理删除)|
277
277
  | `yotta-memory backup volumes / setup --dir <目录> / status / ensure-daily / schedule enable|disable|status` | 每日自动备份(v0.12.0;只展示实际枚举的异卷、用户确认一次位置后默认每日执行,Windows Task Scheduler / systemd timer / launchd 调度,`serve` 补跑)|
278
278
  | `yotta-memory backup create / list / doctor / restore <ID> --to <目录> / drill [<ID>]` | 备份、恢复与恢复演练(v0.12.0;独立盘校验、SHA-256 清单、排除 `keys/cache`、恢复默认只写新目录;drill 验证 manifest / 索引 / 测试私密解密)|
279
- | `yotta-memory doctor [--json] [--runtime] [--mcp-config <文件>] [--skill-dir <目录>]` | 开工可靠性检查(v0.12.2;根目录 / 密钥库 / 索引 / 身份 / 最近备份;严重异常时锁定破坏性写入);加 `--runtime` 检查 CLI / current / MCP 配置 / 运行中 server / 技能副本漂移 |
279
+ | `yotta-memory doctor [--json] [--runtime] [--mcp-config <文件>] [--skill-dir <目录>]` | 开工可靠性检查(v0.12.2;根目录 / 密钥库 / 索引 / 身份 / 最近备份;严重异常时锁定破坏性写入);全新空库的缺失 index / agents 降为 info;输出 agent home 发现规则与 `YOTTA_MEMORY_AGENT_HOME` 提示;加 `--runtime` 检查 CLI / current / MCP 配置 / 运行中 server / 技能副本漂移 |
280
280
  | `yotta-memory archive [--days 180] [--threshold 0.35]` | 归档旧记忆(v0.8.0 统一效用分 + v0.10.0 分类型衰减;immutable / BOUND 豁免;私密归档入 `.archive/private/<owner>/<type>/`;阈值默认读 config `maintain_archived_utility`)|
281
281
  | `yotta-memory reindex` | 重建索引(手动改 .md 后校正)|
282
282
  | `yotta-memory export [--out f.json]` / `import <f.json>` | 导出 / 导入 |
@@ -293,6 +293,15 @@ yotta-memory doctor --json
293
293
  | `yotta-memory distill [--owner <id>] [--subject <主题>] [--model <cmd>] [--out <路径>]` | 心理日志蒸馏(v0.8.0 自我提升:统计摘要 / 主题画像 / 知识地图;启发式零依赖,`--model` 可选外部模型 stdin→stdout 提炼;私密产物入 `private/<owner>/distills/`,公共入 `facts/distills/`)|
294
294
  | `yotta-memory explain <文件|主题>` | 查看单条记忆效用分项与归档 / 遗忘状态判定(v0.8.0)|
295
295
 
296
+ ### 首启(非 TTY / GUI 宿主)
297
+
298
+ - **非 TTY 初始化 / 迁移**:用 `--password-stdin` 从管道读主口令(不进 argv),或设 `YOTTA_MEMORY_PASS`;不要依赖交互提示,非 TTY 下会给出可操作错误而不是静默「已取消」。
299
+ - **恢复钥匙写文件**:`--recovery-key-out <文件>`(`init` / `migrate`)把恢复钥匙写入文件,适配 Windows GUI 宿主 stdout 不可捕获;不要把钥匙粘贴到对话。
300
+ - **缺 `--agent-key-file`**:文件不存在时降级为未授权模式(公共 FACT 可读,私密操作 fail-closed 并提示 `key bind <id>`);文件存在但为空 / 不可读仍报错。
301
+ - **空明文库启用加密**:`yotta-memory migrate --password-stdin` 可直接为空的明文库创建加密层(不创建 owner key),随后为每个 agent 执行 `yotta-memory key bind <id>`。
302
+ - **空加密库授权**:`yotta-memory view` 在无 owner key 时可用恢复钥匙校验主口令进入平台;无 owner 时先 `yotta-memory iam <id>`,再回页面授权。
303
+ - **view 端口**:启动前做健康检查;已在运行则打印 URL 复用,端口被占用给明确提示,不再抛未处理的 `EADDRINUSE`。
304
+
296
305
  ## 存储格式(摘要)
297
306
 
298
307
  目录结构(v0.5.0 起,私密记忆按 owner 物理分目录):
package/USER_GUIDE.md CHANGED
@@ -127,11 +127,13 @@ statement: 本周完成发布
127
127
  **启用与迁移**
128
128
 
129
129
  - **新库**:`yotta-memory init`(新建默认加密)→ 输入主口令两次 → **抄下打印的恢复钥匙**(44 位 base64,离线保存;忘口令时用它重设)。不要加密用 `--no-encrypt`。
130
- - **老明文库升级**:`yotta-memory migrate` → 输入主口令 → 私密文件逐个加密、明文删除 → 抄下恢复钥匙。
130
+ - **老明文库升级**:`yotta-memory migrate` → 输入主口令 → 私密文件逐个加密、明文删除 → 抄下恢复钥匙。**空明文库也可以直接 migrate**(只创建加密层,不创建 owner key),随后为每个 agent 执行 `key bind <id>`。
131
+ - **非 TTY / GUI 宿主**:不要依赖交互口令——用 `--password-stdin` 从管道读主口令(推荐,不进 argv),或设 `YOTTA_MEMORY_PASS`;用 `--recovery-key-out <文件>` 把恢复钥匙写文件,避免 stdout 被 GUI 宿主吞掉。当前为非交互环境且未提供口令时,命令会给出可操作错误,不再静默「已取消」。
132
+ - **缺 `--agent-key-file`**:文件不存在时降级为未授权模式——公共 FACT 可读,私密操作 fail-closed 并提示 `key bind <id>`;文件存在但为空 / 不可读仍报错。
131
133
 
132
134
  **用户查看平台(看所有 AI 的记忆)**
133
135
 
134
- `yotta-memory view` → 浏览器打开 http://127.0.0.1:8788 → 输入主口令解锁 → 浏览 / 搜索 / 导出全部记忆(含各 AI 私密);也可在此**授权 / 吊销**某 AI 读取其私密、**重设口令**、**查看恢复钥匙**。点击「授权」后会弹窗展示只显示一次的 `agent_key`,请立即单独保存;引擎同时写临时 `keys/pending/<id>.key`,该 AI 新会话用 `key claim` 领取到自己的宿主目录,领取成功后 pending 删除。已绑定的 agent 需先「吊销」再授权,旧 key 随即校验失败。口令只在本地内存派生,不落盘、不发远端;默认仅本机,远程需 `--host` 显式开启。
136
+ `yotta-memory view` → 浏览器打开 http://127.0.0.1:8788 → 输入主口令解锁 → 浏览 / 搜索 / 导出全部记忆(含各 AI 私密);也可在此**授权 / 吊销**某 AI 读取其私密、**重设口令**、**查看恢复钥匙**。空加密库没有 owner key 时,`view` 会用恢复钥匙校验主口令进入平台;此时先在终端执行 `yotta-memory iam <id>`,页面才会出现可授权的 owner。`view` 启动前会做端口健康检查:已在运行则直接打印 URL 复用;端口被占用会给出明确提示。点击「授权」后会弹窗展示只显示一次的 `agent_key`,请立即单独保存;引擎同时写临时 `keys/pending/<id>.key`,该 AI 新会话用 `key claim` 领取到自己的宿主目录,领取成功后 pending 删除。已绑定的 agent 需先「吊销」再授权,旧 key 随即校验失败。口令只在本地内存派生,不落盘、不发远端;默认仅本机,远程需 `--host` 显式开启。
135
137
 
136
138
  **AI 接入加密库**
137
139
 
package/assets/view.html CHANGED
@@ -53,7 +53,7 @@ async function api(p,b){try{const r=await fetch(p,{method:'POST',headers:{'Conte
53
53
  async function boot(){const s=await api('/api/status');document.getElementById('ver').textContent='v'+(s.version||'');if(s.unlocked){showApp();}}
54
54
  function showApp(){document.getElementById('lock').style.display='none';document.getElementById('app').style.display='block';loadOwners();load();}
55
55
  async function unlock(){const d=await api('/api/unlock',{password:document.getElementById('pw').value});if(d.error){document.getElementById('lockerr').textContent=d.error;return;}showApp();}
56
- async function loadOwners(){const d=await api('/api/owners');const box=document.getElementById('owners');box.innerHTML='';if(!d.owners||!d.owners.length){box.innerHTML='(无 owner)';return;}
56
+ async 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;}
57
57
  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);}
58
58
  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();});};});
59
59
  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();});};});
@@ -21,12 +21,13 @@ const path = require('path');
21
21
  const os = require('os');
22
22
  const crypto = require('crypto');
23
23
  const http = require('http');
24
+ const net = require('net');
24
25
  const child_process = require('child_process');
25
26
  const { AsyncLocalStorage } = require('async_hooks');
26
27
 
27
- const VERSION = '0.16.1';
28
+ const VERSION = '0.16.2';
28
29
  // @generated view-html:start
29
- 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='(无 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";
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";
30
31
  // @generated view-html:end
31
32
  // MCP 协议(2026-07-28 无状态 + 2025-11-25 legacy 握手,dual-era)
32
33
  const MCP_PROTOCOL_MODERN = '2026-07-28';
@@ -618,6 +619,15 @@ function resolveIdentity(opts) {
618
619
  if (keyFile) {
619
620
  let fileKey = '';
620
621
  try { fileKey = fs.readFileSync(path.resolve(keyFile), 'utf8').trim(); } catch (e) {
622
+ if (e && e.code === 'ENOENT') {
623
+ return {
624
+ id: explicit,
625
+ agentKey: '',
626
+ source: 'missing-key-file',
627
+ warning: '未找到 agent-key 文件: ' + keyFile + ';已降级为未授权模式(公共 FACT 可读,私密操作需要显式授权)。',
628
+ error: '',
629
+ };
630
+ }
621
631
  return { id: '', agentKey: '', source: 'invalid-key-file', error: '无法读取 agent-key 文件: ' + keyFile };
622
632
  }
623
633
  if (!fileKey) return { id: '', agentKey: '', source: 'invalid-key-file', error: 'agent-key 文件为空: ' + keyFile };
@@ -1872,7 +1882,7 @@ function verifyWrittenReadable(root, rel, subj, agent) {
1872
1882
 
1873
1883
  // ---- 命令 core(CLI 与 MCP 共用;返回 { error, exitCode, text },不 process.exit)----
1874
1884
  function isExistingStore(root) {
1875
- return ['facts', 'private', 'keys', 'agents.json', 'index.json'].some(function (name) {
1885
+ return ['facts', 'private', 'keys', 'index.json'].some(function (name) {
1876
1886
  return fs.existsSync(path.join(root, name));
1877
1887
  });
1878
1888
  }
@@ -1881,7 +1891,10 @@ function initCore(opts) {
1881
1891
  const root = opts.dir ? path.resolve(String(opts.dir)) : (opts.project ? projectRoot() : userRoot());
1882
1892
  const existing = isExistingStore(root);
1883
1893
  if (existing && opts.attach) {
1884
- return { error: false, text: '已接入现有记忆库: ' + root };
1894
+ const mode = isEncrypted(root) ? '加密' : '明文';
1895
+ let text = '已接入现有记忆库(当前模式:' + mode + '): ' + root;
1896
+ if (!isEncrypted(root)) text += '\n提示:当前为明文模式,私密内容未加密;如需启用加密,请运行 yotta-memory migrate(空明文库同样可用)。';
1897
+ return { error: false, text: text };
1885
1898
  }
1886
1899
  if (existing && opts.force) {
1887
1900
  return { error: true, text: '拒绝: --force 不能覆盖现有记忆库;已有记忆库的强制重建必须由完整备份与显式确认保护,当前版本不提供覆盖初始化路径。如只是接入,请使用 init --attach。' };
@@ -1896,11 +1909,17 @@ function initCore(opts) {
1896
1909
  if (isEncrypted(root)) return { error: false, text: '已初始化记忆库(已启用加密): ' + root };
1897
1910
  if (hasPlaintextPrivate(root)) return { error: true, text: '检测到明文私密区,请先 yotta-memory migrate 迁移到密文;新建空库可直接 init --encrypt。' };
1898
1911
  const password = String(opts.password || process.env.YOTTA_MEMORY_PASS || '');
1899
- if (!password) return { error: true, text: '启用加密需要主口令:交互输入,或 YOTTA_MEMORY_PASS 环境变量(自动化)。' };
1912
+ if (!password) return { error: true, text: '启用加密需要主口令:交互输入、--password-stdin(推荐)或 YOTTA_MEMORY_PASS 环境变量(自动化)。' };
1900
1913
  const r = initEncryptionCore(root, password, null);
1914
+ if (opts.recoveryKeyOut) {
1915
+ const outPath = path.resolve(String(opts.recoveryKeyOut));
1916
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
1917
+ fs.writeFileSync(outPath, r.rk.toString('base64') + '\n', { encoding: 'utf8', mode: 0o600 });
1918
+ return { error: false, text: '已初始化加密记忆库: ' + root + '\n[恢复钥匙] 已写入文件: ' + outPath + '(务必离线保存,仅此一次,泄露=可解全部私密;不要把钥匙粘贴到对话中)' };
1919
+ }
1901
1920
  return { error: false, text: '已初始化加密记忆库: ' + root + '\n[恢复钥匙](务必离线保存,仅此一次,泄露=可解全部私密): ' + r.rk.toString('base64') };
1902
1921
  }
1903
- return { error: false, text: '已初始化记忆库: ' + root + (isEncrypted(root) ? '(已启用加密)' : '(明文;建议 init --encrypt 或 migrate 启用加密)') };
1922
+ return { error: false, text: '已初始化记忆库: ' + root + (isEncrypted(root) ? '(已启用加密)' : '(明文;私密内容未加密,如需启用加密请运行 migrate)') };
1904
1923
  }
1905
1924
 
1906
1925
  function backupDestination(opts) {
@@ -2900,6 +2919,34 @@ function runtimeDoctorCore(opts) {
2900
2919
  text: lines.join('\n'),
2901
2920
  };
2902
2921
  }
2922
+ function storeHasMemoryData(root) {
2923
+ const factsDir = path.join(root, PUBLIC_DIR);
2924
+ if (fs.existsSync(factsDir)) {
2925
+ try {
2926
+ if (fs.readdirSync(factsDir).some(function (f) { return /\.md(\.enc)?$/.test(f); })) return true;
2927
+ } catch (e) {}
2928
+ }
2929
+ if (hasPlaintextPrivate(root)) return true;
2930
+ for (const owner of collectOwners(root)) {
2931
+ for (const t of PRIVATE_LEAF) {
2932
+ const dir = path.join(root, PRIVATE_DIR, owner, t);
2933
+ if (!fs.existsSync(dir)) continue;
2934
+ try {
2935
+ if (fs.readdirSync(dir).some(function (f) { return f.endsWith(ENC_SUFFIX); })) return true;
2936
+ } catch (e) {}
2937
+ }
2938
+ }
2939
+ return false;
2940
+ }
2941
+ function storeHasPublicFacts(root) {
2942
+ const factsDir = path.join(root, PUBLIC_DIR);
2943
+ if (!fs.existsSync(factsDir)) return false;
2944
+ try {
2945
+ return fs.readdirSync(factsDir).some(function (f) { return /\.md(\.enc)?$/.test(f); });
2946
+ } catch (e) {
2947
+ return false;
2948
+ }
2949
+ }
2903
2950
  // 开工可靠性检查:只读检查根目录、密钥库、索引、身份登记与最近备份。
2904
2951
  function doctorCore(opts) {
2905
2952
  opts = opts || {};
@@ -2909,6 +2956,8 @@ function doctorCore(opts) {
2909
2956
  const warnings = [];
2910
2957
  const critical = [];
2911
2958
  const exists = isExistingStore(root);
2959
+ const hasData = storeHasMemoryData(root);
2960
+ const hasPublicFacts = storeHasPublicFacts(root);
2912
2961
  checks.root = { ok: exists, path: root };
2913
2962
  if (!exists) critical.push('记忆库不存在或基本结构缺失: ' + root);
2914
2963
 
@@ -2930,7 +2979,8 @@ function doctorCore(opts) {
2930
2979
  const idxFile = indexPath(root);
2931
2980
  if (!fs.existsSync(idxFile)) {
2932
2981
  checks.index = { exists: false, valid: false };
2933
- warnings.push('公共索引缺失;可运行 yotta-memory reindex 重建。');
2982
+ if (hasPublicFacts) warnings.push('公共索引缺失;可运行 yotta-memory reindex 重建。');
2983
+ else checks.index.fresh = true;
2934
2984
  } else if (!loadIndex(root)) {
2935
2985
  checks.index = { exists: true, valid: false };
2936
2986
  warnings.push('公共索引无法解析或版本过旧;可运行 yotta-memory reindex 重建。');
@@ -2940,7 +2990,8 @@ function doctorCore(opts) {
2940
2990
 
2941
2991
  if (!fs.existsSync(agentsPath(root))) {
2942
2992
  checks.agents = { exists: false, valid: false };
2943
- warnings.push('agents.json 缺失;智能体身份登记不可用。');
2993
+ if (hasData) warnings.push('agents.json 缺失;智能体身份登记不可用。');
2994
+ else checks.agents.fresh = true;
2944
2995
  } else {
2945
2996
  try {
2946
2997
  JSON.parse(fs.readFileSync(agentsPath(root), 'utf8'));
@@ -3011,12 +3062,14 @@ function doctorCore(opts) {
3011
3062
  }
3012
3063
 
3013
3064
  const level = critical.length ? 'critical' : (warnings.length ? 'warning' : 'ok');
3065
+ const agentHomeEnv = String(process.env.YOTTA_MEMORY_AGENT_HOME || '').trim();
3014
3066
  const lines = [
3015
3067
  '# yotta-memory doctor(开工可靠性检查)',
3016
3068
  '',
3017
3069
  '- 结果: ' + (level === 'critical' ? '严重' : (level === 'warning' ? '警告' : '正常')),
3018
3070
  '- 记忆库: ' + root,
3019
3071
  '- 加密: ' + (encrypted ? '是' : '否'),
3072
+ '- agent home: ' + (agentHomeEnv || '(未设置 YOTTA_MEMORY_AGENT_HOME,按宿主默认检测)'),
3020
3073
  '- 备份目录: ' + (configuredDir || (cfg.backup_setup_choice === 'manual' ? '手动模式' : '(未配置)')),
3021
3074
  ];
3022
3075
  for (const message of critical) lines.push('- [严重] ' + message);
@@ -4209,12 +4262,42 @@ function promptPassword(promptText) {
4209
4262
  });
4210
4263
  }
4211
4264
 
4265
+ function readPasswordFromStdin() {
4266
+ try {
4267
+ const text = fs.readFileSync(0, 'utf8');
4268
+ return String(text || '').replace(/\r?\n$/, '');
4269
+ } catch (e) {
4270
+ return '';
4271
+ }
4272
+ }
4273
+
4274
+ async function resolvePasswordInput(opts, promptText, options) {
4275
+ opts = opts || {};
4276
+ options = options || {};
4277
+ const direct = options.field === 'new' ? String(opts.newPassword || '') : String(opts.password || '');
4278
+ if (direct) return { password: direct, source: 'option', error: '' };
4279
+ if (opts.passwordStdin) {
4280
+ const password = readPasswordFromStdin();
4281
+ if (!password) {
4282
+ return { password: '', source: 'stdin', error: '--password-stdin 没有读到内容;请通过管道传入主口令。' };
4283
+ }
4284
+ return { password: password, source: 'stdin', error: '' };
4285
+ }
4286
+ const envPass = String(process.env.YOTTA_MEMORY_PASS || '');
4287
+ if (envPass) return { password: envPass, source: 'env', error: '' };
4288
+ if (!process.stdin.isTTY) {
4289
+ return { password: '', source: 'non-tty', error: '当前为非交互环境,无法读取主口令。请使用 --password-stdin(推荐,管道传入)或设置 YOTTA_MEMORY_PASS;不要把口令直接写在命令行参数里。' };
4290
+ }
4291
+ const password = await promptPassword(promptText);
4292
+ if (!password) return { password: '', source: 'prompt', error: '已取消。' };
4293
+ return { password: password, source: 'prompt', error: '' };
4294
+ }
4295
+
4212
4296
  function migrateCore(root, password, recoveryKeyIn) {
4213
4297
  // 第三个参数历史上是 recovery key;兼容旧调用,同时支持 { agent, recoveryKey } 选项对象。
4214
4298
  const opts = recoveryKeyIn && typeof recoveryKeyIn === 'object' ? recoveryKeyIn : {};
4215
4299
  if (!fs.existsSync(root)) return { error: true, text: '记忆库不存在,请先 yotta-memory init。' };
4216
4300
  if (isEncrypted(root)) return { error: true, text: '记忆库已启用加密(keys/ 存在),无需迁移。' };
4217
- if (!hasPlaintextPrivate(root)) return { error: false, text: '无私密明文可迁移(private/ 下无 .md 文件);如需新建加密库请 init --encrypt。' };
4218
4301
  const salt = crypto.randomBytes(16);
4219
4302
  ensureKeysDir(root);
4220
4303
  fs.writeFileSync(encSaltPath(root), salt);
@@ -4247,8 +4330,17 @@ function migrateCore(root, password, recoveryKeyIn) {
4247
4330
  }
4248
4331
  }
4249
4332
  buildIndex(root);
4250
- let tail = '已迁移 ' + moved + ' 个私密文件(' + owners.length + ' 个 owner)到密文。';
4251
- tail += '\n[恢复钥匙](务必离线保存,仅此一次,泄露=可解全部私密): ' + rk.toString('base64');
4333
+ let tail = moved > 0
4334
+ ? ('已迁移 ' + moved + ' 个私密文件(' + owners.length + ' 个 owner)到密文。')
4335
+ : '私密区为空;已为明文库启用加密(未迁移文件、未创建 owner key)。';
4336
+ if (opts.recoveryKeyOut) {
4337
+ const outPath = path.resolve(String(opts.recoveryKeyOut));
4338
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
4339
+ fs.writeFileSync(outPath, rk.toString('base64') + '\n', { encoding: 'utf8', mode: 0o600 });
4340
+ tail += '\n[恢复钥匙] 已写入文件: ' + outPath + '(务必离线保存,仅此一次,泄露=可解全部私密;不要把钥匙粘贴到对话中)';
4341
+ } else {
4342
+ tail += '\n[恢复钥匙](务必离线保存,仅此一次,泄露=可解全部私密): ' + rk.toString('base64');
4343
+ }
4252
4344
  tail += '\n迁移不会写明文 owner key cache;请为每个 agent 执行一次 yotta-memory key bind <id>,再注入 agent_key。';
4253
4345
  if (self && owners.indexOf(self) !== -1) tail += '\n你的身份:' + self + ',请先执行 yotta-memory key bind ' + self + '。';
4254
4346
  return { error: false, text: tail };
@@ -4257,13 +4349,13 @@ function migrateCore(root, password, recoveryKeyIn) {
4257
4349
  async function cmdMigrate(opts) {
4258
4350
  const root = userRoot();
4259
4351
  if (isEncrypted(root)) { console.error('记忆库已启用加密,无需迁移。'); process.exit(2); }
4260
- if (!hasPlaintextPrivate(root)) { console.error('无私密明文可迁移。'); process.exit(2); }
4261
4352
  let password = opts.password || process.env.YOTTA_MEMORY_PASS || '';
4262
4353
  if (!password) {
4263
- password = await promptPassword('主口令(迁移后私密区加密,勿忘;忘口令可用恢复钥匙重设): ') || '';
4264
- if (!password) { console.error('已取消。'); process.exit(2); }
4354
+ const got = await resolvePasswordInput(opts, '主口令(迁移后私密区加密,勿忘;忘口令可用恢复钥匙重设): ');
4355
+ if (got.error) { console.error(got.error); process.exit(2); }
4356
+ password = got.password;
4265
4357
  }
4266
- const r = migrateCore(root, password, { agent: opts.agent, recoveryKey: null });
4358
+ const r = migrateCore(root, password, { agent: opts.agent, recoveryKey: null, recoveryKeyOut: opts.recoveryKeyOut });
4267
4359
  console.log(r.text);
4268
4360
  if (r.error) process.exit(2);
4269
4361
  }
@@ -4315,14 +4407,18 @@ async function cmdResetPassword(opts) {
4315
4407
  let rk = opts.recoveryKey || '';
4316
4408
  let np = opts.newPassword || process.env.YOTTA_MEMORY_PASS || '';
4317
4409
  if (!rk && !cur) {
4318
- cur = await promptPassword('当前口令(或 --recovery-key 用恢复钥匙): ') || '';
4319
- if (!cur) { console.error('已取消。'); process.exit(2); }
4410
+ const got = await resolvePasswordInput(opts, '当前口令(或 --recovery-key 用恢复钥匙): ');
4411
+ if (got.error) { console.error(got.error); process.exit(2); }
4412
+ cur = got.password;
4320
4413
  }
4321
4414
  if (!np) {
4322
- np = await promptPassword('新口令: ') || '';
4323
- if (!np) { console.error('已取消。'); process.exit(2); }
4324
- const np2 = await promptPassword('再次输入确认: ') || '';
4325
- if (np2 !== np) { console.error('两次输入不一致。'); process.exit(2); }
4415
+ const got = await resolvePasswordInput(opts, '新口令: ', { field: 'new' });
4416
+ if (got.error) { console.error(got.error); process.exit(2); }
4417
+ np = got.password;
4418
+ if (got.source === 'prompt') {
4419
+ const np2 = await promptPassword('再次输入确认: ') || '';
4420
+ if (np2 !== np) { console.error('两次输入不一致。'); process.exit(2); }
4421
+ }
4326
4422
  }
4327
4423
  const r = resetPasswordCore(root, { password: cur, recoveryKey: rk, newPassword: np });
4328
4424
  console.log(r.text);
@@ -4458,7 +4554,7 @@ function keyStatusCore(root, owner, opts) {
4458
4554
  function keyListCore(root) {
4459
4555
  root = root || userRoot();
4460
4556
  if (!isEncrypted(root)) return { text: '记忆库未启用加密。' };
4461
- const owners = collectOwners(root);
4557
+ const owners = keyOwners(root);
4462
4558
  const bound = owners.filter(function (o) { return fs.existsSync(encAgentBindingPath(root, o)); });
4463
4559
  const pending = owners.filter(function (o) { return fs.existsSync(encPendingPath(root, o)); });
4464
4560
  const legacy = owners.filter(function (o) { return fs.existsSync(encCachePath(root, o)); });
@@ -4486,8 +4582,9 @@ async function cmdKeyBind(id, opts) {
4486
4582
  if (!id) { console.error('请指定 <id>:yotta-memory key bind <id>'); process.exit(2); }
4487
4583
  let password = opts.password || process.env.YOTTA_MEMORY_PASS || '';
4488
4584
  if (!password && !opts.recoveryKey) {
4489
- password = await promptPassword('主口令: ') || '';
4490
- if (!password) { console.error('已取消。'); process.exit(2); }
4585
+ const got = await resolvePasswordInput(opts, '主口令: ');
4586
+ if (got.error) { console.error(got.error); process.exit(2); }
4587
+ password = got.password;
4491
4588
  }
4492
4589
  const r = keyBindCore(root, id, { password: password, recoveryKey: opts.recoveryKey });
4493
4590
  console.log(r.text);
@@ -4571,7 +4668,8 @@ function viewOriginAllowed(req) {
4571
4668
  const requestHost = String(req.headers.host || '').trim().toLowerCase();
4572
4669
  return !!requestHost && originHost === requestHost;
4573
4670
  }
4574
- function viewServerCore(root, port, host) {
4671
+ function viewServerCore(root, port, host, opts) {
4672
+ opts = opts || {};
4575
4673
  let session = { umk: null, ownerKeys: {} };
4576
4674
  const server = http.createServer(function (req, res) {
4577
4675
  if (!viewHostAllowed(req, host) || !viewOriginAllowed(req)) {
@@ -4625,9 +4723,16 @@ function viewServerCore(root, port, host) {
4625
4723
  for (const o of owners) {
4626
4724
  try { ownerKeys[o] = unwrapOwnerKey(root, o, umk); verified = true; } catch (e) {}
4627
4725
  }
4628
- if (!verified) return json(401, { error: '口令错误。' });
4726
+ if (!verified && owners.length === 0) {
4727
+ try { unwrapRecoveryEnc(root, umk); verified = true; } catch (e) {}
4728
+ }
4729
+ if (!verified) return json(401, { error: owners.length === 0 ? '口令错误;空加密库已用恢复钥匙校验失败。' : '口令错误。' });
4629
4730
  session = { umk: umk, ownerKeys: ownerKeys };
4630
- json(200, { ok: true, owners: Object.keys(ownerKeys) });
4731
+ json(200, {
4732
+ ok: true,
4733
+ owners: Object.keys(ownerKeys),
4734
+ hint: owners.length === 0 ? '当前没有 owner。请先在终端执行 yotta-memory iam <id>,再回到本页授权。' : '',
4735
+ });
4631
4736
  });
4632
4737
  }
4633
4738
  if (req.method === 'POST' && pathname === '/api/owners') {
@@ -4635,7 +4740,10 @@ function viewServerCore(root, port, host) {
4635
4740
  const agents = loadAgents(root).agents || {};
4636
4741
  const owners = keyOwners(root);
4637
4742
  const all = Array.from(new Set(owners.concat(Object.keys(agents))));
4638
- return json(200, { owners: all.map(function (o) { return { owner: o, registered: !!agents[o], authorized: fs.existsSync(encAgentBindingPath(root, o)) }; }) });
4743
+ return json(200, {
4744
+ owners: all.map(function (o) { return { owner: o, registered: !!agents[o], authorized: fs.existsSync(encAgentBindingPath(root, o)) }; }),
4745
+ hint: all.length ? '' : '当前没有 owner。请先在终端执行 yotta-memory iam <id>,再回到本页授权。',
4746
+ });
4639
4747
  }
4640
4748
  if (req.method === 'POST' && pathname === '/api/entries') {
4641
4749
  if (!session.umk) return json(401, { error: '请先解锁。' });
@@ -4712,6 +4820,14 @@ function viewServerCore(root, port, host) {
4712
4820
  res.writeHead(404, { 'Content-Type': 'text/plain' });
4713
4821
  res.end('not found');
4714
4822
  });
4823
+ server.on('error', function (e) {
4824
+ const message = 'yotta-memory view 启动失败: ' + (e && e.message ? e.message : String(e)) + ';请检查端口是否被占用,或使用 --port <其它端口>。';
4825
+ if (typeof opts.onError === 'function') {
4826
+ opts.onError(e, message);
4827
+ return;
4828
+ }
4829
+ console.error(message);
4830
+ });
4715
4831
  server.listen(port, host, function () {
4716
4832
  console.log('yotta-memory 用户查看平台已启动(v' + VERSION + ')');
4717
4833
  console.log('URL: http://' + host + ':' + port + '(默认仅本机 127.0.0.1;远程需 --host 显式开启)');
@@ -4722,16 +4838,75 @@ function viewServerCore(root, port, host) {
4722
4838
  return server;
4723
4839
  }
4724
4840
 
4725
- function cmdView(opts) {
4841
+ function probeViewPort(host, port) {
4842
+ return new Promise(function (resolve) {
4843
+ const socket = net.connect({ host: host, port: port });
4844
+ let done = false;
4845
+ function finish(state) {
4846
+ if (done) return;
4847
+ done = true;
4848
+ try { socket.destroy(); } catch (e) {}
4849
+ resolve(state);
4850
+ }
4851
+ socket.setTimeout(800, function () { finish('timeout'); });
4852
+ socket.once('connect', function () { finish('open'); });
4853
+ socket.once('error', function (e) { finish(e && e.code === 'ECONNREFUSED' ? 'closed' : 'error'); });
4854
+ });
4855
+ }
4856
+
4857
+ function probeViewStatus(host, port) {
4858
+ return new Promise(function (resolve) {
4859
+ const body = JSON.stringify({});
4860
+ const req = http.request({
4861
+ host: host,
4862
+ port: port,
4863
+ path: '/api/status',
4864
+ method: 'POST',
4865
+ timeout: 800,
4866
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
4867
+ }, function (res) {
4868
+ let data = '';
4869
+ res.on('data', function (chunk) { data += chunk; });
4870
+ res.on('end', function () {
4871
+ try {
4872
+ const parsed = JSON.parse(data);
4873
+ resolve(!!parsed.version && typeof parsed.unlocked === 'boolean');
4874
+ } catch (e) {
4875
+ resolve(false);
4876
+ }
4877
+ });
4878
+ });
4879
+ req.on('timeout', function () { req.destroy(); resolve(false); });
4880
+ req.on('error', function () { resolve(false); });
4881
+ req.end(body);
4882
+ });
4883
+ }
4884
+
4885
+ async function cmdView(opts) {
4726
4886
  const root = userRoot();
4727
4887
  ensureInit(root);
4728
4888
  if (!isEncrypted(root)) {
4729
- console.error('记忆库未启用加密(无 keys/)。yotta-memory view 是加密库的用户查看平台;请先 init --encrypt 或 migrate。');
4889
+ console.log('记忆库未启用加密(无 keys/)。yotta-memory view 是加密库的用户查看平台;请先 init --encrypt 或 migrate。');
4730
4890
  process.exit(2);
4731
4891
  }
4732
4892
  const host = opts.host || '127.0.0.1';
4733
4893
  const port = opts.port || 8788;
4734
- viewServerCore(root, port, host);
4894
+ const state = await probeViewPort(host, port);
4895
+ if (state === 'open') {
4896
+ const own = await probeViewStatus(host, port);
4897
+ if (own) {
4898
+ console.log('检测到已在运行的 yotta-memory view: http://' + host + ':' + port + '(无需重复启动)');
4899
+ return;
4900
+ }
4901
+ console.error('端口已被占用: ' + host + ':' + port + '。请关闭占用进程,或使用 --port <其它端口>。');
4902
+ process.exit(2);
4903
+ }
4904
+ viewServerCore(root, port, host, {
4905
+ onError: function (e, message) {
4906
+ console.error(message);
4907
+ process.exit(2);
4908
+ },
4909
+ });
4735
4910
  }
4736
4911
 
4737
4912
  function viewHtml() {
@@ -4750,12 +4925,14 @@ async function cmdInit(opts) {
4750
4925
  const fresh = !isExistingStore(root);
4751
4926
  const wantEncrypt = opts.encrypt || (!opts.noEncrypt && fresh);
4752
4927
  let o = Object.assign({}, opts);
4753
- if (wantEncrypt && !o.password && !process.env.YOTTA_MEMORY_PASS) {
4754
- const pw = await promptPassword('主口令(启用加密,勿忘;忘口令可用恢复钥匙重设): ');
4755
- if (!pw) { console.error('已取消。'); process.exit(2); }
4756
- const pw2 = await promptPassword('再次输入确认: ');
4757
- if (pw2 !== pw) { console.error('两次输入不一致。'); process.exit(2); }
4758
- o.password = pw;
4928
+ if (wantEncrypt && !o.password) {
4929
+ const got = await resolvePasswordInput(o, '主口令(启用加密,勿忘;忘口令可用恢复钥匙重设): ');
4930
+ if (got.error) { console.error(got.error); process.exit(2); }
4931
+ if (got.source === 'prompt') {
4932
+ const pw2 = await promptPassword('再次输入确认: ');
4933
+ if (pw2 !== got.password) { console.error('两次输入不一致。'); process.exit(2); }
4934
+ }
4935
+ o.password = got.password;
4759
4936
  }
4760
4937
  const r = initCore(o);
4761
4938
  console.log(r.text);
@@ -5152,12 +5329,28 @@ function cmdIam(agentId, opts) {
5152
5329
  const existed = data.agents[agentId];
5153
5330
  data.agents[agentId] = { host: host, created: (existed && existed.created) || today() };
5154
5331
  saveAgents(root, data);
5155
- const file = writeSelfProfile(root, agentId, { mcpMode: 'stdio', name: opts.name, userName: opts.user, relationship: opts.relationship });
5332
+ const profileIdentity = resolveIdentity({ agent: agentId, agentKey: opts.agentKey, agentKeyFile: opts.agentKeyFile });
5333
+ if (profileIdentity && !profileIdentity.error && profileIdentity.id) {
5334
+ setRuntimeAgent(profileIdentity.id, profileIdentity.agentKey);
5335
+ }
5336
+ let profile = '';
5337
+ let profileWarning = '';
5338
+ try {
5339
+ profile = writeSelfProfile(root, agentId, { mcpMode: 'stdio', name: opts.name, userName: opts.user, relationship: opts.relationship });
5340
+ } catch (e) {
5341
+ const message = e && e.message ? e.message : String(e);
5342
+ if (isEncrypted(root) && /授权密钥|加密/.test(message)) {
5343
+ profileWarning = '自我档案暂未写入:当前加密库还没有 ' + agentId + ' 的授权密钥。请先完成授权(yotta-memory view 或 yotta-memory key bind ' + agentId + '),再在已授权的会话中执行 yotta-memory iam ' + agentId + ' --force --agent-key-file <宿主key文件> 写入档案。';
5344
+ } else {
5345
+ throw e;
5346
+ }
5347
+ }
5156
5348
  console.log('已登记智能体身份: ' + agentId + '(host=' + host + ',' + (conflict ? '--force 覆盖' : '新建') + ')');
5157
5349
  if (opts.name || opts.user || opts.relationship) {
5158
5350
  console.log('自我档案扩展: ' + [opts.name && '显示名=' + opts.name, opts.user && '用户=' + opts.user, opts.relationship && '关系=' + opts.relationship].filter(Boolean).join(' / '));
5159
5351
  }
5160
- console.log('已写入自我档案: ' + file);
5352
+ if (profile) console.log('已写入自我档案: ' + profile);
5353
+ if (profileWarning) console.log(profileWarning);
5161
5354
  console.log('本机免 token:以后用 whoami 确认身份;远端接入需 token new --agent ' + agentId);
5162
5355
  }
5163
5356
  function cmdWhoami(opts) {
@@ -6894,13 +7087,13 @@ function usage() {
6894
7087
  const banner = 'yotta-memory v' + VERSION + ' — 元忆:有权限边界的文件式智能体记忆';
6895
7088
  const sections = [
6896
7089
  ['核心记忆', [
6897
- ['init', '初始化记忆库(新建默认加密:需主口令 + 恢复钥匙;--attach 接入已有库;--no-encrypt 降级明文)'],
7090
+ ['init', '初始化记忆库(新建默认加密:需主口令 + 恢复钥匙;--attach 接入已有库;--no-encrypt 降级明文;非 TTY 用 --password-stdin;--recovery-key-out <文件> 写恢复钥匙)'],
6898
7091
  ['remember <type> <subject> <statement>', '写入记忆(--source 来源;--weight 权重;--verify 写后回读校验;--no-hint 关启发)'],
6899
7092
  ['recall [关键词]', '检索记忆(--type/--limit/--agent/--owner/--all/--unsafe)'],
6900
7093
  ['forget', '删除一条记忆'],
6901
7094
  ['archive', '归档(--days/--threshold 盖棺分+年龄)'],
6902
7095
  ['backup', '备份记忆库(volumes / setup / status / ensure-daily / schedule / create / list / doctor / restore <id> --to <目录> / drill)'],
6903
- ['doctor', '开工可靠性检查(根目录/密钥库/索引/身份/最近备份;--runtime 加查 CLI/current/MCP/进程/技能副本漂移)'],
7096
+ ['doctor', '开工可靠性检查(根目录/密钥库/索引/身份/最近备份;全新空库缺失 index/agents 降为 info;输出 YOTTA_MEMORY_AGENT_HOME 提示;--runtime 加查 CLI/current/MCP/进程/技能副本漂移)'],
6904
7097
  ['maintain', '记忆自组织(归档/遗忘候选/去重/合并;默认 dry-run;--dedup 查重+置信度,--dedup --apply 自动合并高置信组)'],
6905
7098
  ['consolidate', '周期摘要压缩(默认 dry-run;--apply 执行;--undo <batch> 回滚;--batches 查批次;候选=超龄+闲置+低效用,immutable/BOUND 豁免)'],
6906
7099
  ['distill', '心理日志蒸馏(统计摘要/主题画像/知识地图;--model 可选外部模型)'],
@@ -6918,8 +7111,8 @@ function usage() {
6918
7111
  ['token', '生成/列出/吊销访问 token(new --agent / list / revoke --agent)']
6919
7112
  ]],
6920
7113
  ['加密与安全', [
6921
- ['migrate', '把明文私密区迁移为密文(需主口令;迁移后打印恢复钥匙)'],
6922
- ['view', '启动用户查看平台(--port/--host;口令解锁浏览/授权/吊销 AI)'],
7114
+ ['migrate', '把明文库迁移为密文(需主口令;空明文库同样可用;--password-stdin;--recovery-key-out <文件> 写恢复钥匙)'],
7115
+ ['view', '启动用户查看平台(--port/--host;空加密库可用恢复钥匙校验主口令;已在运行则复用 URL)'],
6923
7116
  ['reset-password', '重设主口令(忘口令用恢复钥匙)'],
6924
7117
  ['key', '管理 agent_key binding(list / bind <id> / rotate <id> / claim <id> [--to <AI_HOME> | --agent-key-file <文件>] / status <id> [--to <AI_HOME> | --agent-key-file <文件>] / revoke <id>;bind/rotate 需主口令或恢复钥匙)'],
6925
7118
  ['config', '查看/设置配置(get;set memory_home <目录> / backup_dir <目录> / embedding_cmd <命令> / embedding_timeout <毫秒> / maintain_* 阈值与半衰 / consolidate_* 参数)']
@@ -6951,7 +7144,7 @@ async function main() {
6951
7144
  if (!args.length) { usage(); return; }
6952
7145
  const opts = {};
6953
7146
  const positional = [];
6954
- const valueOpts = new Set(['--type', '--limit', '--days', '--out', '--owner', '--agent', '--agent-id', '--agent-key', '--agent-key-file', '--threshold', '--scope', '--host', '--port', '--dir', '--name', '--user', '--relationship', '--source', '--weight', '--budget', '--password', '--new-password', '--recovery-key', '--reason', '--merge', '--model', '--subject', '--embedding', '--focus', '--embedding-timeout', '--min-age', '--min-idle', '--max-utility', '--min-group', '--period', '--to', '--id', '--time', '--tools', '--mcp-config', '--skill-dir']);
7147
+ const valueOpts = new Set(['--type', '--limit', '--days', '--out', '--owner', '--agent', '--agent-id', '--agent-key', '--agent-key-file', '--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']);
6955
7148
  for (let i = 0; i < args.length; i++) {
6956
7149
  const a = args[i];
6957
7150
  if (a === '--version' || a === '-v') { console.log(VERSION); return; }
@@ -6971,6 +7164,7 @@ async function main() {
6971
7164
  else if (a === '--no-hint') opts.noHint = true;
6972
7165
  else if (a === '--encrypt') opts.encrypt = true;
6973
7166
  else if (a === '--no-encrypt') opts.noEncrypt = true;
7167
+ else if (a === '--password-stdin') opts.passwordStdin = true;
6974
7168
  else if (a === '--json') opts.json = true;
6975
7169
  else if (a === '--manual') opts.manual = true;
6976
7170
  else if (a === '--skip-schedule') opts.skipSchedule = true;
@@ -7010,6 +7204,7 @@ async function main() {
7010
7204
  else if (a === '--password') opts.password = v;
7011
7205
  else if (a === '--new-password') opts.newPassword = v;
7012
7206
  else if (a === '--recovery-key') opts.recoveryKey = v;
7207
+ else if (a === '--recovery-key-out') opts.recoveryKeyOut = v;
7013
7208
  else if (a === '--reason') opts.reason = v;
7014
7209
  else if (a === '--merge') opts.merge = v;
7015
7210
  else if (a === '--model') opts.model = v;
@@ -7057,6 +7252,7 @@ async function main() {
7057
7252
  process.exit(2);
7058
7253
  }
7059
7254
  const runtimeIdentity = resolveIdentity(opts);
7255
+ if (runtimeIdentity.warning) console.error(runtimeIdentity.warning);
7060
7256
  setRuntimeAgent(runtimeIdentity.error ? '' : runtimeIdentity.id, runtimeIdentity.error ? '' : runtimeIdentity.agentKey);
7061
7257
  switch (first) {
7062
7258
  case 'init': await cmdInit(opts); break;
@@ -7109,7 +7305,7 @@ async function main() {
7109
7305
  break;
7110
7306
  }
7111
7307
  case 'migrate': await cmdMigrate(opts); break;
7112
- case 'view': cmdView(opts); break;
7308
+ case 'view': await cmdView(opts); break;
7113
7309
  case 'reset-password': await cmdResetPassword(opts); break;
7114
7310
  case 'key': {
7115
7311
  const sub = rest[0];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yottameta/yotta-memory",
3
- "version": "0.16.1",
3
+ "version": "0.16.2",
4
4
  "description": "Yuanyi (元忆) — boundary-aware, file-based memory for AI agents. File-based, zero-dependency, diff/rollback-able; FACT/PREF/BOUND/COMMIT types (public shared / private isolated), user-level + project-level storage; v0.12 reliability baseline (init guard, trash-based deletion, independent-volume backup/list/doctor/restore, start-of-work doctor, transactional snapshots before destructive writes), v0.10 consolidation (periodic summaries with provenance, near-duplicate auto-merge, per-type decay, batch audit + rollback), v0.9 recall quality + context focus + optional local embedding plugin, plus v0.8 semantic search, feedback loop, self-organization and distillation.",
5
5
  "license": "MIT",
6
6
  "keywords": [
package/references/faq.md CHANGED
@@ -8,6 +8,15 @@
8
8
  ## 2. 私密区加密怎么用?
9
9
  `init` 默认初始化加密库(需主口令 + 恢复钥匙,请妥善保存);明文库可用 `migrate` 升级为加密。私密区文件为 `.md.enc`,可 git 版本化。查看/授权用 `yotta-memory view`(口令解锁,浏览/授权/吊销 AI);授权时一次性展示的 `agent_key` 请立即保存,服务端同时写 `keys/pending/<id>.key` 供该 AI 新会话用 `key claim` 领取。出现 `[YTM_MIGRATION_REQUIRED]` 说明还有 agent 未绑定,请由你在 `view` 平台逐个点「授权」完成重新授权(AI 只提醒、不代执行)。
10
10
 
11
+ ## 2.1 非 TTY / GUI 宿主怎么初始化?
12
+ 不要依赖交互提示。用 `--password-stdin` 从管道读主口令(不进 argv),或用 `YOTTA_MEMORY_PASS`;恢复钥匙用 `--recovery-key-out <文件>` 写文件,避免 GUI 宿主吞掉 stdout。非 TTY 且未提供口令时会明确提示改用哪种方式,不会静默「已取消」。
13
+
14
+ ## 2.2 空加密库 `view` 打不开?
15
+ v0.16.2 起,空加密库(没有 owner key)可用恢复钥匙校验主口令进入 `view`。如果页面显示「无 owner」,先在终端执行 `yotta-memory iam <id>` 登记身份,再回页面授权;也可以由用户在终端执行 `yotta-memory key bind <id>`。
16
+
17
+ ## 2.3 `--agent-key-file` 指向的文件不存在?
18
+ 文件不存在时不再致命:元忆降级为未授权模式,公共 FACT 仍可读;私密操作 fail-closed,并提示 `key bind <id>`。文件存在但为空或不可读仍会报错。推荐用 `YOTTA_MEMORY_AGENT_HOME` 指定宿主目录,授权后 key 原地生效。
19
+
11
20
  ## 3. 多智能体权限怎么隔离?
12
21
  公共 FACT 所有智能体可读;PREF / BOUND / COMMIT 按 owner 物理隔离,调用方必须持有匹配的 `agent_key`(用户执行 `key bind <id>`,或在 `view` 平台授权获得)。owner ID 单独存在不构成认证,不授权 / 无 key 读不到。私密操作缺 key 时会输出 `[YTM_MIGRATION_REQUIRED]`;授权完成后该标记消失。AI 新会话用 `key status <id>` 检查,pending 存在则 `key claim <id>` 落到 `<AI_HOME>/.yotta-memory-agent-key`;`AI_HOME` 默认规则由 claim / status 共用(显式 `--to` / `--agent-key-file` > `YOTTA_MEMORY_AGENT_HOME` / `YOTTA_MEMORY_AGENT_KEY_FILE` > Codex / OpenCode / 通用宿主默认),status 会显示 `checked` 与 `discovery`。吊销后旧 key 立即校验失败,需重新授权。
13
22
 
@@ -3,7 +3,7 @@
3
3
  "slug": "yotta-memory",
4
4
  "name": "元忆",
5
5
  "package": "@yottameta/yotta-memory",
6
- "version": "0.16.1",
6
+ "version": "0.16.2",
7
7
  "trust": "yottameta",
8
8
  "install": {
9
9
  "idempotent": true