@yottameta/yotta-memory 0.22.0 → 0.22.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,18 @@
1
+ ## v0.22.2 (2026-10-05)
2
+
3
+ - **修复(分发载荷缺口)**:`@yottameta/yotta-memory-plugin@0.22.1` 包内载荷落后技能源(`errText` 0/17、`bodyMirrorsStatement` 0/2)——0.22.1 的 D2/D3 修复未随插件分发。本版为维护性同步升版:引擎行为与 0.22.1 一致,插件载荷重新从技能源构建并对齐(逐字节校验)。
4
+
5
+ ## v0.22.1 (2026-10-03)
6
+
7
+ - 管理台「高级 / CLI」页速查层升级为**全量**:覆盖 `HELP_MODEL` 全部 33 命令 / 31 子命令(64 项),分组对齐引擎帮助的 4 组(核心记忆 / 身份与画像 / 加密与安全 / 平台与服务);每条 = 引用命令 + 可复制示例 + 危险标记 + 注意事项,说明文案仍从 `HELP_MODEL` 取(单一真源)。
8
+ - 覆盖度双向门禁:`test/view-cli-help.test.js` 断言每个命令 / 子命令至少一条速查、速查引用真实存在;`tools/export_cli_help.js --check` 在仓库级复跑同一口径。
9
+ - 完整帮助(175 选项行)与只读 `POST /api/help` 不变;页面仍只展示与复制,不执行命令。
10
+ - **管理台归属修正**:编辑 FACT(公共记忆)时可直接改归属(仅元数据 + 审计 + 可撤销,ID 校验);PREF / BOUND / COMMIT(私密)归属置灰,提示删除重写或等待后续「归属迁移」功能。
11
+ - **写操作身份回显(防串号)**:`remember` / `forget` 输出 `[身份] agent_id: …;来源: …`;检测到宿主身份环境 `YOTTA_MEMORY_AGENT_HOME` 时追加 `[注意]` 警告;`SKILL.md` 增加「身份自检」流程(显式身份 + 回显核对 + 异常停写)。
12
+ - **修复(WorkBuddy 验收 D1)**:编辑记忆的「撤销」此前必然失败(`viewUpdateCore` 就地改写 + `viewRestoreCore` 拒绝覆盖已存在目标);现在 `.trash/view-history/` 的编辑历史撤销允许安全覆盖,并先把当前版本留作重做快照;回收区直恢复仍 fail-closed。
13
+ - **修复(D2)**:失败提示不再把布尔 `error` 当文案(统一 `errText`,覆盖 7 处 toast + 重设口令 1 处);失败时显示服务端 `text`。
14
+ - **修复(D3)**:编辑常规条目时同步 markdown 正文(仅当旧正文与旧 statement 完全一致;`distill` 类长文保留原 body)。
15
+
1
16
  ## v0.22.0 (2026-10-03)
2
17
 
3
18
  - 记忆管理台新增「高级 / CLI」页:左侧菜单入口 + 高频命令分组速查(备份恢复 / 维护瘦身 / 检索上下文 / 扫描安全 / 导入导出 / 身份权限 / 运行时与局域网),每条 = 人话说明 + 可复制示例 + 危险操作标记;「完整命令与参数」按需加载,直接渲染引擎帮助模型(`HELP_MODEL` 单一真源,随安装版本自动更新)。
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、事务快照、基线探针、恢复演练、记忆库安全扫描、scan、命中打点、容量水位、--capacity、--propose、上下文压缩审计、context --audit
4
- version: 0.22.0
4
+ version: 0.22.2
5
5
  license: MIT
6
6
  ---
7
7
 
@@ -63,6 +63,7 @@ AI 更新流程:先运行 `yotta-memory --version` 记录当前引擎版本;
63
63
  2. **进行中落盘**:出现事实 / 偏好 / 边界 / 纠正 / 承诺信号时立即 `yotta-memory remember <type> <subject> <statement> --verify`,不攒到收工。
64
64
  3. **收工归档**:收工前复盘本轮,检查是否留下 COMMIT / 会话小结;有关键结论但未落盘时补写并 `recall` 回读,无长期价值不硬凑。旧记录定期 `yotta-memory maintain --apply`(单条低效用归档)+ 记忆多了先 `yotta-memory consolidate`(propose 报告)→ 确认后 `yotta-memory consolidate --apply --yes`(非交互;同主题压缩成带溯源摘要,`--undo` 可回滚)。上下文被宿主压缩过、担心有决策只留在对话里时,把压缩内容喂给 `yotta-memory context --audit --from <文件>` 核对。
65
65
  4. **多智能体纪律**:FACT 写入公共区,PREF / BOUND / COMMIT 只写本智能体私密区;不读取其他智能体私密区。**一切读写一律走 `yotta-memory` CLI / MCP 工具**——禁止用 shell(`Get-ChildItem` / `Get-Content` / `cat` / `ls` / `type` 等)直接读或改记忆库目录下的 `.md` / `index.json` / `tokens.json` / `agents.json` / `grants.json` 等文件,否则会绕过权限边界、读到别的智能体私密内容。
66
+ 5. **身份自检(v0.22.1,防串号)**:跨宿主环境(Codex / YottaCode / DSH / OpenCode / 多 MCP)里,任何写入都必须显式声明身份:CLI 传 `--agent <id> --agent-key-file <文件>`,stdio MCP 传 `--agent-id <id> --agent-key-file <文件>`;不要依赖宿主发现。每次 `remember` / `forget` 的结果都会回显 `[身份] agent_id: …;来源: …`——**回显的 ID 不是本会话身份就立即停写并报告用户**;出现 `[注意] 检测到宿主身份环境 YOTTA_MEMORY_AGENT_HOME=…` 时按同样规则核对,必要时显式覆盖后重试。
66
67
 
67
68
  ## 会话闭环契约(v0.14.0,AI 必做)
68
69
 
@@ -312,7 +313,7 @@ yotta-memory doctor --json
312
313
  |---|---|
313
314
  | `yotta-memory init [--project] [--dir <目录>] [--attach] [--encrypt|--no-encrypt] [--password-stdin] [--recovery-key-out <文件>]` | 初始化(**新建默认加密**:设主口令 + 抄下恢复钥匙;已有库必须用 `--attach`,默认拒绝覆盖;`--no-encrypt` 降级明文;老明文库用 `migrate`;非 TTY 用 `--password-stdin`;恢复钥匙可写文件)|
314
315
  | `yotta-memory migrate [--password-stdin] [--recovery-key-out <文件>]` | 明文库 → 密文迁移(**由用户执行**;首次迁移推荐交互式,非 ASCII 口令勿用 Windows 管道;`view` 授权与 `key bind` 等价关系见《明文库转加密(第一次最短路径)》)|
315
- | `yotta-memory view [--port 8788] [--host 127.0.0.1]` | 用户查看平台(本机 Web:口令解锁浏览 / 搜索 / 导出全部 AI 记忆 + 授权 / 吊销 AI + 重设口令 + 显示恢复钥匙;已在运行则复用 URL,端口占用给明确提示)|
316
+ | `yotta-memory view [--port 8788] [--host 127.0.0.1]` | 用户查看平台(本机 Web:口令解锁浏览 / 搜索 / 导出全部 AI 记忆 + 授权 / 吊销 AI + 重设口令 + 显示恢复钥匙;「高级 / CLI」页 = 全量命令速查(33 命令 / 31 子命令)+ 完整帮助,只展示与复制;已在运行则复用 URL,端口占用给明确提示)|
316
317
  | `yotta-memory reset-password [--password <当前> | --recovery-key <钥匙>] [--new-password <新>]` | 重设主口令(忘口令用恢复钥匙)|
317
318
  | `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` 重新授权)|
318
319
  | `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 关闭类型启发式提示)|
package/assets/view.html CHANGED
@@ -347,6 +347,9 @@
347
347
  return data;
348
348
  } catch (e) { return { error: String(e) }; }
349
349
  }
350
+ function errText(r, fallback) {
351
+ return r && r.error ? ((r.text || r.message || fallback) || '操作失败') : '';
352
+ }
350
353
  function fmtBytes(n) {
351
354
  n = Number(n) || 0;
352
355
  if (n < 1024) return n + ' B';
@@ -454,7 +457,7 @@
454
457
  }
455
458
  async function loadEntries() {
456
459
  var d = await api('/api/entries', { query: state.query, offset: state.offset, limit: state.limit, type: state.type === 'all' ? '' : state.type });
457
- if (d.error) { toast(d.error); return; }
460
+ if (d.error) { toast(errText(d, '加载失败')); return; }
458
461
  state.entries = d.entries || [];
459
462
  state.count = d.count || 0;
460
463
  var totalPg = Math.max(1, Math.ceil(state.count / state.limit));
@@ -517,17 +520,20 @@
517
520
 
518
521
  function editorHtml(entry, isNew) {
519
522
  var t = entry ? String(entry.type || 'FACT').toUpperCase() : 'FACT';
523
+ var ownerLocked = !isNew && t !== 'FACT';
520
524
  return '<div class="field"><label>类型</label><select id="fType"' + (isNew ? '' : ' disabled') + '>' +
521
525
  ['FACT', 'PREF', 'BOUND', 'COMMIT'].map(function (x) { return '<option value="' + x + '"' + (x === t ? ' selected' : '') + '>' + typeNames[x] + '</option>'; }).join('') + '</select></div>' +
522
- '<div class="field" id="fOwnerWrap"><label>归属 AI(私密记忆必填;点选或输入自定义 ID)</label>' +
526
+ '<div class="field" id="fOwnerWrap"><label>' + (t === 'FACT' && !isNew ? '归属 AI(FACT 为公共记忆,归属仅作来源标记,可修改)' : '归属 AI(私密记忆必填;点选或输入自定义 ID)') + '</label>' +
523
527
  '<div class="owner-pick" id="fOwnerPick">' + (state.owners.length ? state.owners.map(function (o) {
524
- return '<button type="button" class="owner-chip' + ((entry && entry.owner === o.owner) ? ' active' : '') + '" data-owner="' + esc(o.owner) + '">' + esc(o.owner) + '<span class="tag">' + (o.authorized ? '已授权' : '未授权') + '</span></button>';
528
+ return '<button type="button" class="owner-chip' + ((entry && entry.owner === o.owner) ? ' active' : '') + '"' + (ownerLocked ? ' disabled' : '') + ' data-owner="' + esc(o.owner) + '">' + esc(o.owner) + '<span class="tag">' + (o.authorized ? '已授权' : '未授权') + '</span></button>';
525
529
  }).join('') : '<span class="faint">还没有已登记的 AI;可直接输入自定义 ID。</span>') + '</div>' +
526
- '<input id="fOwner" placeholder="自定义 AI ID(如 codex)" value="' + esc(entry && entry.owner || '') + '"></div>' +
530
+ '<input id="fOwner" placeholder="自定义 AI ID(如 codex)" value="' + esc(entry && entry.owner || '') + '"' + (ownerLocked ? ' disabled' : '') + '></div>' +
527
531
  '<div class="field"><label>标题 subject</label><input id="fSubject" value="' + esc(entry && entry.subject || '') + '"></div>' +
528
532
  '<div class="field"><label>内容 statement</label><textarea id="fStatement">' + esc(entry && entry.statement || '') + '</textarea></div>' +
529
533
  '<div class="field"><label>标签(逗号分隔)</label><input id="fTags" value="' + esc(entry && (entry.tags || []).join(', ') || '') + '"></div>' +
530
- (isNew ? '' : '<div class="faint">类型与归属不可修改;如需变更请删除后重写。</div>');
534
+ (isNew ? '' : (t === 'FACT'
535
+ ? '<div class="faint">类型不可修改;FACT 为公共记忆,归属仅作来源标记,可修改。</div>'
536
+ : '<div class="faint">类型与归属不可修改;私密记忆如需变更归属请删除后重写。</div>'));
531
537
  }
532
538
  function syncOwnerField() {
533
539
  var t = $('#fType') ? $('#fType').value : 'FACT';
@@ -536,7 +542,7 @@
536
542
  }
537
543
  function bindOwnerPicker() {
538
544
  var pick = $('#fOwnerPick');
539
- if (!pick) return;
545
+ if (!pick || ($('#fOwner') && $('#fOwner').disabled)) return;
540
546
  pick.addEventListener('click', function (e) {
541
547
  var chip = e.target.closest('.owner-chip');
542
548
  if (!chip) return;
@@ -555,12 +561,15 @@
555
561
  var statement = $('#fStatement').value.trim();
556
562
  var tags = $('#fTags').value.split(',').map(function (x) { return x.trim(); }).filter(Boolean);
557
563
  if (!subject || !statement) { toast('标题与内容不能为空'); return; }
558
- var r = await api('/api/memory/update', { file: entry.file, subject: subject, statement: statement, tags: tags });
559
- if (r.error) { toast(r.error); return; }
564
+ var body = { file: entry.file, subject: subject, statement: statement, tags: tags };
565
+ var type = String(entry.type || 'FACT').toUpperCase();
566
+ if (type === 'FACT' && $('#fOwner') && !$('#fOwner').disabled) body.owner = $('#fOwner').value.trim();
567
+ var r = await api('/api/memory/update', body);
568
+ if (r.error) { toast(errText(r, '保存失败')); return; }
560
569
  closeModal();
561
- toast('已更新 1 条记忆', '撤销', async function () {
570
+ toast('已更新 1 条记忆' + (r.ownerChange ? '(归属已改为 ' + (r.ownerChange.to || '公共') + ')' : ''), '撤销', async function () {
562
571
  var u = await api('/api/memory/restore', { ref: r.undo });
563
- toast(u.error || '已撤销这次编辑');
572
+ toast(errText(u, '撤销失败') || '已撤销这次编辑');
564
573
  refreshAll();
565
574
  });
566
575
  refreshAll();
@@ -578,7 +587,7 @@
578
587
  var tags = $('#fTags').value.split(',').map(function (x) { return x.trim(); }).filter(Boolean);
579
588
  if (!subject || !statement) { toast('标题与内容不能为空'); return; }
580
589
  var r = await api('/api/memory/create', { type: type, owner: owner, subject: subject, statement: statement, tags: tags });
581
- if (r.error) { toast(r.error); return; }
590
+ if (r.error) { toast(errText(r, '创建失败')); return; }
582
591
  closeModal();
583
592
  toast('已创建 1 条记忆');
584
593
  refreshAll();
@@ -591,11 +600,11 @@
591
600
  function doDelete(entry) {
592
601
  confirmModal('删除这条记忆?', '将移入回收区(不物理删除),可从「最近删除」恢复。', '删除', true, async function () {
593
602
  var r = await api('/api/memory/delete', { file: entry.file });
594
- if (r.error) { toast(r.error); return; }
603
+ if (r.error) { toast(errText(r, '删除失败')); return; }
595
604
  closeModal();
596
605
  toast('已删除 1 条记忆', '撤销', async function () {
597
606
  var u = await api('/api/memory/restore', { ref: r.trash });
598
- toast(u.error || '已恢复');
607
+ toast(errText(u, '恢复失败') || '已恢复');
599
608
  refreshAll();
600
609
  });
601
610
  refreshAll();
@@ -604,11 +613,11 @@
604
613
  function doArchive(entry) {
605
614
  confirmModal('藏起来(归档)?', '归档后不参与召回,但数据保留,可随时放回。', '藏起来', false, async function () {
606
615
  var r = await api('/api/memory/archive', { file: entry.file });
607
- if (r.error) { toast(r.error); return; }
616
+ if (r.error) { toast(errText(r, '归档失败')); return; }
608
617
  closeModal();
609
618
  toast('已藏起来 1 条', '放回', async function () {
610
619
  var u = await api('/api/memory/unarchive', { archive: r.archive });
611
- toast(u.error || '已放回');
620
+ toast(errText(u, '放回失败') || '已放回');
612
621
  refreshAll();
613
622
  });
614
623
  refreshAll();
@@ -625,7 +634,7 @@
625
634
  var b = e.target.closest('button[data-unarch]');
626
635
  if (!b) return;
627
636
  var r = await api('/api/memory/unarchive', { archive: b.dataset.unarch });
628
- toast(r.error || '已放回');
637
+ toast(errText(r, '放回失败') || '已放回');
629
638
  closeModal();
630
639
  refreshAll();
631
640
  }, { once: true });
@@ -640,7 +649,7 @@
640
649
  var b = e.target.closest('button[data-restore]');
641
650
  if (!b) return;
642
651
  var r = await api('/api/memory/restore', { ref: b.dataset.restore });
643
- toast(r.error || '已恢复');
652
+ toast(errText(r, '恢复失败') || '已恢复');
644
653
  closeModal();
645
654
  refreshAll();
646
655
  }, { once: true });
@@ -673,13 +682,13 @@
673
682
  if (authorized) {
674
683
  confirmModal('吊销 ' + owner + ' 的授权?', '吊销后该智能体立即失去私密读写能力,旧 key 会立即校验失败。', '吊销', true, async function () {
675
684
  var r = await api('/api/revoke', { owner: owner });
676
- closeModal(); toast(r.error || '已吊销'); loadOwners();
685
+ closeModal(); toast(errText(r, '吊销失败') || '已吊销'); loadOwners();
677
686
  });
678
687
  } else {
679
688
  confirmModal('授权 ' + owner + '?', '「授权」由你(用户)操作:确认后生成只显示一次的 agent_key,请立即单独保存;服务端同时写临时待领取文件 keys/pending/<agent_id>.key,供该 AI 新会话执行 key claim 领取,领取成功后自动删除。AI 不应代为执行该授权操作。', '授权', false, async function () {
680
689
  var r = await api('/api/authorize', { owner: owner });
681
690
  closeModal();
682
- if (r.error) { toast(r.error); return; }
691
+ if (r.error) { toast(errText(r, '授权失败')); return; }
683
692
  openModal('agent_key(只显示一次)', '<p class="muted">请立即单独保存;AI 新会话执行 key status → key claim 领取。</p><textarea class="keybox" readonly>' + esc(r.agentKey) + '</textarea>', { okText: '我已保存', onOk: closeModal });
684
693
  var box = $('#modalBody').querySelector('textarea');
685
694
  if (box) { box.focus(); box.select(); }
@@ -695,7 +704,7 @@
695
704
  var typed = $('#confirmOwner').value.trim();
696
705
  if (typed !== target) { toast('ID 不匹配'); return; }
697
706
  var r = await api('/api/identity-remove', { owner: target, confirm: typed });
698
- closeModal(); toast(r.error || '已删除'); loadOwners(); loadOverview(); loadEntries();
707
+ closeModal(); toast(errText(r, '删除失败') || '已删除'); loadOwners(); loadOverview(); loadEntries();
699
708
  },
700
709
  });
701
710
  }
@@ -706,17 +715,17 @@
706
715
  var a = $('#pwCur').value, b = $('#pwNew').value, c = $('#pwNew2').value;
707
716
  if (!b || b !== c) { $('#pwOut').textContent = '两次新口令不一致'; return; }
708
717
  var r = await api('/api/reset-password', { currentPassword: a, newPassword: b });
709
- $('#pwOut').textContent = r.error || (r.text || '已重设');
718
+ $('#pwOut').textContent = r.error ? (r.text || '重设失败') : (r.text || '已重设');
710
719
  if (!r.error) { $('#pwCur').value = $('#pwNew').value = $('#pwNew2').value = ''; }
711
720
  });
712
721
  $('#btnRk').addEventListener('click', async function () {
713
722
  var r = await api('/api/recovery-key');
714
- if (r.error) { toast(r.error); return; }
723
+ if (r.error) { toast(errText(r, '恢复钥匙读取失败')); return; }
715
724
  openModal('恢复钥匙(离线保存)', '<p class="muted">泄露等同口令泄露。忘记主口令时可用它重设。</p><textarea class="keybox" readonly>' + esc(r.recoveryKey) + '</textarea>', { okText: '我已保存', onOk: closeModal });
716
725
  });
717
726
  $('#btnExport').addEventListener('click', async function () {
718
727
  var r = await api('/api/export');
719
- if (r.error) { toast(r.error); return; }
728
+ if (r.error) { toast(errText(r, '导出失败')); return; }
720
729
  var blob = new Blob([JSON.stringify(r, null, 2)], { type: 'application/json' });
721
730
  var a = document.createElement('a');
722
731
  a.href = URL.createObjectURL(blob);
@@ -812,7 +821,7 @@
812
821
  async function ensureHelp() {
813
822
  if (state.help) { renderQuick(); return; }
814
823
  var d = await api('/api/help');
815
- if (!d || d.error) { toast((d && d.error) || '帮助加载失败'); return; }
824
+ if (!d || d.error) { toast(errText(d, '帮助加载失败')); return; }
816
825
  state.help = d;
817
826
  renderQuick();
818
827
  }
@@ -25,7 +25,7 @@ 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.22.0';
28
+ const VERSION = '0.22.2';
29
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', '--from']);
30
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', '--propose', '--audit', '--capacity', '--rules', '--explain', '--semantic', '--runtime', '--ablate', '--timing', '--baseline', '--probe', '--quarantine', '--restore', '--no-usage', '--yes', '--keep-memories', '--keep-identity']);
31
31
 
@@ -305,55 +305,86 @@ const HELP_MODEL = [
305
305
  { name: '--version', usage: '--version;短写法 -v', what: '显示当前版本', when: '确认安装的是哪个版本时', options: [] },
306
306
  ] },
307
307
  ];
308
- // v0.22.0:管理台「高级 / CLI」页速查数据。这里只维护「引用哪条命令 + 可复制示例 + 危险标记」,
309
- // 说明文案统一从 HELP_MODEL 取(单一真源),管理台不再维护第二份参数表。
308
+ // v0.22.1:管理台「高级 / CLI」页速查数据 —— 全量覆盖 HELP_MODEL 的每个命令与子命令。
309
+ // 这里只维护「引用哪条命令 + 可复制示例 + 危险标记 + 注意事项」,说明文案统一从
310
+ // HELP_MODEL 取(单一真源);覆盖度由 test/view-cli-help.test.js 与
311
+ // tools/export_cli_help.js --check 双向门禁。
310
312
  const VIEW_HELP_QUICK = [
311
- { group: '备份恢复', items: [
313
+ { group: '核心记忆', items: [
314
+ { cmd: 'init', example: 'yotta-memory init --encrypt' },
315
+ { cmd: 'remember', example: 'yotta-memory remember FACT 主题 内容' },
316
+ { cmd: 'recall', example: 'yotta-memory recall 关键词 --type BOUND' },
317
+ { cmd: 'forget', example: 'yotta-memory forget <记忆 id>', danger: true, note: '删除前先确认 id;重要库先 backup create 留恢复点' },
318
+ { cmd: 'rename', example: 'yotta-memory rename <记忆 id> <新文件名> --dry-run' },
319
+ { cmd: 'archive', example: 'yotta-memory archive --dry-run', note: '先预演;--force 会关闭冷却期与常青豁免' },
320
+ { cmd: 'backup', example: 'yotta-memory backup <子命令>' },
321
+ { cmd: 'backup volumes', example: 'yotta-memory backup volumes' },
312
322
  { cmd: 'backup setup', example: 'yotta-memory backup setup' },
323
+ { cmd: 'backup status', example: 'yotta-memory backup status' },
324
+ { cmd: 'backup ensure-daily', example: 'yotta-memory backup ensure-daily' },
325
+ { cmd: 'backup schedule', example: 'yotta-memory backup schedule on --time 03:30' },
313
326
  { cmd: 'backup create', example: 'yotta-memory backup create' },
314
327
  { cmd: 'backup list', example: 'yotta-memory backup list' },
328
+ { cmd: 'backup doctor', example: 'yotta-memory backup doctor' },
315
329
  { cmd: 'backup restore', example: 'yotta-memory backup restore <id> --to <目录>', danger: true, note: '恢复会写入目标目录;覆盖非空目录需显式 --force,先确认目标和备份' },
316
330
  { cmd: 'backup drill', example: 'yotta-memory backup drill <id>', note: '先演练确认备份可用,再正式恢复' },
317
- ] },
318
- { group: '维护瘦身', items: [
319
331
  { cmd: 'doctor', example: 'yotta-memory doctor' },
320
332
  { cmd: 'maintain', example: 'yotta-memory maintain --capacity', note: '只读报告,不会自动归档或改权重' },
321
- { cmd: 'archive', example: 'yotta-memory archive --dry-run', note: '先预演;--force 会关闭冷却期与常青豁免' },
322
333
  { cmd: 'consolidate', example: 'yotta-memory consolidate --propose' },
323
- { cmd: 'forget', example: 'yotta-memory forget <记忆 id>', danger: true, note: '删除前先确认 id;重要库先 backup create 留恢复点' },
324
- ] },
325
- { group: '检索上下文', items: [
326
- { cmd: 'context', example: 'yotta-memory context' },
327
- { cmd: 'recall', example: 'yotta-memory recall 关键词 --type BOUND' },
328
- { cmd: 'bench', example: 'yotta-memory bench' },
334
+ { cmd: 'distill', example: 'yotta-memory distill --model <命令>' },
335
+ { cmd: 'feedback', example: 'yotta-memory feedback <记忆 id> --useful' },
329
336
  { cmd: 'explain', example: 'yotta-memory explain <记忆 id>' },
330
- ] },
331
- { group: '扫描安全', items: [
337
+ { cmd: 'bench', example: 'yotta-memory bench' },
332
338
  { cmd: 'scan', example: 'yotta-memory scan' },
333
- { cmd: 'doctor', example: 'yotta-memory doctor --baseline', note: '恢复 / 迁移后追加六类只读基线探针' },
334
- ] },
335
- { group: '导入导出', items: [
339
+ { cmd: 'reindex', example: 'yotta-memory reindex' },
336
340
  { cmd: 'export', example: 'yotta-memory export --out 记忆导出.json' },
337
341
  { cmd: 'import', example: 'yotta-memory import 记忆导出.json', danger: true, note: '导入会写入现有记忆库;先确认文件来源与内容' },
338
342
  ] },
339
- { group: '身份权限', items: [
343
+ { group: '身份与画像', items: [
340
344
  { cmd: 'iam', example: 'yotta-memory iam --name <名字> --user <用户> --relationship <关系>' },
345
+ { cmd: 'identity', example: 'yotta-memory identity remove <id> --dry-run', note: '子命令 remove:真删该 AI 的私密记忆(公共 FACT 保留)' },
346
+ { cmd: 'identity remove', example: 'yotta-memory identity remove <id> --yes', danger: true, note: '先 --dry-run 预览;--yes 后不可恢复(私密记忆真删)' },
341
347
  { cmd: 'whoami', example: 'yotta-memory whoami' },
342
- { cmd: 'key status', example: 'yotta-memory key status <id>' },
348
+ { cmd: 'profile', example: 'yotta-memory profile --json' },
349
+ { cmd: 'context', example: 'yotta-memory context' },
350
+ { cmd: 'token', example: 'yotta-memory token <子命令>' },
351
+ { cmd: 'token new', example: 'yotta-memory token new --agent <id>', note: '一次性 token,只显示一次,请立即保存' },
352
+ { cmd: 'token list', example: 'yotta-memory token list' },
353
+ { cmd: 'token revoke', example: 'yotta-memory token revoke --agent <id>', danger: true, note: '吊销后该 token 立即失效' },
354
+ ] },
355
+ { group: '加密与安全', items: [
356
+ { cmd: 'migrate', example: 'yotta-memory migrate --recovery-key-out <文件>', note: '迁移后立刻保存恢复钥匙' },
357
+ { cmd: 'view', example: 'yotta-memory view' },
358
+ { cmd: 'reset-password', example: 'yotta-memory reset-password --recovery-key <钥匙>', danger: true, note: '重置后旧口令失效;恢复钥匙必须单独保管' },
359
+ { cmd: 'key', example: 'yotta-memory key <子命令>' },
360
+ { cmd: 'key list', example: 'yotta-memory key list' },
361
+ { cmd: 'key bind', example: 'yotta-memory key bind <id>' },
362
+ { cmd: 'key rotate', example: 'yotta-memory key rotate <id>', danger: true, note: '轮换后旧 key 立即失效' },
363
+ { cmd: 'key authorize', example: 'yotta-memory key authorize <id>', note: '授权该 AI 读写私密区' },
343
364
  { cmd: 'key claim', example: 'yotta-memory key claim <id>' },
365
+ { cmd: 'key status', example: 'yotta-memory key status <id>' },
344
366
  { cmd: 'key revoke', example: 'yotta-memory key revoke <id>', danger: true, note: '吊销后该 agent_key 立即失效' },
345
- { cmd: 'identity remove', example: 'yotta-memory identity remove <id> --dry-run', danger: true, note: '真删该 AI 的私密记忆(公共 FACT 保留);先 --dry-run 预览' },
367
+ { cmd: 'config', example: 'yotta-memory config <get | set>' },
368
+ { cmd: 'config get', example: 'yotta-memory config get' },
369
+ { cmd: 'config set', example: 'yotta-memory config set <键> <值>' },
346
370
  ] },
347
- { group: '运行时与局域网', items: [
348
- { cmd: 'runtime status', example: 'yotta-memory runtime status' },
371
+ { group: '平台与服务', items: [
372
+ { cmd: 'runtime', example: 'yotta-memory runtime <子命令>' },
373
+ { cmd: 'runtime install', example: 'yotta-memory runtime install <tarball|版本>' },
374
+ { cmd: 'runtime use', example: 'yotta-memory runtime use <版本>' },
349
375
  { cmd: 'runtime rollback', example: 'yotta-memory runtime rollback', danger: true, note: '回滚运行时入口;先确认当前版本与目标版本' },
376
+ { cmd: 'runtime status', example: 'yotta-memory runtime status' },
377
+ { cmd: 'runtime list', example: 'yotta-memory runtime list' },
350
378
  { cmd: 'serve', example: 'yotta-memory serve' },
351
- { cmd: 'lan status', example: 'yotta-memory lan status' },
379
+ { cmd: 'lan', example: 'yotta-memory lan <子命令>' },
352
380
  { cmd: 'lan enable', example: 'yotta-memory lan enable', note: '开机自启;不再需要时用 lan disable 关闭' },
381
+ { cmd: 'lan disable', example: 'yotta-memory lan disable' },
382
+ { cmd: 'lan status', example: 'yotta-memory lan status' },
383
+ { cmd: '--version', example: 'yotta-memory --version' },
353
384
  ] },
354
385
  ];
355
386
  // @generated view-html:start
356
- const VIEW_HTML = "<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"robots\" content=\"noindex,nofollow\">\n<link rel=\"icon\" href=\"data:,\">\n<title>元忆 · 记忆管理台</title>\n<style>\n :root{--bg:#0f0c12;--bg2:#141019;--panel:#191420;--panel2:#1f1828;--line:rgba(255,217,138,.12);--line2:rgba(255,217,138,.24);\n --text:#f4eee4;--muted:#a79c8e;--faint:#776c60;--amber:#f0a13a;--gold:#ffd98a;--ok:#7fc49a;--danger:#e07070;--shadow:0 10px 30px rgba(0,0,0,.35)}\n body.light{--bg:#f6f1e8;--bg2:#f1eade;--panel:#fffdf8;--panel2:#faf5ec;--line:rgba(56,42,28,.12);--line2:rgba(56,42,28,.24);\n --text:#241c14;--muted:#6f6458;--faint:#96897b;--amber:#c1781c;--gold:#9c6a16;--ok:#3e7d57;--danger:#b23c3c;--shadow:0 8px 24px rgba(56,42,28,.10)}\n *{box-sizing:border-box}[hidden]{display:none!important}\n html,body{margin:0;padding:0}\n body{background:var(--bg);color:var(--text);font:14px/1.6 \"Microsoft YaHei\",\"PingFang SC\",\"Noto Sans CJK SC\",system-ui,sans-serif;letter-spacing:0}\n button{font:inherit;color:inherit;cursor:pointer}\n input,select,textarea{font:inherit;color:var(--text);background:var(--panel2);border:1px solid var(--line2);border-radius:8px;padding:9px 12px;outline:0;width:100%}\n input:focus,select:focus,textarea:focus{border-color:var(--amber)}\n textarea{min-height:96px;resize:vertical}\n .muted{color:var(--muted)}.faint{color:var(--faint);font-size:12px}\n .sv{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;flex:none}\n\n /* lock */\n .lock-wrap{min-height:100vh;display:grid;place-items:center;padding:24px}\n .lock-card{width:min(420px,100%);background:var(--panel);border:1px solid var(--line2);border-radius:12px;padding:26px;box-shadow:var(--shadow)}\n .lock-card h1{margin:14px 0 6px;font-size:22px}\n .lock-card .field{margin-top:16px}\n .err{color:var(--danger);margin-top:10px;font-size:13px;min-height:20px}\n\n /* shell */\n .shell{display:grid;grid-template-columns:236px minmax(0,1fr);min-height:100vh}\n .sidebar{background:var(--bg2);border-right:1px solid var(--line);padding:22px 16px;display:flex;flex-direction:column;gap:22px;position:sticky;top:0;height:100vh;min-width:0}\n .brand{display:flex;align-items:center;gap:10px;padding:0 6px}\n .brand-mark{width:34px;height:34px;border-radius:8px;background:rgba(240,161,58,.14);border:1px solid var(--line2);display:grid;place-items:center;color:var(--gold)}\n .brand-name{font-size:16px;font-weight:600}.brand-sub{font-size:11px;color:var(--faint)}\n .nav{display:flex;flex-direction:column;gap:4px;min-width:0}\n .nav button{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:6px;border:1px solid transparent;background:transparent;color:var(--muted);text-align:left}\n .nav button:hover{background:var(--panel);color:var(--text)}\n .nav button.active{background:var(--panel);color:var(--gold);border-color:var(--line)}\n .side-foot{margin-top:auto;display:flex;flex-direction:column;gap:10px;min-width:0}\n .privacy{display:flex;gap:8px;align-items:flex-start;padding:10px;border:1px solid var(--line);border-radius:8px;color:var(--muted);font-size:12px;background:var(--panel)}\n .privacy .sv{color:var(--ok);margin-top:2px}\n .ghost-btn{display:flex;align-items:center;justify-content:center;gap:8px;border:1px solid var(--line);background:transparent;color:var(--muted);border-radius:6px;padding:8px}\n .ghost-btn:hover{color:var(--text);border-color:var(--line2)}\n\n .main{min-width:0;padding:26px 30px 60px}\n .topbar{display:flex;align-items:center;gap:14px;margin-bottom:22px;flex-wrap:wrap}\n .page-title{font-size:20px;font-weight:600;margin:0}.page-sub{color:var(--faint);font-size:12px;margin-top:2px}\n .spacer{flex:1}\n .search{display:flex;align-items:center;gap:8px;min-width:240px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:8px 12px;color:var(--muted)}\n .search input{background:transparent;border:0;padding:0;width:100%}\n .chip-user{display:flex;align-items:center;gap:8px;padding:6px 10px;border:1px solid var(--line);border-radius:999px;color:var(--muted);font-size:12px}\n .dot{width:8px;height:8px;border-radius:50%;background:var(--amber)}\n\n .view{display:none}.view.active{display:block}\n .grid{display:grid;gap:14px}.g4{grid-template-columns:repeat(4,minmax(0,1fr))}.g2{grid-template-columns:repeat(2,minmax(0,1fr))}\n .card{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:16px;min-width:0}\n .card h3{margin:0 0 4px;font-size:14px}\n .label{color:var(--muted);font-size:12px}.value{font-size:26px;font-weight:600;margin:8px 0 2px}.delta{font-size:12px;color:var(--ok)}\n .section{margin-top:20px}\n .shead{display:flex;align-items:baseline;gap:10px;margin-bottom:10px}.shead h2{font-size:15px;margin:0}\n .bars{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px}\n .bar{border:1px solid var(--line);border-radius:8px;padding:12px;background:var(--panel2)}\n .bar b{font-size:18px;display:block;margin-top:6px}\n .bar .dot{display:inline-block;margin-right:6px}\n\n .toolbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap}\n .chips{display:flex;gap:8px;flex-wrap:wrap}\n .chip{border:1px solid var(--line);background:transparent;color:var(--muted);border-radius:999px;padding:5px 12px;font-size:12px}\n .chip.active{color:var(--gold);border-color:var(--line2);background:rgba(240,161,58,.10)}\n .btn{display:inline-flex;align-items:center;gap:8px;padding:8px 14px;border-radius:6px;border:1px solid var(--line2);background:transparent;color:var(--text)}\n .btn:hover{border-color:var(--gold)}\n .btn.primary{background:var(--amber);border-color:var(--amber);color:#1b1206;font-weight:600}\n .btn.danger{border-color:rgba(224,112,112,.5);color:var(--danger)}\n .btn.small{padding:5px 10px;font-size:12px}\n .btn[disabled]{opacity:.5;cursor:not-allowed}\n\n .list{display:flex;flex-direction:column;gap:8px;margin-top:14px}\n .row{display:flex;align-items:center;gap:12px;padding:12px 14px;border:1px solid var(--line);border-radius:8px;background:var(--panel2)}\n .row:hover{border-color:var(--line2)}\n .row-main{flex:1;min-width:0;cursor:pointer}\n .row-title{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .row-snippet{color:var(--muted);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .row-meta{color:var(--faint);font-size:11px;margin-top:2px}\n .row-actions{display:flex;gap:6px}\n .ibtn{width:30px;height:30px;display:grid;place-items:center;border:1px solid var(--line);background:transparent;border-radius:6px;color:var(--muted)}\n .ibtn:hover{color:var(--text);border-color:var(--line2)}\n .badge{font-size:11px;padding:2px 8px;border-radius:999px;border:1px solid var(--line2);color:var(--gold);background:rgba(240,161,58,.10);white-space:nowrap}\n .badge.pref{color:#b79cdd;border-color:rgba(183,156,221,.4);background:rgba(183,156,221,.10)}\n .badge.bound{color:#8fb4de;border-color:rgba(143,180,222,.4);background:rgba(143,180,222,.10)}\n .badge.commit{color:var(--ok);border-color:rgba(127,196,154,.4);background:rgba(127,196,154,.10)}\n .badge.off{color:var(--faint);border-color:var(--line);background:transparent}\n .empty{color:var(--faint);text-align:center;padding:26px;border:1px dashed var(--line);border-radius:8px;margin-top:14px}\n .pager{display:flex;align-items:center;gap:10px;margin-top:12px;color:var(--faint);font-size:12px}\n\n table{width:100%;border-collapse:collapse}\n th,td{text-align:left;padding:12px 10px;border-bottom:1px solid var(--line);font-size:13px}\n th{color:var(--faint);font-weight:500;font-size:12px}\n .owner-name{font-weight:600}\n\n .field{margin-bottom:12px}\n .field label{display:block;color:var(--muted);font-size:12px;margin-bottom:6px}\n .row2{display:grid;grid-template-columns:1fr 1fr;gap:12px}\n .owner-pick{display:flex;flex-wrap:wrap;gap:6px;max-height:132px;overflow:auto;margin-bottom:8px}\n .owner-chip{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:transparent;color:var(--muted);border-radius:999px;padding:5px 10px;font-size:12px}\n .owner-chip:hover{color:var(--text);border-color:var(--line2)}\n .owner-chip.active{color:var(--gold);border-color:var(--line2);background:rgba(240,161,58,.12)}\n .owner-chip .tag{font-size:10px;color:var(--faint);border:1px solid var(--line);border-radius:999px;padding:0 6px}\n .owner-chip.active .tag{color:var(--gold);border-color:var(--line2)}\n\n .mask{position:fixed;inset:0;background:rgba(0,0,0,.5);display:none;place-items:center;z-index:50;padding:20px}\n .mask.open{display:grid}\n #drawerMask{z-index:40}\n .modal{width:min(560px,100%);max-height:86vh;overflow:auto;background:var(--panel);border:1px solid var(--line2);border-radius:10px;padding:18px;box-shadow:var(--shadow)}\n .modal h3{margin:0 0 10px;font-size:16px}\n .modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:14px}\n .drawer{position:fixed;top:0;right:0;bottom:0;width:min(440px,92vw);background:var(--panel);border-left:1px solid var(--line2);padding:20px;overflow:auto;transform:translateX(100%);transition:transform .2s;z-index:45}\n .drawer.open{transform:none}\n .kv{display:grid;grid-template-columns:86px 1fr;gap:6px 10px;font-size:13px;margin:14px 0}\n .kv dt{color:var(--faint)}.kv dd{margin:0;word-break:break-all}\n .quote{border-left:2px solid var(--line2);padding-left:12px;color:var(--muted);white-space:pre-wrap;word-break:break-word}\n .keybox{width:100%;min-height:64px;font-family:ui-monospace,Consolas,monospace;font-size:12px}\n\n .toasts{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);display:flex;flex-direction:column;gap:8px;align-items:center;z-index:60}\n .toast{display:flex;align-items:center;gap:10px;background:var(--panel2);border:1px solid var(--line2);border-radius:999px;padding:9px 16px;box-shadow:var(--shadow);font-size:13px}\n .toast button{border:0;background:transparent;color:var(--gold);font-weight:600}\n .list-plain{display:flex;flex-direction:column;gap:8px;max-height:46vh;overflow:auto}\n .list-plain .item{display:flex;align-items:center;gap:10px;border:1px solid var(--line);border-radius:8px;padding:9px 10px;font-size:12px;color:var(--muted)}\n .list-plain .item .spacer{flex:1}\n\n /* advanced cli */\n .quick-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}\n .cmd-item{border:1px solid var(--line);border-radius:8px;padding:12px 14px;background:var(--panel2);display:flex;flex-direction:column;gap:8px;min-width:0}\n .cmd-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}\n .cmd-name{font-family:ui-monospace,Consolas,monospace;font-size:12px;color:var(--gold)}\n .cmd-what{font-weight:600}\n .cmd-when{color:var(--muted);font-size:12px}\n .code-line{display:flex;align-items:center;gap:8px;background:rgba(0,0,0,.28);border:1px solid var(--line);border-radius:6px;padding:6px 10px;min-width:0}\n body.light .code-line{background:rgba(56,42,28,.06)}\n .code-line code{flex:1;min-width:0;font-family:ui-monospace,Consolas,monospace;font-size:12px;overflow-x:auto;white-space:nowrap}\n .danger-badge{color:var(--danger);border-color:rgba(224,112,112,.5);background:rgba(224,112,112,.10)}\n .cmd-note{color:var(--danger);font-size:12px}\n .quick-group{margin-top:14px}\n .quick-group h4{margin:0 0 8px;font-size:13px;color:var(--muted)}\n .help-cmd{border:1px solid var(--line);border-radius:8px;background:var(--panel2);margin-top:10px;min-width:0}\n .help-cmd summary{cursor:pointer;padding:10px 14px;display:flex;gap:8px;align-items:center;flex-wrap:wrap;list-style:none}\n .help-cmd summary::-webkit-details-marker{display:none}\n .help-cmd summary::before{content:'';width:6px;height:6px;border-right:1.6px solid var(--muted);border-bottom:1.6px solid var(--muted);transform:rotate(-45deg);transition:transform .15s;flex:none}\n .help-cmd[open] summary::before{transform:rotate(45deg)}\n .help-body{padding:12px 14px;display:flex;flex-direction:column;gap:8px;border-top:1px solid var(--line)}\n .help-sub{border-top:1px solid var(--line);padding-top:8px;margin-top:2px;display:flex;flex-direction:column;gap:6px}\n .help-sub .cmd-name{color:var(--text)}\n .opt-row{display:grid;grid-template-columns:minmax(150px,auto) minmax(0,1fr);gap:2px 12px;padding:7px 0;border-top:1px solid var(--line);font-size:12px}\n .opt-row:first-child{border-top:0}\n .opt-flag{font-family:ui-monospace,Consolas,monospace;word-break:break-all}\n .opt-what{color:var(--muted)}\n .opt-when{color:var(--faint)}\n .opt-risk{color:var(--danger)}\n\n @media (max-width:960px){.g4{grid-template-columns:repeat(2,minmax(0,1fr))}.bars{grid-template-columns:repeat(2,minmax(0,1fr))}.quick-list{grid-template-columns:minmax(0,1fr)}}\n @media (max-width:760px){\n .shell{grid-template-columns:minmax(0,1fr)}\n .sidebar{position:static;height:auto;padding:14px;gap:12px}\n .nav{flex-direction:row;overflow-x:auto;width:100%}\n .nav button{white-space:nowrap}\n .side-foot{margin-top:0;flex-direction:row}\n .privacy{flex:1}\n .main{padding:18px 16px 48px}\n .search{min-width:100%;order:3}\n .g4{grid-template-columns:repeat(2,minmax(0,1fr))}\n table{display:block;overflow-x:auto;white-space:nowrap}\n .row{flex-wrap:wrap}.row-actions{width:100%;justify-content:flex-end}\n }\n</style>\n</head>\n<body>\n<div id=\"lock\" class=\"lock-wrap\">\n <div class=\"lock-card\">\n <div class=\"brand\">\n <div class=\"brand-mark\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><ellipse cx=\"12\" cy=\"5\" rx=\"8\" ry=\"3\"/><path d=\"M4 5v14c0 1.7 3.6 3 8 3s8-1.3 8-3V5\"/><path d=\"M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3\"/></svg></div>\n <div><div class=\"brand-name\">元忆 yotta-memory</div><div class=\"brand-sub\">你的 AI 记忆,由你掌控</div></div>\n </div>\n <h1>解锁记忆管理台 <span id=\"ver\" class=\"faint\"></span></h1>\n <div class=\"field\"><label>主口令(只在本地内存派生,不落盘、不发远端)</label><input type=\"password\" id=\"pw\" placeholder=\"主口令\"></div>\n <button class=\"btn primary\" id=\"unlockBtn\" style=\"width:100%\">解锁</button>\n <div class=\"err\" id=\"lockErr\"></div>\n <div class=\"faint\" style=\"margin-top:8px\">忘记口令可用恢复钥匙在 CLI 重设:<code>yotta-memory reset-password --recovery-key &lt;钥匙&gt;</code></div>\n </div>\n</div>\n\n<div id=\"app\" class=\"shell\" hidden>\n <aside class=\"sidebar\">\n <div class=\"brand\">\n <div class=\"brand-mark\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><ellipse cx=\"12\" cy=\"5\" rx=\"8\" ry=\"3\"/><path d=\"M4 5v14c0 1.7 3.6 3 8 3s8-1.3 8-3V5\"/><path d=\"M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3\"/></svg></div>\n <div><div class=\"brand-name\">元忆</div><div class=\"brand-sub\">你的 AI 记忆,由你掌控</div></div>\n </div>\n <nav class=\"nav\" id=\"nav\">\n <button data-view=\"overview\" class=\"active\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M3 10.5 12 3l9 7.5\"/><path d=\"M5 9.5V21h14V9.5\"/></svg>概览</button>\n <button data-view=\"memories\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M4 19.5A2.5 2.5 0 0 1 6.5 17H20\"/><path d=\"M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z\"/></svg>记忆</button>\n <button data-view=\"access\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2\"/><circle cx=\"9\" cy=\"7\" r=\"4\"/><path d=\"M22 21v-2a4 4 0 0 0-3-3.87\"/></svg>AI 与权限</button>\n <button data-view=\"settings\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M4 21v-7\"/><path d=\"M4 10V3\"/><path d=\"M12 21v-9\"/><path d=\"M12 8V3\"/><path d=\"M20 21v-5\"/><path d=\"M20 12V3\"/><path d=\"M1 14h6\"/><path d=\"M9 8h6\"/><path d=\"M17 16h6\"/></svg>设置</button>\n <button data-view=\"cli\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"m4 17 6-6-6-6\"/><path d=\"M12 19h8\"/></svg>高级 / CLI</button>\n </nav>\n <div class=\"side-foot\">\n <div class=\"privacy\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z\"/><path d=\"m9 12 2 2 4-4\"/></svg><span>数据不出本机<br>私密区按 AI 隔离加密</span></div>\n <button class=\"ghost-btn\" id=\"themeBtn\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4\"/><path d=\"M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4\"/></svg>切换主题</button>\n </div>\n </aside>\n\n <main class=\"main\">\n <div class=\"topbar\">\n <div><h1 class=\"page-title\" id=\"pageTitle\">概览</h1><div class=\"page-sub\" id=\"pageSub\">你的 AI 记住了什么,一眼看清</div></div>\n <div class=\"spacer\"></div>\n <label class=\"search\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m20 20-3.5-3.5\"/></svg><input id=\"globalSearch\" placeholder=\"搜索记忆 / 关键词\"></label>\n <div class=\"chip-user\"><span class=\"dot\"></span>本机 · 用户</div>\n </div>\n\n <section class=\"view active\" id=\"view-overview\">\n <div class=\"grid g4\">\n <div class=\"card\"><div class=\"label\">记忆总数</div><div class=\"value\" id=\"ovTotal\">-</div><div class=\"delta\" id=\"ovFiles\">-</div></div>\n <div class=\"card\"><div class=\"label\">私密记忆</div><div class=\"value\" id=\"ovPrivate\">-</div><div class=\"delta\">仅对应 AI 与本人可见</div></div>\n <div class=\"card\"><div class=\"label\">AI 智能体</div><div class=\"value\" id=\"ovOwners\">-</div><div class=\"delta\" id=\"ovAuth\">-</div></div>\n <div class=\"card\"><div class=\"label\">已藏起来(归档)</div><div class=\"value\" id=\"ovArchived\">-</div><div class=\"delta\">可随时放回</div></div>\n </div>\n <div class=\"section\">\n <div class=\"shead\"><h2>四类记忆分布</h2><span class=\"faint\">FACT 共享 · PREF / BOUND / COMMIT 私密</span></div>\n <div class=\"bars\" id=\"typeBars\"></div>\n </div>\n <div class=\"section grid g2\">\n <div class=\"card\">\n <h3>最近更新</h3>\n <div class=\"list\" id=\"recentList\"></div>\n </div>\n <div class=\"card\">\n <h3>备份与安全</h3>\n <div class=\"kv\" style=\"margin-top:10px\">\n <dt>备份目录</dt><dd id=\"ovBackup\">-</dd>\n <dt>存储占用</dt><dd id=\"ovBytes\">-</dd>\n <dt>私密加密</dt><dd>已启用(AES-256-GCM 信封加密)</dd>\n </div>\n <div style=\"display:flex;gap:8px;flex-wrap:wrap\">\n <button class=\"btn small\" id=\"quickArchived\">查看已藏</button>\n <button class=\"btn small\" id=\"quickTrash\">最近删除</button>\n <button class=\"btn small primary\" data-goto=\"memories\">管理记忆</button>\n </div>\n </div>\n </div>\n </section>\n\n <section class=\"view\" id=\"view-memories\">\n <div class=\"card toolbar\">\n <label class=\"search\" style=\"min-width:280px;flex:1\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m20 20-3.5-3.5\"/></svg><input id=\"memSearch\" placeholder=\"搜索记忆(标题 / 内容 / 标签)\"></label>\n <div class=\"chips\" id=\"typeChips\">\n <button class=\"chip active\" data-type=\"all\">全部</button>\n <button class=\"chip\" data-type=\"FACT\">FACT</button>\n <button class=\"chip\" data-type=\"PREF\">PREF</button>\n <button class=\"chip\" data-type=\"BOUND\">BOUND</button>\n <button class=\"chip\" data-type=\"COMMIT\">COMMIT</button>\n </div>\n <div class=\"spacer\"></div>\n <button class=\"btn\" id=\"btnArchived\">已藏</button>\n <button class=\"btn\" id=\"btnTrash\">最近删除</button>\n <button class=\"btn primary\" id=\"btnNew\">新建记忆</button>\n </div>\n <div class=\"list\" id=\"memList\"></div>\n <div class=\"empty\" id=\"memEmpty\" hidden>没有匹配的记忆。</div>\n <div class=\"pager\"><button class=\"btn small\" id=\"prevPage\">上一页</button><span id=\"pageInfo\">-</span><button class=\"btn small\" id=\"nextPage\">下一页</button></div>\n </section>\n\n <section class=\"view\" id=\"view-access\">\n <div class=\"card\">\n <h3>谁看得见什么</h3>\n <div class=\"faint\" style=\"margin-bottom:10px\">默认拒绝:每个 AI 只能读自己的私密区;公共 FACT 所有已授权 AI 可见。授权会生成只显示一次的 agent_key,请立即单独保存。</div>\n <table><thead><tr><th>AI 智能体</th><th>FACT 公共</th><th>私密记忆</th><th>状态</th><th></th></tr></thead><tbody id=\"ownerRows\"></tbody></table>\n </div>\n <div class=\"section card\">\n <h3>恢复与备份</h3>\n <div class=\"kv\"><dt>备份目录</dt><dd id=\"accBackup\">-</dd><dt>恢复钥匙</dt><dd>离线保存;泄露等同口令泄露</dd></div>\n <div style=\"display:flex;gap:8px;flex-wrap:wrap\"><button class=\"btn small\" id=\"btnRk\">显示恢复钥匙</button><button class=\"btn small\" id=\"btnExport\">导出全部 JSON</button></div>\n </div>\n </section>\n\n <section class=\"view\" id=\"view-settings\">\n <div class=\"card\">\n <h3>重设主口令</h3>\n <div class=\"row2\" style=\"margin-top:10px\">\n <div class=\"field\"><label>当前口令</label><input type=\"password\" id=\"pwCur\"></div>\n <div class=\"field\"><label>新口令</label><input type=\"password\" id=\"pwNew\"></div>\n <div class=\"field\"><label>确认新口令</label><input type=\"password\" id=\"pwNew2\"></div>\n </div>\n <button class=\"btn primary\" id=\"btnResetPw\">重设口令</button>\n <div class=\"err\" id=\"pwOut\"></div>\n </div>\n <div class=\"section card\">\n <h3>危险操作</h3>\n <div class=\"faint\">删除某个 AI 的身份与其私密记忆(公共 FACT 保留,其它 AI 不受影响)。此操作不可恢复,请谨慎使用。</div>\n <div style=\"margin-top:10px\"><button class=\"btn danger small\" id=\"btnDangerHint\">在哪里执行?</button></div>\n </div>\n </section>\n\n <section class=\"view\" id=\"view-cli\">\n <div class=\"card\">\n <h3>常用命令速查</h3>\n <div class=\"faint\" style=\"margin-bottom:12px\">只展示与复制;命令都在终端执行,危险操作已标注。</div>\n <div id=\"cliQuick\"><div class=\"faint\">加载中…</div></div>\n </div>\n <div class=\"section card\">\n <h3>完整命令与参数</h3>\n <div class=\"faint\" style=\"margin-bottom:12px\">与当前安装版本一致(随版本自动更新);按需加载,只读展示,不执行任何命令。</div>\n <button class=\"btn\" id=\"btnFullHelp\">加载完整命令与参数</button>\n <div id=\"cliFull\" hidden></div>\n </div>\n </section>\n </main>\n</div>\n\n<div class=\"mask\" id=\"modalMask\"><div class=\"modal\"><h3 id=\"modalTitle\"></h3><div id=\"modalBody\"></div><div class=\"modal-actions\"><button class=\"btn\" id=\"modalCancel\">取消</button><button class=\"btn primary\" id=\"modalOk\">确认</button></div></div></div>\n<div class=\"mask\" id=\"drawerMask\"></div>\n<aside class=\"drawer\" id=\"drawer\">\n <div style=\"display:flex;gap:10px;align-items:flex-start\"><div style=\"flex:1\"><h3 id=\"drTitle\"></h3><div class=\"faint\" id=\"drMeta\"></div></div><button class=\"ibtn\" id=\"drClose\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M18 6 6 18\"/><path d=\"m6 6 12 12\"/></svg></button></div>\n <div class=\"quote\" id=\"drBody\" style=\"margin-top:12px\"></div>\n <dl class=\"kv\" id=\"drKv\"></dl>\n <div style=\"display:flex;gap:8px\"><button class=\"btn\" id=\"drEdit\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M12 20h9\"/><path d=\"M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z\"/></svg>编辑</button><button class=\"btn danger\" id=\"drDelete\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2\"/><path d=\"M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6\"/></svg>删除</button></div>\n</aside>\n<div class=\"toasts\" id=\"toasts\"></div>\n\n<script>\n(function () {\n 'use strict';\n var $ = function (s) { return document.querySelector(s); };\n var state = { view: 'overview', entries: [], count: 0, offset: 0, limit: 20, query: '', type: 'all', owners: [], overview: null, current: null, help: null };\n var typeNames = { FACT: 'FACT 事实', PREF: 'PREF 偏好', BOUND: 'BOUND 边界', COMMIT: 'COMMIT 承诺' };\n\n function esc(s) {\n return String(s == null ? '' : s).replace(/[&<>\"']/g, function (c) {\n return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c];\n });\n }\n async function api(pathname, body) {\n try {\n var res = await fetch(pathname, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) });\n var text = await res.text();\n var data;\n try { data = JSON.parse(text); } catch (e) { data = { error: text }; }\n if (res.status === 401) showLock();\n return data;\n } catch (e) { return { error: String(e) }; }\n }\n function fmtBytes(n) {\n n = Number(n) || 0;\n if (n < 1024) return n + ' B';\n if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';\n return (n / 1024 / 1024).toFixed(2) + ' MB';\n }\n function pad2(n) { return (n < 10 ? '0' : '') + n; }\n function fmtTime(e) {\n if (e && e.mtime) {\n var d = new Date(e.mtime);\n if (!isNaN(d.getTime())) {\n return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()) + ' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());\n }\n }\n return (e && (e.updated || e.created)) || '';\n }\n function toast(msg, actionLabel, fn) {\n var el = document.createElement('div');\n el.className = 'toast';\n el.appendChild(document.createTextNode(msg));\n if (actionLabel && fn) {\n var b = document.createElement('button');\n b.textContent = actionLabel;\n b.addEventListener('click', function () { el.remove(); fn(); });\n el.appendChild(b);\n }\n $('#toasts').appendChild(el);\n setTimeout(function () { el.remove(); }, actionLabel ? 9000 : 3600);\n }\n function showLock() { $('#lock').hidden = false; $('#app').hidden = true; }\n function showApp() { $('#lock').hidden = true; $('#app').hidden = false; refreshAll(); }\n\n /* modal / drawer */\n var modalOkFn = null;\n function openModal(title, bodyHtml, opts) {\n opts = opts || {};\n $('#modalTitle').textContent = title;\n $('#modalBody').innerHTML = bodyHtml;\n $('#modalOk').textContent = opts.okText || '确认';\n $('#modalOk').className = 'btn ' + (opts.danger ? 'danger' : 'primary');\n $('#modalOk').hidden = !opts.onOk;\n modalOkFn = opts.onOk || null;\n $('#modalMask').classList.add('open');\n }\n function closeModal() { $('#modalMask').classList.remove('open'); modalOkFn = null; }\n function confirmModal(title, text, okText, danger, onOk) {\n openModal(title, '<p class=\"muted\">' + esc(text) + '</p>', { okText: okText, danger: danger, onOk: onOk });\n }\n $('#modalCancel').addEventListener('click', closeModal);\n $('#modalMask').addEventListener('click', function (e) { if (e.target === $('#modalMask')) closeModal(); });\n $('#modalOk').addEventListener('click', function () { if (modalOkFn) modalOkFn(); });\n function openDrawer() { $('#drawer').classList.add('open'); $('#drawerMask').classList.add('open'); }\n function closeDrawer() { $('#drawer').classList.remove('open'); $('#drawerMask').classList.remove('open'); }\n $('#drClose').addEventListener('click', closeDrawer);\n $('#drawerMask').addEventListener('click', closeDrawer);\n\n /* nav */\n var titles = { overview: ['概览', '你的 AI 记住了什么,一眼看清'], memories: ['记忆', '看 / 改 / 删 / 藏,全在这里'], access: ['AI 与权限', '默认拒绝,按 AI 隔离'], settings: ['设置', '口令 / 备份 / 导出'], cli: ['高级 / CLI', '常用命令速查与完整帮助(只展示与复制)'] };\n function switchView(name) {\n state.view = name;\n Array.prototype.forEach.call(document.querySelectorAll('#nav button'), function (b) { b.classList.toggle('active', b.dataset.view === name); });\n Array.prototype.forEach.call(document.querySelectorAll('.view'), function (v) { v.classList.toggle('active', v.id === 'view-' + name); });\n $('#pageTitle').textContent = titles[name][0];\n $('#pageSub').textContent = titles[name][1];\n window.scrollTo(0, 0);\n if (name === 'cli') ensureHelp();\n }\n $('#nav').addEventListener('click', function (e) { var b = e.target.closest('button'); if (b) switchView(b.dataset.view); });\n Array.prototype.forEach.call(document.querySelectorAll('[data-goto]'), function (b) { b.addEventListener('click', function () { switchView(b.dataset.goto); }); });\n $('#themeBtn').addEventListener('click', function () { document.body.classList.toggle('light'); });\n\n /* data loaders */\n async function loadOverview() {\n var d = await api('/api/overview');\n if (d.error) return;\n state.overview = d;\n var priv = d.total - (d.byType.FACT || 0);\n $('#ovTotal').textContent = d.total;\n $('#ovFiles').textContent = d.files + ' 个文件';\n $('#ovPrivate').textContent = priv;\n $('#ovOwners').textContent = d.owners.length;\n $('#ovAuth').textContent = d.authorized + ' 已授权';\n $('#ovArchived').textContent = d.archived;\n $('#ovBackup').textContent = d.backupDir || '未配置(CLI: yotta-memory backup setup)';\n $('#ovBytes').textContent = fmtBytes(d.bytes);\n $('#accBackup').textContent = d.backupDir || '未配置';\n var colors = { FACT: 'var(--gold)', PREF: '#b79cdd', BOUND: '#8fb4de', COMMIT: 'var(--ok)' };\n $('#typeBars').innerHTML = ['FACT', 'PREF', 'BOUND', 'COMMIT'].map(function (t) {\n var n = d.byType[t] || 0;\n return '<div class=\"bar\"><span class=\"dot\" style=\"background:' + colors[t] + '\"></span><span class=\"faint\">' + typeNames[t] + '</span><b>' + n + '</b></div>';\n }).join('');\n }\n async function loadOwners() {\n var d = await api('/api/owners');\n state.owners = (d && d.owners) || [];\n var box = $('#ownerRows');\n if (!state.owners.length) { box.innerHTML = '<tr><td colspan=\"5\" class=\"faint\">' + esc((d && d.hint) || '当前没有 owner,请先在 CLI 执行 yotta-memory iam <id>。') + '</td></tr>'; return; }\n box.innerHTML = state.owners.map(function (o) {\n return '<tr><td><span class=\"owner-name\">' + esc(o.owner) + '</span><div class=\"faint\">' + (o.registered ? '已登记' : '未登记') + '</div></td>' +\n '<td><span class=\"badge commit\">可见</span></td>' +\n '<td>' + (o.authorized ? '<span class=\"badge commit\">已授权</span>' : '<span class=\"badge off\">仅自己</span>') + '</td>' +\n '<td>' + (o.authorized ? '可读自己的私密区' : '未绑定 agent_key') + '</td>' +\n '<td><button class=\"btn small\" data-auth=\"' + esc(o.owner) + '\">' + (o.authorized ? '吊销' : '授权') + '</button> <button class=\"btn small danger\" data-remove=\"' + esc(o.owner) + '\">删除</button></td></tr>';\n }).join('');\n }\n async function loadEntries() {\n var d = await api('/api/entries', { query: state.query, offset: state.offset, limit: state.limit, type: state.type === 'all' ? '' : state.type });\n if (d.error) { toast(d.error); return; }\n state.entries = d.entries || [];\n state.count = d.count || 0;\n var totalPg = Math.max(1, Math.ceil(state.count / state.limit));\n var curPg = Math.floor(state.offset / state.limit) + 1;\n $('#pageInfo').textContent = '共 ' + state.count + ' 条 · 第 ' + curPg + ' / ' + totalPg + ' 页';\n $('#prevPage').disabled = state.offset <= 0;\n $('#nextPage').disabled = !d.hasMore;\n renderEntries();\n }\n function renderEntries() {\n var box = $('#memList');\n $('#memEmpty').hidden = state.entries.length > 0;\n box.innerHTML = state.entries.map(function (e) {\n var t = String(e.type || 'FACT').toUpperCase();\n return '<div class=\"row\" data-file=\"' + esc(e.file) + '\"><div class=\"row-main\"><span class=\"badge ' + t.toLowerCase() + '\">' + typeNames[t] + '</span>' +\n '<div class=\"row-title\">' + esc(e.subject) + '</div><div class=\"row-snippet\">' + esc(e.statement) + '</div>' +\n '<div class=\"row-meta\">' + esc(e.owner || '公共') + ' · ' + esc(fmtTime(e)) + ' · ' + esc(e.file) + '</div></div>' +\n '<div class=\"row-actions\"><button class=\"ibtn\" data-act=\"edit\" title=\"编辑\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M12 20h9\"/><path d=\"M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z\"/></svg></button>' +\n '<button class=\"ibtn\" data-act=\"archive\" title=\"藏起来(归档)\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M9.9 4.24A9.1 9.1 0 0 1 12 4c7 0 10 8 10 8a17.5 17.5 0 0 1-2.16 3.19\"/><path d=\"M6.6 6.6A16.8 16.8 0 0 0 2 12s3 8 10 8a9.6 9.6 0 0 0 5.4-1.6\"/><path d=\"m2 2 20 20\"/></svg></button>' +\n '<button class=\"ibtn\" data-act=\"delete\" title=\"删除\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2\"/><path d=\"M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6\"/></svg></button></div></div>';\n }).join('');\n }\n async function refreshAll() {\n await loadOverview();\n await loadOwners();\n await loadEntries();\n var recent = state.entries.slice(0, 4).map(function (e) {\n var t = String(e.type || 'FACT').toUpperCase();\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(e.subject) + '</div><div class=\"row-meta\">' + esc(e.owner || '公共') + ' · ' + esc(fmtTime(e)) + '</div></div><span class=\"badge ' + t.toLowerCase() + '\">' + typeNames[t] + '</span></div>';\n }).join('');\n $('#recentList').innerHTML = recent || '<div class=\"faint\">还没有记忆。</div>';\n }\n\n /* entries actions */\n $('#memList').addEventListener('click', async function (e) {\n var row = e.target.closest('.row');\n if (!row) return;\n var file = row.dataset.file;\n var entry = state.entries.filter(function (x) { return x.file === file; })[0];\n var act = e.target.closest('button[data-act]');\n if (!act) { openDetail(entry); return; }\n if (act.dataset.act === 'edit') openEditor(entry);\n else if (act.dataset.act === 'delete') doDelete(entry);\n else if (act.dataset.act === 'archive') doArchive(entry);\n });\n function openDetail(entry) {\n if (!entry) return;\n state.current = entry;\n var t = String(entry.type || 'FACT').toUpperCase();\n $('#drTitle').textContent = entry.subject || '(无标题)';\n $('#drMeta').textContent = typeNames[t] + ' · ' + (entry.owner || '公共') + ' · ' + fmtTime(entry);\n $('#drBody').textContent = entry.statement || '';\n $('#drKv').innerHTML = '<dt>创建</dt><dd>' + esc(entry.btime ? fmtTime({ mtime: entry.btime }) : (entry.created || '-')) + '</dd>' +\n '<dt>最近更新</dt><dd>' + esc(fmtTime(entry) || '-') + '</dd>' +\n '<dt>文件</dt><dd>' + esc(entry.file) + '</dd><dt>可见性</dt><dd>' + (t === 'FACT' ? '所有已授权 AI' : '仅 ' + esc(entry.owner) + '(私密)') + '</dd><dt>标签</dt><dd>' + esc((entry.tags || []).join(', ') || '-') + '</dd>';\n openDrawer();\n }\n $('#drEdit').addEventListener('click', function () { if (state.current) { closeDrawer(); openEditor(state.current); } });\n $('#drDelete').addEventListener('click', function () { if (state.current) { closeDrawer(); doDelete(state.current); } });\n\n function editorHtml(entry, isNew) {\n var t = entry ? String(entry.type || 'FACT').toUpperCase() : 'FACT';\n return '<div class=\"field\"><label>类型</label><select id=\"fType\"' + (isNew ? '' : ' disabled') + '>' +\n ['FACT', 'PREF', 'BOUND', 'COMMIT'].map(function (x) { return '<option value=\"' + x + '\"' + (x === t ? ' selected' : '') + '>' + typeNames[x] + '</option>'; }).join('') + '</select></div>' +\n '<div class=\"field\" id=\"fOwnerWrap\"><label>归属 AI(私密记忆必填;点选或输入自定义 ID)</label>' +\n '<div class=\"owner-pick\" id=\"fOwnerPick\">' + (state.owners.length ? state.owners.map(function (o) {\n return '<button type=\"button\" class=\"owner-chip' + ((entry && entry.owner === o.owner) ? ' active' : '') + '\" data-owner=\"' + esc(o.owner) + '\">' + esc(o.owner) + '<span class=\"tag\">' + (o.authorized ? '已授权' : '未授权') + '</span></button>';\n }).join('') : '<span class=\"faint\">还没有已登记的 AI;可直接输入自定义 ID。</span>') + '</div>' +\n '<input id=\"fOwner\" placeholder=\"自定义 AI ID(如 codex)\" value=\"' + esc(entry && entry.owner || '') + '\"></div>' +\n '<div class=\"field\"><label>标题 subject</label><input id=\"fSubject\" value=\"' + esc(entry && entry.subject || '') + '\"></div>' +\n '<div class=\"field\"><label>内容 statement</label><textarea id=\"fStatement\">' + esc(entry && entry.statement || '') + '</textarea></div>' +\n '<div class=\"field\"><label>标签(逗号分隔)</label><input id=\"fTags\" value=\"' + esc(entry && (entry.tags || []).join(', ') || '') + '\"></div>' +\n (isNew ? '' : '<div class=\"faint\">类型与归属不可修改;如需变更请删除后重写。</div>');\n }\n function syncOwnerField() {\n var t = $('#fType') ? $('#fType').value : 'FACT';\n var wrap = $('#fOwnerWrap');\n if (wrap) wrap.hidden = (t === 'FACT');\n }\n function bindOwnerPicker() {\n var pick = $('#fOwnerPick');\n if (!pick) return;\n pick.addEventListener('click', function (e) {\n var chip = e.target.closest('.owner-chip');\n if (!chip) return;\n Array.prototype.forEach.call(pick.querySelectorAll('.owner-chip'), function (c) { c.classList.toggle('active', c === chip); });\n $('#fOwner').value = chip.dataset.owner;\n });\n $('#fOwner').addEventListener('input', function () {\n var v = $('#fOwner').value.trim();\n Array.prototype.forEach.call(pick.querySelectorAll('.owner-chip'), function (c) { c.classList.toggle('active', c.dataset.owner === v); });\n });\n }\n function openEditor(entry) {\n openModal('编辑记忆', editorHtml(entry, false), {\n okText: '保存', onOk: async function () {\n var subject = $('#fSubject').value.trim();\n var statement = $('#fStatement').value.trim();\n var tags = $('#fTags').value.split(',').map(function (x) { return x.trim(); }).filter(Boolean);\n if (!subject || !statement) { toast('标题与内容不能为空'); return; }\n var r = await api('/api/memory/update', { file: entry.file, subject: subject, statement: statement, tags: tags });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast('已更新 1 条记忆', '撤销', async function () {\n var u = await api('/api/memory/restore', { ref: r.undo });\n toast(u.error || '已撤销这次编辑');\n refreshAll();\n });\n refreshAll();\n },\n });\n bindOwnerPicker();\n }\n $('#btnNew').addEventListener('click', function () {\n openModal('新建记忆', editorHtml(null, true), {\n okText: '创建', onOk: async function () {\n var type = $('#fType').value;\n var owner = $('#fOwner') ? $('#fOwner').value.trim() : '';\n var subject = $('#fSubject').value.trim();\n var statement = $('#fStatement').value.trim();\n var tags = $('#fTags').value.split(',').map(function (x) { return x.trim(); }).filter(Boolean);\n if (!subject || !statement) { toast('标题与内容不能为空'); return; }\n var r = await api('/api/memory/create', { type: type, owner: owner, subject: subject, statement: statement, tags: tags });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast('已创建 1 条记忆');\n refreshAll();\n },\n });\n bindOwnerPicker();\n syncOwnerField();\n $('#fType').addEventListener('change', syncOwnerField);\n });\n function doDelete(entry) {\n confirmModal('删除这条记忆?', '将移入回收区(不物理删除),可从「最近删除」恢复。', '删除', true, async function () {\n var r = await api('/api/memory/delete', { file: entry.file });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast('已删除 1 条记忆', '撤销', async function () {\n var u = await api('/api/memory/restore', { ref: r.trash });\n toast(u.error || '已恢复');\n refreshAll();\n });\n refreshAll();\n });\n }\n function doArchive(entry) {\n confirmModal('藏起来(归档)?', '归档后不参与召回,但数据保留,可随时放回。', '藏起来', false, async function () {\n var r = await api('/api/memory/archive', { file: entry.file });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast('已藏起来 1 条', '放回', async function () {\n var u = await api('/api/memory/unarchive', { archive: r.archive });\n toast(u.error || '已放回');\n refreshAll();\n });\n refreshAll();\n });\n }\n\n async function openArchived() {\n var d = await api('/api/archived');\n var items = (d.entries || []).map(function (e) {\n return '<div class=\"item\"><span>' + esc(e.original) + '</span><span class=\"spacer\"></span><button class=\"btn small\" data-unarch=\"' + esc(e.ref) + '\">放回</button></div>';\n }).join('');\n openModal('已藏起来(归档)', items ? '<div class=\"list-plain\">' + items + '</div>' : '<p class=\"faint\">没有归档的记忆。</p>', {});\n $('#modalBody').addEventListener('click', async function (e) {\n var b = e.target.closest('button[data-unarch]');\n if (!b) return;\n var r = await api('/api/memory/unarchive', { archive: b.dataset.unarch });\n toast(r.error || '已放回');\n closeModal();\n refreshAll();\n }, { once: true });\n }\n async function openTrash() {\n var d = await api('/api/trash');\n var items = (d.entries || []).filter(function (e) { return e.kind === 'trash'; }).map(function (e) {\n return '<div class=\"item\"><span>' + esc(e.original) + '</span><span class=\"spacer\"></span><button class=\"btn small\" data-restore=\"' + esc(e.ref) + '\">恢复</button></div>';\n }).join('');\n openModal('最近删除', items ? '<div class=\"list-plain\">' + items + '</div>' : '<p class=\"faint\">回收区是空的。</p>', {});\n $('#modalBody').addEventListener('click', async function (e) {\n var b = e.target.closest('button[data-restore]');\n if (!b) return;\n var r = await api('/api/memory/restore', { ref: b.dataset.restore });\n toast(r.error || '已恢复');\n closeModal();\n refreshAll();\n }, { once: true });\n }\n $('#btnArchived').addEventListener('click', openArchived);\n $('#quickArchived').addEventListener('click', openArchived);\n $('#btnTrash').addEventListener('click', openTrash);\n $('#quickTrash').addEventListener('click', openTrash);\n\n /* search / filter / paging */\n $('#memSearch').addEventListener('keydown', function (e) { if (e.key === 'Enter') { state.query = e.target.value; state.offset = 0; loadEntries(); } });\n $('#globalSearch').addEventListener('keydown', function (e) { if (e.key === 'Enter') { state.query = e.target.value; state.offset = 0; switchView('memories'); loadEntries(); } });\n $('#typeChips').addEventListener('click', function (e) {\n var b = e.target.closest('.chip');\n if (!b) return;\n state.type = b.dataset.type; state.offset = 0;\n Array.prototype.forEach.call(document.querySelectorAll('#typeChips .chip'), function (c) { c.classList.toggle('active', c === b); });\n loadEntries();\n });\n $('#prevPage').addEventListener('click', function () { if (state.offset > 0) { state.offset -= state.limit; loadEntries(); } });\n $('#nextPage').addEventListener('click', function () { state.offset += state.limit; loadEntries(); });\n\n /* owners */\n $('#ownerRows').addEventListener('click', async function (e) {\n var authBtn = e.target.closest('button[data-auth]');\n var rmBtn = e.target.closest('button[data-remove]');\n if (authBtn) {\n var owner = authBtn.dataset.auth;\n var authorized = state.owners.filter(function (o) { return o.owner === owner; })[0].authorized;\n if (authorized) {\n confirmModal('吊销 ' + owner + ' 的授权?', '吊销后该智能体立即失去私密读写能力,旧 key 会立即校验失败。', '吊销', true, async function () {\n var r = await api('/api/revoke', { owner: owner });\n closeModal(); toast(r.error || '已吊销'); loadOwners();\n });\n } else {\n confirmModal('授权 ' + owner + '?', '「授权」由你(用户)操作:确认后生成只显示一次的 agent_key,请立即单独保存;服务端同时写临时待领取文件 keys/pending/<agent_id>.key,供该 AI 新会话执行 key claim 领取,领取成功后自动删除。AI 不应代为执行该授权操作。', '授权', false, async function () {\n var r = await api('/api/authorize', { owner: owner });\n closeModal();\n if (r.error) { toast(r.error); return; }\n openModal('agent_key(只显示一次)', '<p class=\"muted\">请立即单独保存;AI 新会话执行 key status → key claim 领取。</p><textarea class=\"keybox\" readonly>' + esc(r.agentKey) + '</textarea>', { okText: '我已保存', onOk: closeModal });\n var box = $('#modalBody').querySelector('textarea');\n if (box) { box.focus(); box.select(); }\n loadOwners();\n });\n }\n return;\n }\n if (rmBtn) {\n var target = rmBtn.dataset.remove;\n openModal('删除 ' + target + ' 的身份与私密记忆', '<p class=\"muted\">不可恢复;公共 FACT 保留,其它 AI 不受影响。请输入完整 ID 确认:</p><input id=\"confirmOwner\" placeholder=\"' + esc(target) + '\">', {\n okText: '删除', danger: true, onOk: async function () {\n var typed = $('#confirmOwner').value.trim();\n if (typed !== target) { toast('ID 不匹配'); return; }\n var r = await api('/api/identity-remove', { owner: target, confirm: typed });\n closeModal(); toast(r.error || '已删除'); loadOwners(); loadOverview(); loadEntries();\n },\n });\n }\n });\n\n /* settings */\n $('#btnResetPw').addEventListener('click', async function () {\n var a = $('#pwCur').value, b = $('#pwNew').value, c = $('#pwNew2').value;\n if (!b || b !== c) { $('#pwOut').textContent = '两次新口令不一致'; return; }\n var r = await api('/api/reset-password', { currentPassword: a, newPassword: b });\n $('#pwOut').textContent = r.error || (r.text || '已重设');\n if (!r.error) { $('#pwCur').value = $('#pwNew').value = $('#pwNew2').value = ''; }\n });\n $('#btnRk').addEventListener('click', async function () {\n var r = await api('/api/recovery-key');\n if (r.error) { toast(r.error); return; }\n openModal('恢复钥匙(离线保存)', '<p class=\"muted\">泄露等同口令泄露。忘记主口令时可用它重设。</p><textarea class=\"keybox\" readonly>' + esc(r.recoveryKey) + '</textarea>', { okText: '我已保存', onOk: closeModal });\n });\n $('#btnExport').addEventListener('click', async function () {\n var r = await api('/api/export');\n if (r.error) { toast(r.error); return; }\n var blob = new Blob([JSON.stringify(r, null, 2)], { type: 'application/json' });\n var a = document.createElement('a');\n a.href = URL.createObjectURL(blob);\n a.download = 'yottamemory-export.json';\n a.click();\n toast('已导出 JSON');\n });\n $('#btnDangerHint').addEventListener('click', function () {\n toast('在「AI 与权限」列表里,每个 AI 右侧有「删除」按钮');\n });\n\n /* advanced cli */\n function findHelpEntry(groups, ref) {\n var seg = String(ref).split(' ');\n for (var i = 0; i < groups.length; i++) {\n var cmds = groups[i].commands || [];\n for (var j = 0; j < cmds.length; j++) {\n var c = cmds[j];\n if (c.name !== seg[0]) continue;\n if (seg.length === 1) return c;\n var subs = c.subcommands || [];\n for (var k = 0; k < subs.length; k++) if (subs[k].name === seg[1]) return subs[k];\n }\n }\n return null;\n }\n function copyText(text) {\n function fallback() {\n var ta = document.createElement('textarea');\n ta.value = text;\n ta.style.position = 'fixed';\n ta.style.top = '-1000px';\n document.body.appendChild(ta);\n ta.select();\n var ok = false;\n try { ok = document.execCommand('copy'); } catch (e) {}\n ta.remove();\n return ok;\n }\n if (navigator.clipboard && navigator.clipboard.writeText) {\n return navigator.clipboard.writeText(text).then(function () { return true; }, function () { return fallback(); });\n }\n return Promise.resolve(fallback());\n }\n function copyBtn(text) {\n return '<button class=\"ibtn copy-btn\" data-copy=\"' + esc(text) + '\" title=\"复制命令\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V5a2 2 0 0 1 2-2h10\"/></svg></button>';\n }\n function codeLine(text) {\n return '<div class=\"code-line\"><code>' + esc(text) + '</code>' + copyBtn(text) + '</div>';\n }\n function optRows(options) {\n return (options || []).map(function (o) {\n return '<div class=\"opt-row\"><div class=\"opt-flag\">' + esc(o.flag + (o.arg ? ' ' + o.arg : '')) + '</div><div>' +\n '<div class=\"opt-what\">' + esc(o.what) + '</div>' +\n (o.when ? '<div class=\"opt-when\">' + esc(o.when) + '</div>' : '') +\n (o.caution ? '<div class=\"opt-risk\">' + esc(o.caution) + '</div>' : '') +\n '</div></div>';\n }).join('');\n }\n function renderQuick() {\n if (!state.help) return;\n var d = state.help;\n $('#cliQuick').innerHTML = (d.quick || []).map(function (g) {\n var items = (g.items || []).map(function (it) {\n var meta = findHelpEntry(d.groups, it.cmd) || {};\n return '<div class=\"cmd-item\"><div class=\"cmd-head\"><code class=\"cmd-name\">' + esc(it.cmd) + '</code>' +\n (it.danger ? '<span class=\"badge danger-badge\">危险</span>' : '') + '</div>' +\n '<div class=\"cmd-what\">' + esc(meta.what || '') + '</div>' +\n '<div class=\"cmd-when\">' + esc(meta.when || '') + '</div>' +\n codeLine(it.example) +\n (it.note ? '<div class=\"cmd-note\">' + esc(it.note) + '</div>' : '') + '</div>';\n }).join('');\n return '<div class=\"quick-group\"><h4>' + esc(g.group) + '</h4><div class=\"quick-list\">' + items + '</div></div>';\n }).join('');\n }\n function renderFull() {\n if (!state.help) return;\n $('#cliFull').innerHTML = (state.help.groups || []).map(function (g) {\n var cmds = (g.commands || []).map(function (c) {\n var subs = (c.subcommands || []).map(function (s) {\n return '<div class=\"help-sub\"><div class=\"cmd-head\"><code class=\"cmd-name\">' + esc(c.name + ' ' + s.name) + '</code></div>' +\n (s.what ? '<div class=\"cmd-what\">' + esc(s.what) + '</div>' : '') +\n (s.when ? '<div class=\"cmd-when\">' + esc(s.when) + '</div>' : '') +\n codeLine('yotta-memory ' + c.name + ' ' + s.usage) + optRows(s.options) + '</div>';\n }).join('');\n return '<details class=\"help-cmd\"><summary><code class=\"cmd-name\">' + esc(c.name) + '</code><span class=\"cmd-what\">' + esc(c.what) + '</span></summary>' +\n '<div class=\"help-body\">' + (c.when ? '<div class=\"cmd-when\">' + esc(c.when) + '</div>' : '') +\n codeLine('yotta-memory ' + c.usage) + optRows(c.options) + subs + '</div></details>';\n }).join('');\n return '<div class=\"quick-group\"><h4>' + esc(g.group) + '</h4>' + cmds + '</div>';\n }).join('');\n }\n async function ensureHelp() {\n if (state.help) { renderQuick(); return; }\n var d = await api('/api/help');\n if (!d || d.error) { toast((d && d.error) || '帮助加载失败'); return; }\n state.help = d;\n renderQuick();\n }\n $('#btnFullHelp').addEventListener('click', async function () {\n if (!state.help) await ensureHelp();\n if (!state.help) return;\n renderFull();\n $('#cliFull').hidden = false;\n $('#btnFullHelp').hidden = true;\n });\n document.addEventListener('click', function (e) {\n var b = e.target.closest('.copy-btn[data-copy]');\n if (!b) return;\n copyText(b.dataset.copy).then(function (ok) { toast(ok ? '已复制' : '复制失败,请手动选择'); });\n });\n\n /* unlock / boot */\n $('#unlockBtn').addEventListener('click', async function () {\n var r = await api('/api/unlock', { password: $('#pw').value });\n if (r.error) { $('#lockErr').textContent = r.error; return; }\n $('#lockErr').textContent = '';\n showApp();\n });\n $('#pw').addEventListener('keydown', function (e) { if (e.key === 'Enter') $('#unlockBtn').click(); });\n (async function boot() {\n var s = await api('/api/status');\n if (s && s.version) $('#ver').textContent = 'v' + s.version;\n if (s && s.unlocked) showApp(); else showLock();\n if (s && s.encrypted === false) $('#lockErr').textContent = '当前记忆库未启用加密;请先在 CLI 执行 init --encrypt / migrate。';\n })();\n})();\n</script>\n</body>\n</html>\n";
387
+ const VIEW_HTML = "<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"robots\" content=\"noindex,nofollow\">\n<link rel=\"icon\" href=\"data:,\">\n<title>元忆 · 记忆管理台</title>\n<style>\n :root{--bg:#0f0c12;--bg2:#141019;--panel:#191420;--panel2:#1f1828;--line:rgba(255,217,138,.12);--line2:rgba(255,217,138,.24);\n --text:#f4eee4;--muted:#a79c8e;--faint:#776c60;--amber:#f0a13a;--gold:#ffd98a;--ok:#7fc49a;--danger:#e07070;--shadow:0 10px 30px rgba(0,0,0,.35)}\n body.light{--bg:#f6f1e8;--bg2:#f1eade;--panel:#fffdf8;--panel2:#faf5ec;--line:rgba(56,42,28,.12);--line2:rgba(56,42,28,.24);\n --text:#241c14;--muted:#6f6458;--faint:#96897b;--amber:#c1781c;--gold:#9c6a16;--ok:#3e7d57;--danger:#b23c3c;--shadow:0 8px 24px rgba(56,42,28,.10)}\n *{box-sizing:border-box}[hidden]{display:none!important}\n html,body{margin:0;padding:0}\n body{background:var(--bg);color:var(--text);font:14px/1.6 \"Microsoft YaHei\",\"PingFang SC\",\"Noto Sans CJK SC\",system-ui,sans-serif;letter-spacing:0}\n button{font:inherit;color:inherit;cursor:pointer}\n input,select,textarea{font:inherit;color:var(--text);background:var(--panel2);border:1px solid var(--line2);border-radius:8px;padding:9px 12px;outline:0;width:100%}\n input:focus,select:focus,textarea:focus{border-color:var(--amber)}\n textarea{min-height:96px;resize:vertical}\n .muted{color:var(--muted)}.faint{color:var(--faint);font-size:12px}\n .sv{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;flex:none}\n\n /* lock */\n .lock-wrap{min-height:100vh;display:grid;place-items:center;padding:24px}\n .lock-card{width:min(420px,100%);background:var(--panel);border:1px solid var(--line2);border-radius:12px;padding:26px;box-shadow:var(--shadow)}\n .lock-card h1{margin:14px 0 6px;font-size:22px}\n .lock-card .field{margin-top:16px}\n .err{color:var(--danger);margin-top:10px;font-size:13px;min-height:20px}\n\n /* shell */\n .shell{display:grid;grid-template-columns:236px minmax(0,1fr);min-height:100vh}\n .sidebar{background:var(--bg2);border-right:1px solid var(--line);padding:22px 16px;display:flex;flex-direction:column;gap:22px;position:sticky;top:0;height:100vh;min-width:0}\n .brand{display:flex;align-items:center;gap:10px;padding:0 6px}\n .brand-mark{width:34px;height:34px;border-radius:8px;background:rgba(240,161,58,.14);border:1px solid var(--line2);display:grid;place-items:center;color:var(--gold)}\n .brand-name{font-size:16px;font-weight:600}.brand-sub{font-size:11px;color:var(--faint)}\n .nav{display:flex;flex-direction:column;gap:4px;min-width:0}\n .nav button{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:6px;border:1px solid transparent;background:transparent;color:var(--muted);text-align:left}\n .nav button:hover{background:var(--panel);color:var(--text)}\n .nav button.active{background:var(--panel);color:var(--gold);border-color:var(--line)}\n .side-foot{margin-top:auto;display:flex;flex-direction:column;gap:10px;min-width:0}\n .privacy{display:flex;gap:8px;align-items:flex-start;padding:10px;border:1px solid var(--line);border-radius:8px;color:var(--muted);font-size:12px;background:var(--panel)}\n .privacy .sv{color:var(--ok);margin-top:2px}\n .ghost-btn{display:flex;align-items:center;justify-content:center;gap:8px;border:1px solid var(--line);background:transparent;color:var(--muted);border-radius:6px;padding:8px}\n .ghost-btn:hover{color:var(--text);border-color:var(--line2)}\n\n .main{min-width:0;padding:26px 30px 60px}\n .topbar{display:flex;align-items:center;gap:14px;margin-bottom:22px;flex-wrap:wrap}\n .page-title{font-size:20px;font-weight:600;margin:0}.page-sub{color:var(--faint);font-size:12px;margin-top:2px}\n .spacer{flex:1}\n .search{display:flex;align-items:center;gap:8px;min-width:240px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:8px 12px;color:var(--muted)}\n .search input{background:transparent;border:0;padding:0;width:100%}\n .chip-user{display:flex;align-items:center;gap:8px;padding:6px 10px;border:1px solid var(--line);border-radius:999px;color:var(--muted);font-size:12px}\n .dot{width:8px;height:8px;border-radius:50%;background:var(--amber)}\n\n .view{display:none}.view.active{display:block}\n .grid{display:grid;gap:14px}.g4{grid-template-columns:repeat(4,minmax(0,1fr))}.g2{grid-template-columns:repeat(2,minmax(0,1fr))}\n .card{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:16px;min-width:0}\n .card h3{margin:0 0 4px;font-size:14px}\n .label{color:var(--muted);font-size:12px}.value{font-size:26px;font-weight:600;margin:8px 0 2px}.delta{font-size:12px;color:var(--ok)}\n .section{margin-top:20px}\n .shead{display:flex;align-items:baseline;gap:10px;margin-bottom:10px}.shead h2{font-size:15px;margin:0}\n .bars{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px}\n .bar{border:1px solid var(--line);border-radius:8px;padding:12px;background:var(--panel2)}\n .bar b{font-size:18px;display:block;margin-top:6px}\n .bar .dot{display:inline-block;margin-right:6px}\n\n .toolbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap}\n .chips{display:flex;gap:8px;flex-wrap:wrap}\n .chip{border:1px solid var(--line);background:transparent;color:var(--muted);border-radius:999px;padding:5px 12px;font-size:12px}\n .chip.active{color:var(--gold);border-color:var(--line2);background:rgba(240,161,58,.10)}\n .btn{display:inline-flex;align-items:center;gap:8px;padding:8px 14px;border-radius:6px;border:1px solid var(--line2);background:transparent;color:var(--text)}\n .btn:hover{border-color:var(--gold)}\n .btn.primary{background:var(--amber);border-color:var(--amber);color:#1b1206;font-weight:600}\n .btn.danger{border-color:rgba(224,112,112,.5);color:var(--danger)}\n .btn.small{padding:5px 10px;font-size:12px}\n .btn[disabled]{opacity:.5;cursor:not-allowed}\n\n .list{display:flex;flex-direction:column;gap:8px;margin-top:14px}\n .row{display:flex;align-items:center;gap:12px;padding:12px 14px;border:1px solid var(--line);border-radius:8px;background:var(--panel2)}\n .row:hover{border-color:var(--line2)}\n .row-main{flex:1;min-width:0;cursor:pointer}\n .row-title{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .row-snippet{color:var(--muted);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .row-meta{color:var(--faint);font-size:11px;margin-top:2px}\n .row-actions{display:flex;gap:6px}\n .ibtn{width:30px;height:30px;display:grid;place-items:center;border:1px solid var(--line);background:transparent;border-radius:6px;color:var(--muted)}\n .ibtn:hover{color:var(--text);border-color:var(--line2)}\n .badge{font-size:11px;padding:2px 8px;border-radius:999px;border:1px solid var(--line2);color:var(--gold);background:rgba(240,161,58,.10);white-space:nowrap}\n .badge.pref{color:#b79cdd;border-color:rgba(183,156,221,.4);background:rgba(183,156,221,.10)}\n .badge.bound{color:#8fb4de;border-color:rgba(143,180,222,.4);background:rgba(143,180,222,.10)}\n .badge.commit{color:var(--ok);border-color:rgba(127,196,154,.4);background:rgba(127,196,154,.10)}\n .badge.off{color:var(--faint);border-color:var(--line);background:transparent}\n .empty{color:var(--faint);text-align:center;padding:26px;border:1px dashed var(--line);border-radius:8px;margin-top:14px}\n .pager{display:flex;align-items:center;gap:10px;margin-top:12px;color:var(--faint);font-size:12px}\n\n table{width:100%;border-collapse:collapse}\n th,td{text-align:left;padding:12px 10px;border-bottom:1px solid var(--line);font-size:13px}\n th{color:var(--faint);font-weight:500;font-size:12px}\n .owner-name{font-weight:600}\n\n .field{margin-bottom:12px}\n .field label{display:block;color:var(--muted);font-size:12px;margin-bottom:6px}\n .row2{display:grid;grid-template-columns:1fr 1fr;gap:12px}\n .owner-pick{display:flex;flex-wrap:wrap;gap:6px;max-height:132px;overflow:auto;margin-bottom:8px}\n .owner-chip{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:transparent;color:var(--muted);border-radius:999px;padding:5px 10px;font-size:12px}\n .owner-chip:hover{color:var(--text);border-color:var(--line2)}\n .owner-chip.active{color:var(--gold);border-color:var(--line2);background:rgba(240,161,58,.12)}\n .owner-chip .tag{font-size:10px;color:var(--faint);border:1px solid var(--line);border-radius:999px;padding:0 6px}\n .owner-chip.active .tag{color:var(--gold);border-color:var(--line2)}\n\n .mask{position:fixed;inset:0;background:rgba(0,0,0,.5);display:none;place-items:center;z-index:50;padding:20px}\n .mask.open{display:grid}\n #drawerMask{z-index:40}\n .modal{width:min(560px,100%);max-height:86vh;overflow:auto;background:var(--panel);border:1px solid var(--line2);border-radius:10px;padding:18px;box-shadow:var(--shadow)}\n .modal h3{margin:0 0 10px;font-size:16px}\n .modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:14px}\n .drawer{position:fixed;top:0;right:0;bottom:0;width:min(440px,92vw);background:var(--panel);border-left:1px solid var(--line2);padding:20px;overflow:auto;transform:translateX(100%);transition:transform .2s;z-index:45}\n .drawer.open{transform:none}\n .kv{display:grid;grid-template-columns:86px 1fr;gap:6px 10px;font-size:13px;margin:14px 0}\n .kv dt{color:var(--faint)}.kv dd{margin:0;word-break:break-all}\n .quote{border-left:2px solid var(--line2);padding-left:12px;color:var(--muted);white-space:pre-wrap;word-break:break-word}\n .keybox{width:100%;min-height:64px;font-family:ui-monospace,Consolas,monospace;font-size:12px}\n\n .toasts{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);display:flex;flex-direction:column;gap:8px;align-items:center;z-index:60}\n .toast{display:flex;align-items:center;gap:10px;background:var(--panel2);border:1px solid var(--line2);border-radius:999px;padding:9px 16px;box-shadow:var(--shadow);font-size:13px}\n .toast button{border:0;background:transparent;color:var(--gold);font-weight:600}\n .list-plain{display:flex;flex-direction:column;gap:8px;max-height:46vh;overflow:auto}\n .list-plain .item{display:flex;align-items:center;gap:10px;border:1px solid var(--line);border-radius:8px;padding:9px 10px;font-size:12px;color:var(--muted)}\n .list-plain .item .spacer{flex:1}\n\n /* advanced cli */\n .quick-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}\n .cmd-item{border:1px solid var(--line);border-radius:8px;padding:12px 14px;background:var(--panel2);display:flex;flex-direction:column;gap:8px;min-width:0}\n .cmd-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}\n .cmd-name{font-family:ui-monospace,Consolas,monospace;font-size:12px;color:var(--gold)}\n .cmd-what{font-weight:600}\n .cmd-when{color:var(--muted);font-size:12px}\n .code-line{display:flex;align-items:center;gap:8px;background:rgba(0,0,0,.28);border:1px solid var(--line);border-radius:6px;padding:6px 10px;min-width:0}\n body.light .code-line{background:rgba(56,42,28,.06)}\n .code-line code{flex:1;min-width:0;font-family:ui-monospace,Consolas,monospace;font-size:12px;overflow-x:auto;white-space:nowrap}\n .danger-badge{color:var(--danger);border-color:rgba(224,112,112,.5);background:rgba(224,112,112,.10)}\n .cmd-note{color:var(--danger);font-size:12px}\n .quick-group{margin-top:14px}\n .quick-group h4{margin:0 0 8px;font-size:13px;color:var(--muted)}\n .help-cmd{border:1px solid var(--line);border-radius:8px;background:var(--panel2);margin-top:10px;min-width:0}\n .help-cmd summary{cursor:pointer;padding:10px 14px;display:flex;gap:8px;align-items:center;flex-wrap:wrap;list-style:none}\n .help-cmd summary::-webkit-details-marker{display:none}\n .help-cmd summary::before{content:'';width:6px;height:6px;border-right:1.6px solid var(--muted);border-bottom:1.6px solid var(--muted);transform:rotate(-45deg);transition:transform .15s;flex:none}\n .help-cmd[open] summary::before{transform:rotate(45deg)}\n .help-body{padding:12px 14px;display:flex;flex-direction:column;gap:8px;border-top:1px solid var(--line)}\n .help-sub{border-top:1px solid var(--line);padding-top:8px;margin-top:2px;display:flex;flex-direction:column;gap:6px}\n .help-sub .cmd-name{color:var(--text)}\n .opt-row{display:grid;grid-template-columns:minmax(150px,auto) minmax(0,1fr);gap:2px 12px;padding:7px 0;border-top:1px solid var(--line);font-size:12px}\n .opt-row:first-child{border-top:0}\n .opt-flag{font-family:ui-monospace,Consolas,monospace;word-break:break-all}\n .opt-what{color:var(--muted)}\n .opt-when{color:var(--faint)}\n .opt-risk{color:var(--danger)}\n\n @media (max-width:960px){.g4{grid-template-columns:repeat(2,minmax(0,1fr))}.bars{grid-template-columns:repeat(2,minmax(0,1fr))}.quick-list{grid-template-columns:minmax(0,1fr)}}\n @media (max-width:760px){\n .shell{grid-template-columns:minmax(0,1fr)}\n .sidebar{position:static;height:auto;padding:14px;gap:12px}\n .nav{flex-direction:row;overflow-x:auto;width:100%}\n .nav button{white-space:nowrap}\n .side-foot{margin-top:0;flex-direction:row}\n .privacy{flex:1}\n .main{padding:18px 16px 48px}\n .search{min-width:100%;order:3}\n .g4{grid-template-columns:repeat(2,minmax(0,1fr))}\n table{display:block;overflow-x:auto;white-space:nowrap}\n .row{flex-wrap:wrap}.row-actions{width:100%;justify-content:flex-end}\n }\n</style>\n</head>\n<body>\n<div id=\"lock\" class=\"lock-wrap\">\n <div class=\"lock-card\">\n <div class=\"brand\">\n <div class=\"brand-mark\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><ellipse cx=\"12\" cy=\"5\" rx=\"8\" ry=\"3\"/><path d=\"M4 5v14c0 1.7 3.6 3 8 3s8-1.3 8-3V5\"/><path d=\"M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3\"/></svg></div>\n <div><div class=\"brand-name\">元忆 yotta-memory</div><div class=\"brand-sub\">你的 AI 记忆,由你掌控</div></div>\n </div>\n <h1>解锁记忆管理台 <span id=\"ver\" class=\"faint\"></span></h1>\n <div class=\"field\"><label>主口令(只在本地内存派生,不落盘、不发远端)</label><input type=\"password\" id=\"pw\" placeholder=\"主口令\"></div>\n <button class=\"btn primary\" id=\"unlockBtn\" style=\"width:100%\">解锁</button>\n <div class=\"err\" id=\"lockErr\"></div>\n <div class=\"faint\" style=\"margin-top:8px\">忘记口令可用恢复钥匙在 CLI 重设:<code>yotta-memory reset-password --recovery-key &lt;钥匙&gt;</code></div>\n </div>\n</div>\n\n<div id=\"app\" class=\"shell\" hidden>\n <aside class=\"sidebar\">\n <div class=\"brand\">\n <div class=\"brand-mark\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><ellipse cx=\"12\" cy=\"5\" rx=\"8\" ry=\"3\"/><path d=\"M4 5v14c0 1.7 3.6 3 8 3s8-1.3 8-3V5\"/><path d=\"M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3\"/></svg></div>\n <div><div class=\"brand-name\">元忆</div><div class=\"brand-sub\">你的 AI 记忆,由你掌控</div></div>\n </div>\n <nav class=\"nav\" id=\"nav\">\n <button data-view=\"overview\" class=\"active\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M3 10.5 12 3l9 7.5\"/><path d=\"M5 9.5V21h14V9.5\"/></svg>概览</button>\n <button data-view=\"memories\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M4 19.5A2.5 2.5 0 0 1 6.5 17H20\"/><path d=\"M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z\"/></svg>记忆</button>\n <button data-view=\"access\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2\"/><circle cx=\"9\" cy=\"7\" r=\"4\"/><path d=\"M22 21v-2a4 4 0 0 0-3-3.87\"/></svg>AI 与权限</button>\n <button data-view=\"settings\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M4 21v-7\"/><path d=\"M4 10V3\"/><path d=\"M12 21v-9\"/><path d=\"M12 8V3\"/><path d=\"M20 21v-5\"/><path d=\"M20 12V3\"/><path d=\"M1 14h6\"/><path d=\"M9 8h6\"/><path d=\"M17 16h6\"/></svg>设置</button>\n <button data-view=\"cli\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"m4 17 6-6-6-6\"/><path d=\"M12 19h8\"/></svg>高级 / CLI</button>\n </nav>\n <div class=\"side-foot\">\n <div class=\"privacy\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z\"/><path d=\"m9 12 2 2 4-4\"/></svg><span>数据不出本机<br>私密区按 AI 隔离加密</span></div>\n <button class=\"ghost-btn\" id=\"themeBtn\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4\"/><path d=\"M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4\"/></svg>切换主题</button>\n </div>\n </aside>\n\n <main class=\"main\">\n <div class=\"topbar\">\n <div><h1 class=\"page-title\" id=\"pageTitle\">概览</h1><div class=\"page-sub\" id=\"pageSub\">你的 AI 记住了什么,一眼看清</div></div>\n <div class=\"spacer\"></div>\n <label class=\"search\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m20 20-3.5-3.5\"/></svg><input id=\"globalSearch\" placeholder=\"搜索记忆 / 关键词\"></label>\n <div class=\"chip-user\"><span class=\"dot\"></span>本机 · 用户</div>\n </div>\n\n <section class=\"view active\" id=\"view-overview\">\n <div class=\"grid g4\">\n <div class=\"card\"><div class=\"label\">记忆总数</div><div class=\"value\" id=\"ovTotal\">-</div><div class=\"delta\" id=\"ovFiles\">-</div></div>\n <div class=\"card\"><div class=\"label\">私密记忆</div><div class=\"value\" id=\"ovPrivate\">-</div><div class=\"delta\">仅对应 AI 与本人可见</div></div>\n <div class=\"card\"><div class=\"label\">AI 智能体</div><div class=\"value\" id=\"ovOwners\">-</div><div class=\"delta\" id=\"ovAuth\">-</div></div>\n <div class=\"card\"><div class=\"label\">已藏起来(归档)</div><div class=\"value\" id=\"ovArchived\">-</div><div class=\"delta\">可随时放回</div></div>\n </div>\n <div class=\"section\">\n <div class=\"shead\"><h2>四类记忆分布</h2><span class=\"faint\">FACT 共享 · PREF / BOUND / COMMIT 私密</span></div>\n <div class=\"bars\" id=\"typeBars\"></div>\n </div>\n <div class=\"section grid g2\">\n <div class=\"card\">\n <h3>最近更新</h3>\n <div class=\"list\" id=\"recentList\"></div>\n </div>\n <div class=\"card\">\n <h3>备份与安全</h3>\n <div class=\"kv\" style=\"margin-top:10px\">\n <dt>备份目录</dt><dd id=\"ovBackup\">-</dd>\n <dt>存储占用</dt><dd id=\"ovBytes\">-</dd>\n <dt>私密加密</dt><dd>已启用(AES-256-GCM 信封加密)</dd>\n </div>\n <div style=\"display:flex;gap:8px;flex-wrap:wrap\">\n <button class=\"btn small\" id=\"quickArchived\">查看已藏</button>\n <button class=\"btn small\" id=\"quickTrash\">最近删除</button>\n <button class=\"btn small primary\" data-goto=\"memories\">管理记忆</button>\n </div>\n </div>\n </div>\n </section>\n\n <section class=\"view\" id=\"view-memories\">\n <div class=\"card toolbar\">\n <label class=\"search\" style=\"min-width:280px;flex:1\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m20 20-3.5-3.5\"/></svg><input id=\"memSearch\" placeholder=\"搜索记忆(标题 / 内容 / 标签)\"></label>\n <div class=\"chips\" id=\"typeChips\">\n <button class=\"chip active\" data-type=\"all\">全部</button>\n <button class=\"chip\" data-type=\"FACT\">FACT</button>\n <button class=\"chip\" data-type=\"PREF\">PREF</button>\n <button class=\"chip\" data-type=\"BOUND\">BOUND</button>\n <button class=\"chip\" data-type=\"COMMIT\">COMMIT</button>\n </div>\n <div class=\"spacer\"></div>\n <button class=\"btn\" id=\"btnArchived\">已藏</button>\n <button class=\"btn\" id=\"btnTrash\">最近删除</button>\n <button class=\"btn primary\" id=\"btnNew\">新建记忆</button>\n </div>\n <div class=\"list\" id=\"memList\"></div>\n <div class=\"empty\" id=\"memEmpty\" hidden>没有匹配的记忆。</div>\n <div class=\"pager\"><button class=\"btn small\" id=\"prevPage\">上一页</button><span id=\"pageInfo\">-</span><button class=\"btn small\" id=\"nextPage\">下一页</button></div>\n </section>\n\n <section class=\"view\" id=\"view-access\">\n <div class=\"card\">\n <h3>谁看得见什么</h3>\n <div class=\"faint\" style=\"margin-bottom:10px\">默认拒绝:每个 AI 只能读自己的私密区;公共 FACT 所有已授权 AI 可见。授权会生成只显示一次的 agent_key,请立即单独保存。</div>\n <table><thead><tr><th>AI 智能体</th><th>FACT 公共</th><th>私密记忆</th><th>状态</th><th></th></tr></thead><tbody id=\"ownerRows\"></tbody></table>\n </div>\n <div class=\"section card\">\n <h3>恢复与备份</h3>\n <div class=\"kv\"><dt>备份目录</dt><dd id=\"accBackup\">-</dd><dt>恢复钥匙</dt><dd>离线保存;泄露等同口令泄露</dd></div>\n <div style=\"display:flex;gap:8px;flex-wrap:wrap\"><button class=\"btn small\" id=\"btnRk\">显示恢复钥匙</button><button class=\"btn small\" id=\"btnExport\">导出全部 JSON</button></div>\n </div>\n </section>\n\n <section class=\"view\" id=\"view-settings\">\n <div class=\"card\">\n <h3>重设主口令</h3>\n <div class=\"row2\" style=\"margin-top:10px\">\n <div class=\"field\"><label>当前口令</label><input type=\"password\" id=\"pwCur\"></div>\n <div class=\"field\"><label>新口令</label><input type=\"password\" id=\"pwNew\"></div>\n <div class=\"field\"><label>确认新口令</label><input type=\"password\" id=\"pwNew2\"></div>\n </div>\n <button class=\"btn primary\" id=\"btnResetPw\">重设口令</button>\n <div class=\"err\" id=\"pwOut\"></div>\n </div>\n <div class=\"section card\">\n <h3>危险操作</h3>\n <div class=\"faint\">删除某个 AI 的身份与其私密记忆(公共 FACT 保留,其它 AI 不受影响)。此操作不可恢复,请谨慎使用。</div>\n <div style=\"margin-top:10px\"><button class=\"btn danger small\" id=\"btnDangerHint\">在哪里执行?</button></div>\n </div>\n </section>\n\n <section class=\"view\" id=\"view-cli\">\n <div class=\"card\">\n <h3>常用命令速查</h3>\n <div class=\"faint\" style=\"margin-bottom:12px\">只展示与复制;命令都在终端执行,危险操作已标注。</div>\n <div id=\"cliQuick\"><div class=\"faint\">加载中…</div></div>\n </div>\n <div class=\"section card\">\n <h3>完整命令与参数</h3>\n <div class=\"faint\" style=\"margin-bottom:12px\">与当前安装版本一致(随版本自动更新);按需加载,只读展示,不执行任何命令。</div>\n <button class=\"btn\" id=\"btnFullHelp\">加载完整命令与参数</button>\n <div id=\"cliFull\" hidden></div>\n </div>\n </section>\n </main>\n</div>\n\n<div class=\"mask\" id=\"modalMask\"><div class=\"modal\"><h3 id=\"modalTitle\"></h3><div id=\"modalBody\"></div><div class=\"modal-actions\"><button class=\"btn\" id=\"modalCancel\">取消</button><button class=\"btn primary\" id=\"modalOk\">确认</button></div></div></div>\n<div class=\"mask\" id=\"drawerMask\"></div>\n<aside class=\"drawer\" id=\"drawer\">\n <div style=\"display:flex;gap:10px;align-items:flex-start\"><div style=\"flex:1\"><h3 id=\"drTitle\"></h3><div class=\"faint\" id=\"drMeta\"></div></div><button class=\"ibtn\" id=\"drClose\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M18 6 6 18\"/><path d=\"m6 6 12 12\"/></svg></button></div>\n <div class=\"quote\" id=\"drBody\" style=\"margin-top:12px\"></div>\n <dl class=\"kv\" id=\"drKv\"></dl>\n <div style=\"display:flex;gap:8px\"><button class=\"btn\" id=\"drEdit\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M12 20h9\"/><path d=\"M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z\"/></svg>编辑</button><button class=\"btn danger\" id=\"drDelete\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2\"/><path d=\"M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6\"/></svg>删除</button></div>\n</aside>\n<div class=\"toasts\" id=\"toasts\"></div>\n\n<script>\n(function () {\n 'use strict';\n var $ = function (s) { return document.querySelector(s); };\n var state = { view: 'overview', entries: [], count: 0, offset: 0, limit: 20, query: '', type: 'all', owners: [], overview: null, current: null, help: null };\n var typeNames = { FACT: 'FACT 事实', PREF: 'PREF 偏好', BOUND: 'BOUND 边界', COMMIT: 'COMMIT 承诺' };\n\n function esc(s) {\n return String(s == null ? '' : s).replace(/[&<>\"']/g, function (c) {\n return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c];\n });\n }\n async function api(pathname, body) {\n try {\n var res = await fetch(pathname, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) });\n var text = await res.text();\n var data;\n try { data = JSON.parse(text); } catch (e) { data = { error: text }; }\n if (res.status === 401) showLock();\n return data;\n } catch (e) { return { error: String(e) }; }\n }\n function errText(r, fallback) {\n return r && r.error ? ((r.text || r.message || fallback) || '操作失败') : '';\n }\n function fmtBytes(n) {\n n = Number(n) || 0;\n if (n < 1024) return n + ' B';\n if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';\n return (n / 1024 / 1024).toFixed(2) + ' MB';\n }\n function pad2(n) { return (n < 10 ? '0' : '') + n; }\n function fmtTime(e) {\n if (e && e.mtime) {\n var d = new Date(e.mtime);\n if (!isNaN(d.getTime())) {\n return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()) + ' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());\n }\n }\n return (e && (e.updated || e.created)) || '';\n }\n function toast(msg, actionLabel, fn) {\n var el = document.createElement('div');\n el.className = 'toast';\n el.appendChild(document.createTextNode(msg));\n if (actionLabel && fn) {\n var b = document.createElement('button');\n b.textContent = actionLabel;\n b.addEventListener('click', function () { el.remove(); fn(); });\n el.appendChild(b);\n }\n $('#toasts').appendChild(el);\n setTimeout(function () { el.remove(); }, actionLabel ? 9000 : 3600);\n }\n function showLock() { $('#lock').hidden = false; $('#app').hidden = true; }\n function showApp() { $('#lock').hidden = true; $('#app').hidden = false; refreshAll(); }\n\n /* modal / drawer */\n var modalOkFn = null;\n function openModal(title, bodyHtml, opts) {\n opts = opts || {};\n $('#modalTitle').textContent = title;\n $('#modalBody').innerHTML = bodyHtml;\n $('#modalOk').textContent = opts.okText || '确认';\n $('#modalOk').className = 'btn ' + (opts.danger ? 'danger' : 'primary');\n $('#modalOk').hidden = !opts.onOk;\n modalOkFn = opts.onOk || null;\n $('#modalMask').classList.add('open');\n }\n function closeModal() { $('#modalMask').classList.remove('open'); modalOkFn = null; }\n function confirmModal(title, text, okText, danger, onOk) {\n openModal(title, '<p class=\"muted\">' + esc(text) + '</p>', { okText: okText, danger: danger, onOk: onOk });\n }\n $('#modalCancel').addEventListener('click', closeModal);\n $('#modalMask').addEventListener('click', function (e) { if (e.target === $('#modalMask')) closeModal(); });\n $('#modalOk').addEventListener('click', function () { if (modalOkFn) modalOkFn(); });\n function openDrawer() { $('#drawer').classList.add('open'); $('#drawerMask').classList.add('open'); }\n function closeDrawer() { $('#drawer').classList.remove('open'); $('#drawerMask').classList.remove('open'); }\n $('#drClose').addEventListener('click', closeDrawer);\n $('#drawerMask').addEventListener('click', closeDrawer);\n\n /* nav */\n var titles = { overview: ['概览', '你的 AI 记住了什么,一眼看清'], memories: ['记忆', '看 / 改 / 删 / 藏,全在这里'], access: ['AI 与权限', '默认拒绝,按 AI 隔离'], settings: ['设置', '口令 / 备份 / 导出'], cli: ['高级 / CLI', '常用命令速查与完整帮助(只展示与复制)'] };\n function switchView(name) {\n state.view = name;\n Array.prototype.forEach.call(document.querySelectorAll('#nav button'), function (b) { b.classList.toggle('active', b.dataset.view === name); });\n Array.prototype.forEach.call(document.querySelectorAll('.view'), function (v) { v.classList.toggle('active', v.id === 'view-' + name); });\n $('#pageTitle').textContent = titles[name][0];\n $('#pageSub').textContent = titles[name][1];\n window.scrollTo(0, 0);\n if (name === 'cli') ensureHelp();\n }\n $('#nav').addEventListener('click', function (e) { var b = e.target.closest('button'); if (b) switchView(b.dataset.view); });\n Array.prototype.forEach.call(document.querySelectorAll('[data-goto]'), function (b) { b.addEventListener('click', function () { switchView(b.dataset.goto); }); });\n $('#themeBtn').addEventListener('click', function () { document.body.classList.toggle('light'); });\n\n /* data loaders */\n async function loadOverview() {\n var d = await api('/api/overview');\n if (d.error) return;\n state.overview = d;\n var priv = d.total - (d.byType.FACT || 0);\n $('#ovTotal').textContent = d.total;\n $('#ovFiles').textContent = d.files + ' 个文件';\n $('#ovPrivate').textContent = priv;\n $('#ovOwners').textContent = d.owners.length;\n $('#ovAuth').textContent = d.authorized + ' 已授权';\n $('#ovArchived').textContent = d.archived;\n $('#ovBackup').textContent = d.backupDir || '未配置(CLI: yotta-memory backup setup)';\n $('#ovBytes').textContent = fmtBytes(d.bytes);\n $('#accBackup').textContent = d.backupDir || '未配置';\n var colors = { FACT: 'var(--gold)', PREF: '#b79cdd', BOUND: '#8fb4de', COMMIT: 'var(--ok)' };\n $('#typeBars').innerHTML = ['FACT', 'PREF', 'BOUND', 'COMMIT'].map(function (t) {\n var n = d.byType[t] || 0;\n return '<div class=\"bar\"><span class=\"dot\" style=\"background:' + colors[t] + '\"></span><span class=\"faint\">' + typeNames[t] + '</span><b>' + n + '</b></div>';\n }).join('');\n }\n async function loadOwners() {\n var d = await api('/api/owners');\n state.owners = (d && d.owners) || [];\n var box = $('#ownerRows');\n if (!state.owners.length) { box.innerHTML = '<tr><td colspan=\"5\" class=\"faint\">' + esc((d && d.hint) || '当前没有 owner,请先在 CLI 执行 yotta-memory iam <id>。') + '</td></tr>'; return; }\n box.innerHTML = state.owners.map(function (o) {\n return '<tr><td><span class=\"owner-name\">' + esc(o.owner) + '</span><div class=\"faint\">' + (o.registered ? '已登记' : '未登记') + '</div></td>' +\n '<td><span class=\"badge commit\">可见</span></td>' +\n '<td>' + (o.authorized ? '<span class=\"badge commit\">已授权</span>' : '<span class=\"badge off\">仅自己</span>') + '</td>' +\n '<td>' + (o.authorized ? '可读自己的私密区' : '未绑定 agent_key') + '</td>' +\n '<td><button class=\"btn small\" data-auth=\"' + esc(o.owner) + '\">' + (o.authorized ? '吊销' : '授权') + '</button> <button class=\"btn small danger\" data-remove=\"' + esc(o.owner) + '\">删除</button></td></tr>';\n }).join('');\n }\n async function loadEntries() {\n var d = await api('/api/entries', { query: state.query, offset: state.offset, limit: state.limit, type: state.type === 'all' ? '' : state.type });\n if (d.error) { toast(errText(d, '加载失败')); return; }\n state.entries = d.entries || [];\n state.count = d.count || 0;\n var totalPg = Math.max(1, Math.ceil(state.count / state.limit));\n var curPg = Math.floor(state.offset / state.limit) + 1;\n $('#pageInfo').textContent = '共 ' + state.count + ' 条 · 第 ' + curPg + ' / ' + totalPg + ' 页';\n $('#prevPage').disabled = state.offset <= 0;\n $('#nextPage').disabled = !d.hasMore;\n renderEntries();\n }\n function renderEntries() {\n var box = $('#memList');\n $('#memEmpty').hidden = state.entries.length > 0;\n box.innerHTML = state.entries.map(function (e) {\n var t = String(e.type || 'FACT').toUpperCase();\n return '<div class=\"row\" data-file=\"' + esc(e.file) + '\"><div class=\"row-main\"><span class=\"badge ' + t.toLowerCase() + '\">' + typeNames[t] + '</span>' +\n '<div class=\"row-title\">' + esc(e.subject) + '</div><div class=\"row-snippet\">' + esc(e.statement) + '</div>' +\n '<div class=\"row-meta\">' + esc(e.owner || '公共') + ' · ' + esc(fmtTime(e)) + ' · ' + esc(e.file) + '</div></div>' +\n '<div class=\"row-actions\"><button class=\"ibtn\" data-act=\"edit\" title=\"编辑\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M12 20h9\"/><path d=\"M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z\"/></svg></button>' +\n '<button class=\"ibtn\" data-act=\"archive\" title=\"藏起来(归档)\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M9.9 4.24A9.1 9.1 0 0 1 12 4c7 0 10 8 10 8a17.5 17.5 0 0 1-2.16 3.19\"/><path d=\"M6.6 6.6A16.8 16.8 0 0 0 2 12s3 8 10 8a9.6 9.6 0 0 0 5.4-1.6\"/><path d=\"m2 2 20 20\"/></svg></button>' +\n '<button class=\"ibtn\" data-act=\"delete\" title=\"删除\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2\"/><path d=\"M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6\"/></svg></button></div></div>';\n }).join('');\n }\n async function refreshAll() {\n await loadOverview();\n await loadOwners();\n await loadEntries();\n var recent = state.entries.slice(0, 4).map(function (e) {\n var t = String(e.type || 'FACT').toUpperCase();\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(e.subject) + '</div><div class=\"row-meta\">' + esc(e.owner || '公共') + ' · ' + esc(fmtTime(e)) + '</div></div><span class=\"badge ' + t.toLowerCase() + '\">' + typeNames[t] + '</span></div>';\n }).join('');\n $('#recentList').innerHTML = recent || '<div class=\"faint\">还没有记忆。</div>';\n }\n\n /* entries actions */\n $('#memList').addEventListener('click', async function (e) {\n var row = e.target.closest('.row');\n if (!row) return;\n var file = row.dataset.file;\n var entry = state.entries.filter(function (x) { return x.file === file; })[0];\n var act = e.target.closest('button[data-act]');\n if (!act) { openDetail(entry); return; }\n if (act.dataset.act === 'edit') openEditor(entry);\n else if (act.dataset.act === 'delete') doDelete(entry);\n else if (act.dataset.act === 'archive') doArchive(entry);\n });\n function openDetail(entry) {\n if (!entry) return;\n state.current = entry;\n var t = String(entry.type || 'FACT').toUpperCase();\n $('#drTitle').textContent = entry.subject || '(无标题)';\n $('#drMeta').textContent = typeNames[t] + ' · ' + (entry.owner || '公共') + ' · ' + fmtTime(entry);\n $('#drBody').textContent = entry.statement || '';\n $('#drKv').innerHTML = '<dt>创建</dt><dd>' + esc(entry.btime ? fmtTime({ mtime: entry.btime }) : (entry.created || '-')) + '</dd>' +\n '<dt>最近更新</dt><dd>' + esc(fmtTime(entry) || '-') + '</dd>' +\n '<dt>文件</dt><dd>' + esc(entry.file) + '</dd><dt>可见性</dt><dd>' + (t === 'FACT' ? '所有已授权 AI' : '仅 ' + esc(entry.owner) + '(私密)') + '</dd><dt>标签</dt><dd>' + esc((entry.tags || []).join(', ') || '-') + '</dd>';\n openDrawer();\n }\n $('#drEdit').addEventListener('click', function () { if (state.current) { closeDrawer(); openEditor(state.current); } });\n $('#drDelete').addEventListener('click', function () { if (state.current) { closeDrawer(); doDelete(state.current); } });\n\n function editorHtml(entry, isNew) {\n var t = entry ? String(entry.type || 'FACT').toUpperCase() : 'FACT';\n var ownerLocked = !isNew && t !== 'FACT';\n return '<div class=\"field\"><label>类型</label><select id=\"fType\"' + (isNew ? '' : ' disabled') + '>' +\n ['FACT', 'PREF', 'BOUND', 'COMMIT'].map(function (x) { return '<option value=\"' + x + '\"' + (x === t ? ' selected' : '') + '>' + typeNames[x] + '</option>'; }).join('') + '</select></div>' +\n '<div class=\"field\" id=\"fOwnerWrap\"><label>' + (t === 'FACT' && !isNew ? '归属 AI(FACT 为公共记忆,归属仅作来源标记,可修改)' : '归属 AI(私密记忆必填;点选或输入自定义 ID)') + '</label>' +\n '<div class=\"owner-pick\" id=\"fOwnerPick\">' + (state.owners.length ? state.owners.map(function (o) {\n return '<button type=\"button\" class=\"owner-chip' + ((entry && entry.owner === o.owner) ? ' active' : '') + '\"' + (ownerLocked ? ' disabled' : '') + ' data-owner=\"' + esc(o.owner) + '\">' + esc(o.owner) + '<span class=\"tag\">' + (o.authorized ? '已授权' : '未授权') + '</span></button>';\n }).join('') : '<span class=\"faint\">还没有已登记的 AI;可直接输入自定义 ID。</span>') + '</div>' +\n '<input id=\"fOwner\" placeholder=\"自定义 AI ID(如 codex)\" value=\"' + esc(entry && entry.owner || '') + '\"' + (ownerLocked ? ' disabled' : '') + '></div>' +\n '<div class=\"field\"><label>标题 subject</label><input id=\"fSubject\" value=\"' + esc(entry && entry.subject || '') + '\"></div>' +\n '<div class=\"field\"><label>内容 statement</label><textarea id=\"fStatement\">' + esc(entry && entry.statement || '') + '</textarea></div>' +\n '<div class=\"field\"><label>标签(逗号分隔)</label><input id=\"fTags\" value=\"' + esc(entry && (entry.tags || []).join(', ') || '') + '\"></div>' +\n (isNew ? '' : (t === 'FACT'\n ? '<div class=\"faint\">类型不可修改;FACT 为公共记忆,归属仅作来源标记,可修改。</div>'\n : '<div class=\"faint\">类型与归属不可修改;私密记忆如需变更归属请删除后重写。</div>'));\n }\n function syncOwnerField() {\n var t = $('#fType') ? $('#fType').value : 'FACT';\n var wrap = $('#fOwnerWrap');\n if (wrap) wrap.hidden = (t === 'FACT');\n }\n function bindOwnerPicker() {\n var pick = $('#fOwnerPick');\n if (!pick || ($('#fOwner') && $('#fOwner').disabled)) return;\n pick.addEventListener('click', function (e) {\n var chip = e.target.closest('.owner-chip');\n if (!chip) return;\n Array.prototype.forEach.call(pick.querySelectorAll('.owner-chip'), function (c) { c.classList.toggle('active', c === chip); });\n $('#fOwner').value = chip.dataset.owner;\n });\n $('#fOwner').addEventListener('input', function () {\n var v = $('#fOwner').value.trim();\n Array.prototype.forEach.call(pick.querySelectorAll('.owner-chip'), function (c) { c.classList.toggle('active', c.dataset.owner === v); });\n });\n }\n function openEditor(entry) {\n openModal('编辑记忆', editorHtml(entry, false), {\n okText: '保存', onOk: async function () {\n var subject = $('#fSubject').value.trim();\n var statement = $('#fStatement').value.trim();\n var tags = $('#fTags').value.split(',').map(function (x) { return x.trim(); }).filter(Boolean);\n if (!subject || !statement) { toast('标题与内容不能为空'); return; }\n var body = { file: entry.file, subject: subject, statement: statement, tags: tags };\n var type = String(entry.type || 'FACT').toUpperCase();\n if (type === 'FACT' && $('#fOwner') && !$('#fOwner').disabled) body.owner = $('#fOwner').value.trim();\n var r = await api('/api/memory/update', body);\n if (r.error) { toast(errText(r, '保存失败')); return; }\n closeModal();\n toast('已更新 1 条记忆' + (r.ownerChange ? '(归属已改为 ' + (r.ownerChange.to || '公共') + ')' : ''), '撤销', async function () {\n var u = await api('/api/memory/restore', { ref: r.undo });\n toast(errText(u, '撤销失败') || '已撤销这次编辑');\n refreshAll();\n });\n refreshAll();\n },\n });\n bindOwnerPicker();\n }\n $('#btnNew').addEventListener('click', function () {\n openModal('新建记忆', editorHtml(null, true), {\n okText: '创建', onOk: async function () {\n var type = $('#fType').value;\n var owner = $('#fOwner') ? $('#fOwner').value.trim() : '';\n var subject = $('#fSubject').value.trim();\n var statement = $('#fStatement').value.trim();\n var tags = $('#fTags').value.split(',').map(function (x) { return x.trim(); }).filter(Boolean);\n if (!subject || !statement) { toast('标题与内容不能为空'); return; }\n var r = await api('/api/memory/create', { type: type, owner: owner, subject: subject, statement: statement, tags: tags });\n if (r.error) { toast(errText(r, '创建失败')); return; }\n closeModal();\n toast('已创建 1 条记忆');\n refreshAll();\n },\n });\n bindOwnerPicker();\n syncOwnerField();\n $('#fType').addEventListener('change', syncOwnerField);\n });\n function doDelete(entry) {\n confirmModal('删除这条记忆?', '将移入回收区(不物理删除),可从「最近删除」恢复。', '删除', true, async function () {\n var r = await api('/api/memory/delete', { file: entry.file });\n if (r.error) { toast(errText(r, '删除失败')); return; }\n closeModal();\n toast('已删除 1 条记忆', '撤销', async function () {\n var u = await api('/api/memory/restore', { ref: r.trash });\n toast(errText(u, '恢复失败') || '已恢复');\n refreshAll();\n });\n refreshAll();\n });\n }\n function doArchive(entry) {\n confirmModal('藏起来(归档)?', '归档后不参与召回,但数据保留,可随时放回。', '藏起来', false, async function () {\n var r = await api('/api/memory/archive', { file: entry.file });\n if (r.error) { toast(errText(r, '归档失败')); return; }\n closeModal();\n toast('已藏起来 1 条', '放回', async function () {\n var u = await api('/api/memory/unarchive', { archive: r.archive });\n toast(errText(u, '放回失败') || '已放回');\n refreshAll();\n });\n refreshAll();\n });\n }\n\n async function openArchived() {\n var d = await api('/api/archived');\n var items = (d.entries || []).map(function (e) {\n return '<div class=\"item\"><span>' + esc(e.original) + '</span><span class=\"spacer\"></span><button class=\"btn small\" data-unarch=\"' + esc(e.ref) + '\">放回</button></div>';\n }).join('');\n openModal('已藏起来(归档)', items ? '<div class=\"list-plain\">' + items + '</div>' : '<p class=\"faint\">没有归档的记忆。</p>', {});\n $('#modalBody').addEventListener('click', async function (e) {\n var b = e.target.closest('button[data-unarch]');\n if (!b) return;\n var r = await api('/api/memory/unarchive', { archive: b.dataset.unarch });\n toast(errText(r, '放回失败') || '已放回');\n closeModal();\n refreshAll();\n }, { once: true });\n }\n async function openTrash() {\n var d = await api('/api/trash');\n var items = (d.entries || []).filter(function (e) { return e.kind === 'trash'; }).map(function (e) {\n return '<div class=\"item\"><span>' + esc(e.original) + '</span><span class=\"spacer\"></span><button class=\"btn small\" data-restore=\"' + esc(e.ref) + '\">恢复</button></div>';\n }).join('');\n openModal('最近删除', items ? '<div class=\"list-plain\">' + items + '</div>' : '<p class=\"faint\">回收区是空的。</p>', {});\n $('#modalBody').addEventListener('click', async function (e) {\n var b = e.target.closest('button[data-restore]');\n if (!b) return;\n var r = await api('/api/memory/restore', { ref: b.dataset.restore });\n toast(errText(r, '恢复失败') || '已恢复');\n closeModal();\n refreshAll();\n }, { once: true });\n }\n $('#btnArchived').addEventListener('click', openArchived);\n $('#quickArchived').addEventListener('click', openArchived);\n $('#btnTrash').addEventListener('click', openTrash);\n $('#quickTrash').addEventListener('click', openTrash);\n\n /* search / filter / paging */\n $('#memSearch').addEventListener('keydown', function (e) { if (e.key === 'Enter') { state.query = e.target.value; state.offset = 0; loadEntries(); } });\n $('#globalSearch').addEventListener('keydown', function (e) { if (e.key === 'Enter') { state.query = e.target.value; state.offset = 0; switchView('memories'); loadEntries(); } });\n $('#typeChips').addEventListener('click', function (e) {\n var b = e.target.closest('.chip');\n if (!b) return;\n state.type = b.dataset.type; state.offset = 0;\n Array.prototype.forEach.call(document.querySelectorAll('#typeChips .chip'), function (c) { c.classList.toggle('active', c === b); });\n loadEntries();\n });\n $('#prevPage').addEventListener('click', function () { if (state.offset > 0) { state.offset -= state.limit; loadEntries(); } });\n $('#nextPage').addEventListener('click', function () { state.offset += state.limit; loadEntries(); });\n\n /* owners */\n $('#ownerRows').addEventListener('click', async function (e) {\n var authBtn = e.target.closest('button[data-auth]');\n var rmBtn = e.target.closest('button[data-remove]');\n if (authBtn) {\n var owner = authBtn.dataset.auth;\n var authorized = state.owners.filter(function (o) { return o.owner === owner; })[0].authorized;\n if (authorized) {\n confirmModal('吊销 ' + owner + ' 的授权?', '吊销后该智能体立即失去私密读写能力,旧 key 会立即校验失败。', '吊销', true, async function () {\n var r = await api('/api/revoke', { owner: owner });\n closeModal(); toast(errText(r, '吊销失败') || '已吊销'); loadOwners();\n });\n } else {\n confirmModal('授权 ' + owner + '?', '「授权」由你(用户)操作:确认后生成只显示一次的 agent_key,请立即单独保存;服务端同时写临时待领取文件 keys/pending/<agent_id>.key,供该 AI 新会话执行 key claim 领取,领取成功后自动删除。AI 不应代为执行该授权操作。', '授权', false, async function () {\n var r = await api('/api/authorize', { owner: owner });\n closeModal();\n if (r.error) { toast(errText(r, '授权失败')); return; }\n openModal('agent_key(只显示一次)', '<p class=\"muted\">请立即单独保存;AI 新会话执行 key status → key claim 领取。</p><textarea class=\"keybox\" readonly>' + esc(r.agentKey) + '</textarea>', { okText: '我已保存', onOk: closeModal });\n var box = $('#modalBody').querySelector('textarea');\n if (box) { box.focus(); box.select(); }\n loadOwners();\n });\n }\n return;\n }\n if (rmBtn) {\n var target = rmBtn.dataset.remove;\n openModal('删除 ' + target + ' 的身份与私密记忆', '<p class=\"muted\">不可恢复;公共 FACT 保留,其它 AI 不受影响。请输入完整 ID 确认:</p><input id=\"confirmOwner\" placeholder=\"' + esc(target) + '\">', {\n okText: '删除', danger: true, onOk: async function () {\n var typed = $('#confirmOwner').value.trim();\n if (typed !== target) { toast('ID 不匹配'); return; }\n var r = await api('/api/identity-remove', { owner: target, confirm: typed });\n closeModal(); toast(errText(r, '删除失败') || '已删除'); loadOwners(); loadOverview(); loadEntries();\n },\n });\n }\n });\n\n /* settings */\n $('#btnResetPw').addEventListener('click', async function () {\n var a = $('#pwCur').value, b = $('#pwNew').value, c = $('#pwNew2').value;\n if (!b || b !== c) { $('#pwOut').textContent = '两次新口令不一致'; return; }\n var r = await api('/api/reset-password', { currentPassword: a, newPassword: b });\n $('#pwOut').textContent = r.error ? (r.text || '重设失败') : (r.text || '已重设');\n if (!r.error) { $('#pwCur').value = $('#pwNew').value = $('#pwNew2').value = ''; }\n });\n $('#btnRk').addEventListener('click', async function () {\n var r = await api('/api/recovery-key');\n if (r.error) { toast(errText(r, '恢复钥匙读取失败')); return; }\n openModal('恢复钥匙(离线保存)', '<p class=\"muted\">泄露等同口令泄露。忘记主口令时可用它重设。</p><textarea class=\"keybox\" readonly>' + esc(r.recoveryKey) + '</textarea>', { okText: '我已保存', onOk: closeModal });\n });\n $('#btnExport').addEventListener('click', async function () {\n var r = await api('/api/export');\n if (r.error) { toast(errText(r, '导出失败')); return; }\n var blob = new Blob([JSON.stringify(r, null, 2)], { type: 'application/json' });\n var a = document.createElement('a');\n a.href = URL.createObjectURL(blob);\n a.download = 'yottamemory-export.json';\n a.click();\n toast('已导出 JSON');\n });\n $('#btnDangerHint').addEventListener('click', function () {\n toast('在「AI 与权限」列表里,每个 AI 右侧有「删除」按钮');\n });\n\n /* advanced cli */\n function findHelpEntry(groups, ref) {\n var seg = String(ref).split(' ');\n for (var i = 0; i < groups.length; i++) {\n var cmds = groups[i].commands || [];\n for (var j = 0; j < cmds.length; j++) {\n var c = cmds[j];\n if (c.name !== seg[0]) continue;\n if (seg.length === 1) return c;\n var subs = c.subcommands || [];\n for (var k = 0; k < subs.length; k++) if (subs[k].name === seg[1]) return subs[k];\n }\n }\n return null;\n }\n function copyText(text) {\n function fallback() {\n var ta = document.createElement('textarea');\n ta.value = text;\n ta.style.position = 'fixed';\n ta.style.top = '-1000px';\n document.body.appendChild(ta);\n ta.select();\n var ok = false;\n try { ok = document.execCommand('copy'); } catch (e) {}\n ta.remove();\n return ok;\n }\n if (navigator.clipboard && navigator.clipboard.writeText) {\n return navigator.clipboard.writeText(text).then(function () { return true; }, function () { return fallback(); });\n }\n return Promise.resolve(fallback());\n }\n function copyBtn(text) {\n return '<button class=\"ibtn copy-btn\" data-copy=\"' + esc(text) + '\" title=\"复制命令\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V5a2 2 0 0 1 2-2h10\"/></svg></button>';\n }\n function codeLine(text) {\n return '<div class=\"code-line\"><code>' + esc(text) + '</code>' + copyBtn(text) + '</div>';\n }\n function optRows(options) {\n return (options || []).map(function (o) {\n return '<div class=\"opt-row\"><div class=\"opt-flag\">' + esc(o.flag + (o.arg ? ' ' + o.arg : '')) + '</div><div>' +\n '<div class=\"opt-what\">' + esc(o.what) + '</div>' +\n (o.when ? '<div class=\"opt-when\">' + esc(o.when) + '</div>' : '') +\n (o.caution ? '<div class=\"opt-risk\">' + esc(o.caution) + '</div>' : '') +\n '</div></div>';\n }).join('');\n }\n function renderQuick() {\n if (!state.help) return;\n var d = state.help;\n $('#cliQuick').innerHTML = (d.quick || []).map(function (g) {\n var items = (g.items || []).map(function (it) {\n var meta = findHelpEntry(d.groups, it.cmd) || {};\n return '<div class=\"cmd-item\"><div class=\"cmd-head\"><code class=\"cmd-name\">' + esc(it.cmd) + '</code>' +\n (it.danger ? '<span class=\"badge danger-badge\">危险</span>' : '') + '</div>' +\n '<div class=\"cmd-what\">' + esc(meta.what || '') + '</div>' +\n '<div class=\"cmd-when\">' + esc(meta.when || '') + '</div>' +\n codeLine(it.example) +\n (it.note ? '<div class=\"cmd-note\">' + esc(it.note) + '</div>' : '') + '</div>';\n }).join('');\n return '<div class=\"quick-group\"><h4>' + esc(g.group) + '</h4><div class=\"quick-list\">' + items + '</div></div>';\n }).join('');\n }\n function renderFull() {\n if (!state.help) return;\n $('#cliFull').innerHTML = (state.help.groups || []).map(function (g) {\n var cmds = (g.commands || []).map(function (c) {\n var subs = (c.subcommands || []).map(function (s) {\n return '<div class=\"help-sub\"><div class=\"cmd-head\"><code class=\"cmd-name\">' + esc(c.name + ' ' + s.name) + '</code></div>' +\n (s.what ? '<div class=\"cmd-what\">' + esc(s.what) + '</div>' : '') +\n (s.when ? '<div class=\"cmd-when\">' + esc(s.when) + '</div>' : '') +\n codeLine('yotta-memory ' + c.name + ' ' + s.usage) + optRows(s.options) + '</div>';\n }).join('');\n return '<details class=\"help-cmd\"><summary><code class=\"cmd-name\">' + esc(c.name) + '</code><span class=\"cmd-what\">' + esc(c.what) + '</span></summary>' +\n '<div class=\"help-body\">' + (c.when ? '<div class=\"cmd-when\">' + esc(c.when) + '</div>' : '') +\n codeLine('yotta-memory ' + c.usage) + optRows(c.options) + subs + '</div></details>';\n }).join('');\n return '<div class=\"quick-group\"><h4>' + esc(g.group) + '</h4>' + cmds + '</div>';\n }).join('');\n }\n async function ensureHelp() {\n if (state.help) { renderQuick(); return; }\n var d = await api('/api/help');\n if (!d || d.error) { toast(errText(d, '帮助加载失败')); return; }\n state.help = d;\n renderQuick();\n }\n $('#btnFullHelp').addEventListener('click', async function () {\n if (!state.help) await ensureHelp();\n if (!state.help) return;\n renderFull();\n $('#cliFull').hidden = false;\n $('#btnFullHelp').hidden = true;\n });\n document.addEventListener('click', function (e) {\n var b = e.target.closest('.copy-btn[data-copy]');\n if (!b) return;\n copyText(b.dataset.copy).then(function (ok) { toast(ok ? '已复制' : '复制失败,请手动选择'); });\n });\n\n /* unlock / boot */\n $('#unlockBtn').addEventListener('click', async function () {\n var r = await api('/api/unlock', { password: $('#pw').value });\n if (r.error) { $('#lockErr').textContent = r.error; return; }\n $('#lockErr').textContent = '';\n showApp();\n });\n $('#pw').addEventListener('keydown', function (e) { if (e.key === 'Enter') $('#unlockBtn').click(); });\n (async function boot() {\n var s = await api('/api/status');\n if (s && s.version) $('#ver').textContent = 'v' + s.version;\n if (s && s.unlocked) showApp(); else showLock();\n if (s && s.encrypted === false) $('#lockErr').textContent = '当前记忆库未启用加密;请先在 CLI 执行 init --encrypt / migrate。';\n })();\n})();\n</script>\n</body>\n</html>\n";
357
388
  // @generated view-html:end
358
389
  // MCP 协议(2026-07-28 无状态 + 2025-11-25 legacy 握手,dual-era)
359
390
  const MCP_PROTOCOL_MODERN = '2026-07-28';
@@ -1000,6 +1031,22 @@ function identityDiagnostic(ident) {
1000
1031
  }
1001
1032
  return out;
1002
1033
  }
1034
+
1035
+ /** 写操作身份回显:让 AI / 用户当场看到本次写入使用的 agent_id 与来源。 */
1036
+ function identityWriteNote(ident, opts) {
1037
+ opts = opts || {};
1038
+ if (opts.userUnlocked) return '';
1039
+ const id = ident && ident.id ? ident.id : '未声明';
1040
+ const source = ident && ident.source ? ident.source : 'none';
1041
+ let note = '[身份] agent_id: ' + id + ';来源: ' + source;
1042
+ const hostHome = process.env.YOTTA_MEMORY_AGENT_HOME;
1043
+ if (hostHome) {
1044
+ note += '\n[注意] 检测到宿主身份环境 YOTTA_MEMORY_AGENT_HOME=' + hostHome +
1045
+ ';如果这不是本会话 AI 的身份,请显式传 --agent <id> --agent-key-file <path> 覆盖后重试。';
1046
+ }
1047
+ return note;
1048
+ }
1049
+
1003
1050
  function isSafeAgentId(id) {
1004
1051
  const value = String(id || '');
1005
1052
  if (!value || value.length > 128) return false;
@@ -2517,14 +2564,15 @@ function readEntry(fp, root) {
2517
2564
  meta: meta,
2518
2565
  };
2519
2566
  }
2520
- function rewriteFrontmatter(fp, patch, root, owner) {
2567
+ function rewriteFrontmatter(fp, patch, root, owner, bodyOverride) {
2521
2568
  let text;
2522
2569
  try { text = readMemoryText(root || memoryRootForFile(fp), fp, owner || ownerFromPrivatePath(root || memoryRootForFile(fp), fp)); }
2523
2570
  catch (e) { throw e; }
2524
2571
  const parsed = parseFrontmatter(text);
2525
2572
  const meta = Object.assign({}, parsed.meta);
2526
2573
  for (const k of Object.keys(patch)) meta[k] = patch[k];
2527
- writeMemoryText(root || memoryRootForFile(fp), fp, frontmatterToText(meta, parsed.body), owner || ownerFromPrivatePath(root || memoryRootForFile(fp), fp));
2574
+ const body = bodyOverride === undefined ? parsed.body : String(bodyOverride);
2575
+ writeMemoryText(root || memoryRootForFile(fp), fp, frontmatterToText(meta, body), owner || ownerFromPrivatePath(root || memoryRootForFile(fp), fp));
2528
2576
  }
2529
2577
  function bumpReadMeta(root, relFiles, options) {
2530
2578
  options = options || {};
@@ -6784,7 +6832,13 @@ function viewUpdateCore(root, payload) {
6784
6832
  if (!found) return { error: true, text: '未找到记忆文件: ' + payload.file };
6785
6833
  const rel = found.rel;
6786
6834
  const owner = ownerFromPrivatePath(root, found.fp);
6835
+ let parsedOriginal;
6836
+ try { parsedOriginal = parseFrontmatter(readMemoryText(root, found.fp, owner)); } catch (e) { return { error: true, text: '读取失败: ' + e.message }; }
6837
+ if (!parsedOriginal || !parsedOriginal.meta) return { error: true, text: '读取失败: 无法解析记忆文件' };
6838
+ const original = parsedOriginal.meta;
6839
+ const type = String(original.type || 'FACT').toUpperCase();
6787
6840
  const patch = {};
6841
+ let ownerChange = null;
6788
6842
  if (payload.subject !== undefined) {
6789
6843
  const s = String(payload.subject).trim();
6790
6844
  if (!s) return { error: true, text: 'subject 不能为空' };
@@ -6798,9 +6852,21 @@ function viewUpdateCore(root, payload) {
6798
6852
  if (payload.tags !== undefined) {
6799
6853
  patch.tags = Array.isArray(payload.tags) ? payload.tags.map(String).filter(Boolean) : parseTags(String(payload.tags));
6800
6854
  }
6801
- if (!Object.keys(patch).length) return { error: true, text: '没有可更新的字段(subject / statement / tags)。' };
6802
- let original;
6803
- try { original = parseFrontmatter(readMemoryText(root, found.fp, owner)).meta; } catch (e) { return { error: true, text: '读取失败: ' + e.message }; }
6855
+ if (payload.owner !== undefined) {
6856
+ const nextOwner = String(payload.owner || '').trim();
6857
+ const currentOwner = String(original.owner || '').trim();
6858
+ if (nextOwner !== currentOwner) {
6859
+ if (type !== 'FACT') {
6860
+ return { error: true, text: '私密记忆(' + type + ')的归属不可直接修改;如需变更请删除后重写,或等待「归属迁移」功能。' };
6861
+ }
6862
+ if (nextOwner && !isSafeAgentId(nextOwner)) {
6863
+ return { error: true, text: '归属 AI ID 非法:只允许单段名称,禁止 /、\\、.. 和控制字符。' };
6864
+ }
6865
+ patch.owner = nextOwner;
6866
+ ownerChange = { from: currentOwner, to: nextOwner };
6867
+ }
6868
+ }
6869
+ if (!Object.keys(patch).length) return { error: true, text: '没有可更新的字段(subject / statement / tags / owner)。' };
6804
6870
  const ts = new Date().toISOString().replace(/[:.]/g, '-');
6805
6871
  const undoRel = path.posix.join('.trash', 'view-history', ts, rel);
6806
6872
  const undoPath = path.join(root, undoRel);
@@ -6809,10 +6875,26 @@ function viewUpdateCore(root, payload) {
6809
6875
  fs.copyFileSync(found.fp, undoPath);
6810
6876
  } catch (e) { return { error: true, text: '编辑前备份失败: ' + e.message }; }
6811
6877
  patch.updated = today();
6812
- try { rewriteFrontmatter(found.fp, patch, root, owner); } catch (e) { return { error: true, text: '更新失败: ' + e.message }; }
6878
+ // 常规条目约定 body 镜像 statement;长文(如 distill 报告)保留原 body。
6879
+ const bodyMirrorsStatement = String(parsedOriginal.body || '').trim() === String(original.statement || '').trim();
6880
+ const nextBody = patch.statement !== undefined && bodyMirrorsStatement ? patch.statement : undefined;
6881
+ try { rewriteFrontmatter(found.fp, patch, root, owner, nextBody); } catch (e) { return { error: true, text: '更新失败: ' + e.message }; }
6813
6882
  upsertIndexEntry(root, readEntry(found.fp, root));
6814
- appendAudit(root, 'audit', { action: 'view_update', ts: new Date().toISOString(), file: rel, undo: undoRel, before_updated: original.updated || '' });
6815
- return { error: false, file: rel, undo: undoRel, text: '已更新: ' + rel };
6883
+ appendAudit(root, 'audit', {
6884
+ action: 'view_update',
6885
+ ts: new Date().toISOString(),
6886
+ file: rel,
6887
+ undo: undoRel,
6888
+ before_updated: original.updated || '',
6889
+ ...(ownerChange ? { owner_from: ownerChange.from, owner_to: ownerChange.to } : {}),
6890
+ });
6891
+ return {
6892
+ error: false,
6893
+ file: rel,
6894
+ undo: undoRel,
6895
+ ...(ownerChange ? { ownerChange: ownerChange } : {}),
6896
+ text: '已更新: ' + rel + (ownerChange ? '(归属 ' + (ownerChange.from || '公共') + ' → ' + (ownerChange.to || '公共') + ')' : ''),
6897
+ };
6816
6898
  }
6817
6899
 
6818
6900
  function viewDeleteCore(root, payload) {
@@ -6830,18 +6912,39 @@ function viewRestoreCore(root, payload) {
6830
6912
  const abs = resolveWithinRoot(root, ref);
6831
6913
  if (!abs || !fs.existsSync(abs) || fs.statSync(abs).isDirectory()) return { error: true, text: '回收区文件不存在: ' + ref };
6832
6914
  const seg = ref.split('/');
6833
- const originalRel = seg[1] === 'view-history' ? seg.slice(3).join('/') : seg.slice(2).join('/');
6915
+ const isHistory = seg[1] === 'view-history';
6916
+ const originalRel = isHistory ? seg.slice(3).join('/') : seg.slice(2).join('/');
6834
6917
  if (!originalRel) return { error: true, text: '无法解析原路径。' };
6835
6918
  const dest = resolveWithinRoot(root, originalRel);
6836
6919
  if (!dest) return { error: true, text: '原路径非法。' };
6837
- if (fs.existsSync(dest)) return { error: true, text: '原位置已有文件,拒绝覆盖: ' + originalRel };
6920
+ let redoRel = null;
6921
+ if (fs.existsSync(dest)) {
6922
+ // 编辑历史撤销允许覆盖:先把当前版本留作重做快照;回收区直恢复仍 fail-closed。
6923
+ if (!isHistory) return { error: true, text: '原位置已有文件,拒绝覆盖: ' + originalRel };
6924
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
6925
+ redoRel = path.posix.join('.trash', 'view-history', ts, originalRel);
6926
+ const redoAbs = path.join(root, redoRel);
6927
+ try {
6928
+ fs.mkdirSync(path.dirname(redoAbs), { recursive: true });
6929
+ fs.renameSync(dest, redoAbs);
6930
+ } catch (e) { return { error: true, text: '撤销失败: 当前版本快照失败: ' + e.message }; }
6931
+ }
6838
6932
  try {
6839
6933
  fs.mkdirSync(path.dirname(dest), { recursive: true });
6840
6934
  fs.renameSync(abs, dest);
6841
- } catch (e) { return { error: true, text: '恢复失败: ' + e.message }; }
6935
+ } catch (e) {
6936
+ if (redoRel) { try { fs.renameSync(path.join(root, redoRel), dest); } catch (_) { /* keep redo copy */ } }
6937
+ return { error: true, text: '恢复失败: ' + e.message };
6938
+ }
6842
6939
  upsertIndexEntry(root, readEntry(dest, root));
6843
- appendAudit(root, 'audit', { action: 'view_restore', ts: new Date().toISOString(), file: originalRel, from: ref });
6844
- return { error: false, file: originalRel, text: '已恢复: ' + originalRel };
6940
+ appendAudit(root, 'audit', {
6941
+ action: 'view_restore',
6942
+ ts: new Date().toISOString(),
6943
+ file: originalRel,
6944
+ from: ref,
6945
+ ...(redoRel ? { redo: redoRel } : {}),
6946
+ });
6947
+ return { error: false, file: originalRel, ...(redoRel ? { redo: redoRel } : {}), text: '已恢复: ' + originalRel };
6845
6948
  }
6846
6949
 
6847
6950
  function viewArchiveCore(root, payload) {
@@ -7306,6 +7409,10 @@ function cmdRemember(type, subject, statement, opts) {
7306
7409
  if (o.noHint) o.hint = false;
7307
7410
  const r = rememberCore(type, subject, statement, o);
7308
7411
  console.log(r.text);
7412
+ if (!r.error) {
7413
+ const note = identityWriteNote(resolveIdentity(o), o);
7414
+ if (note) console.error(note);
7415
+ }
7309
7416
  if (r.error) process.exit(2);
7310
7417
  }
7311
7418
  function cmdRecall(query, opts) {
@@ -7362,6 +7469,8 @@ function cmdForget(fileRef, opts) {
7362
7469
  unsafe: !!(opts && opts.unsafe),
7363
7470
  });
7364
7471
  console.log(r.text);
7472
+ const note = identityWriteNote(ident, opts);
7473
+ if (note) console.error(note);
7365
7474
  if (r.error) process.exit(2);
7366
7475
  }
7367
7476
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yottameta/yotta-memory",
3
- "version": "0.22.0",
3
+ "version": "0.22.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": [
@@ -3,7 +3,7 @@
3
3
  "slug": "yotta-memory",
4
4
  "name": "元忆",
5
5
  "package": "@yottameta/yotta-memory",
6
- "version": "0.22.0",
6
+ "version": "0.22.2",
7
7
  "trust": "yottameta",
8
8
  "install": {
9
9
  "idempotent": true