@yottameta/yotta-memory 0.22.4 → 0.22.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,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.4';
28
+ const VERSION = '0.22.6';
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
 
@@ -384,7 +384,7 @@ const VIEW_HELP_QUICK = [
384
384
  ] },
385
385
  ];
386
386
  // @generated view-html:start
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";
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 <select id=\"ownerFilter\" title=\"按智能体筛选\" style=\"width:auto;min-width:140px\"><option value=\"\">全部智能体</option></select>\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', owner: '', 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 var byType = d.byType || {};\n var typeCounts = { all: d.total || 0, FACT: byType.FACT || 0, PREF: byType.PREF || 0, BOUND: byType.BOUND || 0, COMMIT: byType.COMMIT || 0 };\n $('#typeBars').innerHTML = ['FACT', 'PREF', 'BOUND', 'COMMIT'].map(function (t) {\n return '<div class=\"bar\" data-type=\"' + t + '\" style=\"cursor:pointer\" title=\"查看 ' + typeNames[t] + '\"><span class=\"dot\" style=\"background:' + colors[t] + '\"></span><span class=\"faint\">' + typeNames[t] + '</span><b>' + typeCounts[t] + '</b></div>';\n }).join('');\n Array.prototype.forEach.call(document.querySelectorAll('#typeChips .chip'), function (c) {\n c.textContent = (c.dataset.type === 'all' ? '全部' : c.dataset.type) + ' ' + (typeCounts[c.dataset.type] || 0);\n });\n }\n async function loadOwners() {\n var d = await api('/api/owners');\n state.owners = (d && d.owners) || [];\n var sel = $('#ownerFilter');\n if (sel) {\n sel.innerHTML = '<option value=\"\">全部智能体</option>' + state.owners.map(function (o) {\n return '<option value=\"' + esc(o.owner) + '\"' + (o.owner === state.owner ? ' selected' : '') + '>' + esc(o.owner) + '</option>';\n }).join('');\n }\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, owner: state.owner || '' });\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 $('#typeBars').addEventListener('click', function (e) {\n var bar = e.target.closest('.bar[data-type]');\n if (!bar) return;\n state.type = bar.dataset.type; state.offset = 0;\n Array.prototype.forEach.call(document.querySelectorAll('#typeChips .chip'), function (c) { c.classList.toggle('active', c.dataset.type === state.type); });\n switchView('memories');\n loadEntries();\n });\n $('#ownerFilter').addEventListener('change', function () { state.owner = this.value; state.offset = 0; loadEntries(); });\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 var groupsHtml = (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 var keys = state.help.configKeys || [];\n var keysHtml = keys.length ? '<div class=\"quick-group\" id=\"cliConfigKeys\"><h4>配置键(yotta-memory config set &lt;键&gt; &lt;值&gt;)</h4><div class=\"quick-list\">' +\n keys.map(function (k) {\n return '<div class=\"cmd-item\"><div class=\"cmd-head\"><code class=\"cmd-name\">' + esc(k) + '</code></div>' +\n codeLine('yotta-memory config set ' + k + ' <值>') + '</div>';\n }).join('') + '</div></div>' : '';\n $('#cliFull').innerHTML = groupsHtml + keysHtml;\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";
388
388
  // @generated view-html:end
389
389
  // MCP 协议(2026-07-28 无状态 + 2025-11-25 legacy 握手,dual-era)
390
390
  const MCP_PROTOCOL_MODERN = '2026-07-28';
@@ -2095,11 +2095,12 @@ function readMemoryText(root, fp, owner) {
2095
2095
  if (!key) throw new Error('私密区已加密:当前无 ' + owner + ' 的授权密钥,请在用户平台授权(yotta-memory view → 授权本智能体)。');
2096
2096
  return decryptMemoryText(fs.readFileSync(fp), key);
2097
2097
  }
2098
- function writeMemoryText(root, fp, text, owner) {
2099
- if (!isEncFile(fp)) { fs.writeFileSync(fp, text, 'utf8'); return; }
2098
+ function writeMemoryText(root, fp, text, owner, opts) {
2099
+ const flag = (opts && opts.exclusive) ? 'wx' : 'w';
2100
+ if (!isEncFile(fp)) { fs.writeFileSync(fp, text, { encoding: 'utf8', flag: flag }); return; }
2100
2101
  const key = getOwnerKeyFor(root, owner);
2101
2102
  if (!key) throw new Error('私密区已加密:当前无 ' + owner + ' 的授权密钥,请在用户平台授权(yotta-memory view → 授权本智能体)。');
2102
- fs.writeFileSync(fp, encryptMemoryText(text, key));
2103
+ fs.writeFileSync(fp, encryptMemoryText(text, key), { flag: flag });
2103
2104
  }
2104
2105
  function ownerIndexPath(root, owner) { return path.join(root, PRIVATE_DIR, owner, ENC_INDEX_FILE); }
2105
2106
  function loadOwnerIndex(root, owner, ownerKey) {
@@ -2111,12 +2112,121 @@ function loadOwnerIndex(root, owner, ownerKey) {
2111
2112
  const j = JSON.parse(d.toString('utf8'));
2112
2113
  return Array.isArray(j.entries) ? j.entries : [];
2113
2114
  }
2115
+ // ---- v0.22.6 索引一致性加固:跨进程互斥 + 原子落盘 + 静默校准 ----
2116
+ // 索引是派生数据,记忆文件才是唯一真源;多智能体并发写入时防止互相覆盖,并自动对齐漂移。
2117
+ function sleepSyncMs(ms) {
2118
+ try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
2119
+ catch (e) { const end = Date.now() + ms; while (Date.now() < end) { /* busy fallback */ } }
2120
+ }
2121
+ function atomicWriteFileSync(fp, data) {
2122
+ const tmp = fp + '.tmp-' + process.pid + '-' + crypto.randomBytes(4).toString('hex');
2123
+ fs.writeFileSync(tmp, data);
2124
+ let lastError = null;
2125
+ for (let i = 0; i < 5; i++) {
2126
+ try { fs.renameSync(tmp, fp); return; }
2127
+ catch (e) { lastError = e; sleepSyncMs(15 + i * 25); }
2128
+ }
2129
+ try { fs.unlinkSync(tmp); } catch (e) { /* ignore */ }
2130
+ throw lastError;
2131
+ }
2132
+ const INDEX_LOCK_NAME = '.index.lock';
2133
+ const heldIndexLocks = new Set();
2134
+ function indexLockPath(root) { return path.join(root, INDEX_LOCK_NAME); }
2135
+ // 跨进程互斥(mkdir 原子语义);锁竞争超时后降级为原行为,由静默校准兜底。
2136
+ function withIndexLock(root, fn) {
2137
+ const key = path.resolve(root);
2138
+ if (heldIndexLocks.has(key)) return fn();
2139
+ const lockPath = indexLockPath(root);
2140
+ const started = Date.now();
2141
+ let acquired = false;
2142
+ while (Date.now() - started < 4000) {
2143
+ try { fs.mkdirSync(lockPath); acquired = true; break; }
2144
+ catch (e) {
2145
+ if (e && e.code !== 'EEXIST') break;
2146
+ try {
2147
+ const st = fs.statSync(lockPath);
2148
+ if (Date.now() - st.mtimeMs > 120000) { fs.rmdirSync(lockPath); continue; }
2149
+ } catch (e2) { /* 锁刚释放,直接重试 */ }
2150
+ sleepSyncMs(25 + Math.floor(Math.random() * 40));
2151
+ }
2152
+ }
2153
+ if (!acquired) return fn();
2154
+ heldIndexLocks.add(key);
2155
+ try { return fn(); }
2156
+ finally {
2157
+ heldIndexLocks.delete(key);
2158
+ try { fs.rmdirSync(lockPath); } catch (e) { /* ignore */ }
2159
+ }
2160
+ }
2161
+ // 只读对账:记忆文件(真源)与索引(根索引 + 有密钥 owner 的私密索引)逐条比对。
2162
+ function indexConsistencyReport(root, options) {
2163
+ options = options || {};
2164
+ const fileSet = new Set(collectEntryFiles(root).map(function (fp) {
2165
+ return path.relative(root, fp).replace(/\\/g, '/');
2166
+ }));
2167
+ const entrySet = new Set();
2168
+ for (const e of (loadIndex(root) || [])) if (e && e.file) entrySet.add(String(e.file));
2169
+ const ownersChecked = [];
2170
+ const ownersSkipped = [];
2171
+ if (isEncrypted(root)) {
2172
+ for (const owner of collectOwners(root)) {
2173
+ const key = getOwnerKeyFor(root, owner, options.identity);
2174
+ if (!key) { ownersSkipped.push(owner); continue; }
2175
+ ownersChecked.push(owner);
2176
+ let entries = [];
2177
+ try { entries = loadOwnerIndex(root, owner, key); } catch (e) { entries = []; }
2178
+ for (const e of entries) if (e && e.file) entrySet.add(String(e.file));
2179
+ }
2180
+ }
2181
+ const skipOwners = new Set(ownersSkipped);
2182
+ const missing = [];
2183
+ let skippedFiles = 0;
2184
+ for (const rel of fileSet) {
2185
+ if (rel.indexOf(PRIVATE_DIR + '/') === 0) {
2186
+ const owner = rel.split('/')[1] || '';
2187
+ if (skipOwners.has(owner)) { skippedFiles += 1; continue; }
2188
+ }
2189
+ if (!entrySet.has(rel)) missing.push(rel);
2190
+ }
2191
+ const ghosts = [];
2192
+ for (const rel of entrySet) if (!fileSet.has(rel)) ghosts.push(rel);
2193
+ return {
2194
+ drift: missing.length > 0 || ghosts.length > 0,
2195
+ files: fileSet.size - skippedFiles,
2196
+ filesTotal: fileSet.size,
2197
+ skippedFiles: skippedFiles,
2198
+ entries: entrySet.size,
2199
+ missing: missing.length,
2200
+ missingSample: missing.slice(0, 10),
2201
+ ghosts: ghosts.length,
2202
+ ghostSample: ghosts.slice(0, 10),
2203
+ ownersChecked: ownersChecked,
2204
+ ownersSkipped: ownersSkipped,
2205
+ };
2206
+ }
2207
+ let lastIndexHealAt = 0;
2208
+ // 静默校准:发现漂移自动按文件真源重建索引;只对齐索引,不动任何记忆文件。
2209
+ function maybeHealIndex(root, options) {
2210
+ options = options || {};
2211
+ const now = Date.now();
2212
+ if (!options.force && now - lastIndexHealAt < 60000) return { checked: false, healed: false };
2213
+ lastIndexHealAt = now;
2214
+ let report = null;
2215
+ try { report = indexConsistencyReport(root, options); }
2216
+ catch (e) { return { checked: true, healed: false, error: e }; }
2217
+ if (!report.drift) return { checked: true, healed: false, report: report };
2218
+ try { buildIndex(root); }
2219
+ catch (e) { return { checked: true, healed: false, error: e, report: report }; }
2220
+ let after = report;
2221
+ try { after = indexConsistencyReport(root, options); } catch (e) { /* keep pre-heal report */ }
2222
+ return { checked: true, healed: true, report: report, after: after };
2223
+ }
2114
2224
  function saveOwnerIndex(root, owner, ownerKey, entries) {
2115
2225
  const data = JSON.stringify({ version: 1, updated: today(), entries: entries }, null, 2);
2116
2226
  const env = aesEncryptBytes(ownerKey, Buffer.from(data, 'utf8'), Buffer.from('index:' + owner, 'utf8'));
2117
2227
  const dir = path.join(root, PRIVATE_DIR, owner);
2118
2228
  fs.mkdirSync(dir, { recursive: true });
2119
- fs.writeFileSync(path.join(dir, ENC_INDEX_FILE), Buffer.concat([Buffer.from(IDX_MAGIC, 'utf8'), env]));
2229
+ atomicWriteFileSync(path.join(dir, ENC_INDEX_FILE), Buffer.concat([Buffer.from(IDX_MAGIC, 'utf8'), env]));
2120
2230
  }
2121
2231
  function hasPlaintextPrivate(root) {
2122
2232
  for (const owner of collectOwners(root)) {
@@ -2273,10 +2383,10 @@ function saveIndex(root, entries) {
2273
2383
  const newShards = [];
2274
2384
  for (const y of Object.keys(byYear).sort()) {
2275
2385
  const name = indexShardName(y);
2276
- fs.writeFileSync(path.join(root, name), JSON.stringify({ version: INDEX_VERSION, year: (parseInt(y, 10) || 0), entries: byYear[y] }, null, 2), 'utf8');
2386
+ atomicWriteFileSync(path.join(root, name), JSON.stringify({ version: INDEX_VERSION, year: (parseInt(y, 10) || 0), entries: byYear[y] }, null, 2), 'utf8');
2277
2387
  newShards.push(name);
2278
2388
  }
2279
- fs.writeFileSync(indexPath(root), JSON.stringify({ version: INDEX_VERSION, updated: today(), shards: newShards, count: clean.length }, null, 2), 'utf8');
2389
+ atomicWriteFileSync(indexPath(root), JSON.stringify({ version: INDEX_VERSION, updated: today(), shards: newShards, count: clean.length }, null, 2), 'utf8');
2280
2390
  if (old && Array.isArray(old.shards)) {
2281
2391
  for (const sh of old.shards) if (newShards.indexOf(sh) === -1 && isSafeShardName(sh)) { try { fs.unlinkSync(path.join(root, sh)); } catch (e) {} }
2282
2392
  }
@@ -2284,7 +2394,7 @@ function saveIndex(root, entries) {
2284
2394
  if (old && Array.isArray(old.shards)) {
2285
2395
  for (const sh of old.shards) { try { fs.unlinkSync(path.join(root, sh)); } catch (e) {} }
2286
2396
  }
2287
- fs.writeFileSync(indexPath(root), JSON.stringify({ version: INDEX_VERSION, updated: today(), entries: clean }, null, 2), 'utf8');
2397
+ atomicWriteFileSync(indexPath(root), JSON.stringify({ version: INDEX_VERSION, updated: today(), entries: clean }, null, 2), 'utf8');
2288
2398
  }
2289
2399
  }
2290
2400
  function collectEntryFiles(root) {
@@ -2421,38 +2531,41 @@ function migrateLayout(root) {
2421
2531
  return moved;
2422
2532
  }
2423
2533
  function buildIndex(root) {
2424
- migrateLayout(root);
2425
- const layout = layoutCollisionReport(root);
2426
- const skip = new Set();
2427
- for (const group of layout.groups) {
2428
- if (group.status !== 'duplicate') continue;
2429
- for (const rel of group.drop) skip.add(rel);
2430
- }
2431
- const publicEntries = [];
2432
- const privateByOwner = {};
2433
- for (const fp of collectEntryFiles(root)) {
2434
- const rel = path.relative(root, fp).replace(/\\/g, '/');
2435
- if (skip.has(rel)) continue;
2436
- if (rel.indexOf(PRIVATE_DIR + '/') === 0) {
2437
- const owner = rel.split('/')[1] || '';
2438
- if (!getOwnerKeyFor(root, owner)) continue;
2439
- let e;
2440
- try { e = readEntry(fp, root); } catch (err) { continue; }
2441
- (privateByOwner[owner] = privateByOwner[owner] || []).push(e);
2442
- } else {
2443
- try { publicEntries.push(readEntry(fp, root)); } catch (err) { /* ignore */ }
2534
+ return withIndexLock(root, function () {
2535
+ migrateLayout(root);
2536
+ const layout = layoutCollisionReport(root);
2537
+ const skip = new Set();
2538
+ for (const group of layout.groups) {
2539
+ if (group.status !== 'duplicate') continue;
2540
+ for (const rel of group.drop) skip.add(rel);
2541
+ }
2542
+ const publicEntries = [];
2543
+ const privateByOwner = {};
2544
+ for (const fp of collectEntryFiles(root)) {
2545
+ const rel = path.relative(root, fp).replace(/\\/g, '/');
2546
+ if (skip.has(rel)) continue;
2547
+ if (rel.indexOf(PRIVATE_DIR + '/') === 0) {
2548
+ const owner = rel.split('/')[1] || '';
2549
+ if (!getOwnerKeyFor(root, owner)) continue;
2550
+ let e;
2551
+ try { e = readEntry(fp, root); } catch (err) { continue; }
2552
+ (privateByOwner[owner] = privateByOwner[owner] || []).push(e);
2553
+ } else {
2554
+ try { publicEntries.push(readEntry(fp, root)); } catch (err) { /* ignore */ }
2555
+ }
2444
2556
  }
2445
- }
2446
- saveIndex(root, publicEntries);
2447
- for (const owner of Object.keys(privateByOwner)) {
2448
- const key = getOwnerKeyFor(root, owner);
2449
- if (key) saveOwnerIndex(root, owner, key, privateByOwner[owner]);
2450
- }
2451
- const all = publicEntries.slice();
2452
- for (const owner of Object.keys(privateByOwner)) all.push.apply(all, privateByOwner[owner]);
2453
- return all;
2557
+ saveIndex(root, publicEntries);
2558
+ for (const owner of Object.keys(privateByOwner)) {
2559
+ const key = getOwnerKeyFor(root, owner);
2560
+ if (key) saveOwnerIndex(root, owner, key, privateByOwner[owner]);
2561
+ }
2562
+ const all = publicEntries.slice();
2563
+ for (const owner of Object.keys(privateByOwner)) all.push.apply(all, privateByOwner[owner]);
2564
+ return all;
2565
+ });
2454
2566
  }
2455
2567
  function ensureIndex(root) {
2568
+ try { maybeHealIndex(root); } catch (e) { /* 静默校准失败不阻断读取 */ }
2456
2569
  if (!isEncrypted(root)) {
2457
2570
  const idx = loadIndex(root);
2458
2571
  return idx || buildIndex(root);
@@ -2470,58 +2583,64 @@ function ensureIndex(root) {
2470
2583
  return all;
2471
2584
  }
2472
2585
  function upsertIndexEntry(root, entry) {
2473
- if (isEncrypted(root) && entry.scope !== 'public' && String(entry.file).indexOf(PUBLIC_DIR + '/') !== 0) {
2474
- const owner = entry.owner || String(entry.file).split('/')[1] || '';
2475
- const key = getOwnerKeyFor(root, owner);
2476
- if (!key) return;
2477
- const idx = loadOwnerIndex(root, owner, key);
2586
+ return withIndexLock(root, function () {
2587
+ if (isEncrypted(root) && entry.scope !== 'public' && String(entry.file).indexOf(PUBLIC_DIR + '/') !== 0) {
2588
+ const owner = entry.owner || String(entry.file).split('/')[1] || '';
2589
+ const key = getOwnerKeyFor(root, owner);
2590
+ if (!key) return;
2591
+ const idx = loadOwnerIndex(root, owner, key);
2592
+ const i = idx.findIndex(function (e) { return e.file === entry.file; });
2593
+ if (i >= 0) idx[i] = entry; else idx.push(entry);
2594
+ saveOwnerIndex(root, owner, key, idx);
2595
+ return;
2596
+ }
2597
+ const idx = getIndex(root);
2478
2598
  const i = idx.findIndex(function (e) { return e.file === entry.file; });
2479
2599
  if (i >= 0) idx[i] = entry; else idx.push(entry);
2480
- saveOwnerIndex(root, owner, key, idx);
2481
- return;
2482
- }
2483
- const idx = getIndex(root);
2484
- const i = idx.findIndex(function (e) { return e.file === entry.file; });
2485
- if (i >= 0) idx[i] = entry; else idx.push(entry);
2486
- saveIndex(root, idx);
2600
+ saveIndex(root, idx);
2601
+ });
2487
2602
  }
2488
2603
  function removeIndexEntry(root, file) {
2489
- if (isEncrypted(root) && String(file).indexOf(PRIVATE_DIR + '/') === 0) {
2490
- const owner = String(file).split('/')[1] || '';
2491
- const key = getOwnerKeyFor(root, owner);
2492
- if (!key) return;
2493
- const idx = loadOwnerIndex(root, owner, key);
2494
- const next = idx.filter(function (e) { return e.file !== file; });
2495
- if (next.length !== idx.length) saveOwnerIndex(root, owner, key, next);
2496
- return;
2497
- }
2498
- const idx = getIndex(root);
2499
- const i = idx.findIndex(function (e) { return e.file === file; });
2500
- if (i >= 0) { idx.splice(i, 1); saveIndex(root, idx); }
2604
+ return withIndexLock(root, function () {
2605
+ if (isEncrypted(root) && String(file).indexOf(PRIVATE_DIR + '/') === 0) {
2606
+ const owner = String(file).split('/')[1] || '';
2607
+ const key = getOwnerKeyFor(root, owner);
2608
+ if (!key) return;
2609
+ const idx = loadOwnerIndex(root, owner, key);
2610
+ const next = idx.filter(function (e) { return e.file !== file; });
2611
+ if (next.length !== idx.length) saveOwnerIndex(root, owner, key, next);
2612
+ return;
2613
+ }
2614
+ const idx = getIndex(root);
2615
+ const i = idx.findIndex(function (e) { return e.file === file; });
2616
+ if (i >= 0) { idx.splice(i, 1); saveIndex(root, idx); }
2617
+ });
2501
2618
  }
2502
2619
  function removeIndexEntries(root, files) {
2503
- const set = new Set(files);
2504
- const byOwner = {};
2505
- const pub = [];
2506
- for (const f of files) {
2507
- if (String(f).indexOf(PRIVATE_DIR + '/') === 0) {
2508
- const o = String(f).split('/')[1] || '';
2509
- (byOwner[o] = byOwner[o] || []).push(f);
2510
- } else pub.push(f);
2511
- }
2512
- if (pub.length) {
2513
- const idx = getIndex(root);
2514
- const next = idx.filter(function (e) { return !set.has(e.file); });
2515
- if (next.length !== idx.length) saveIndex(root, next);
2516
- }
2517
- for (const o of Object.keys(byOwner)) {
2518
- const key = getOwnerKeyFor(root, o);
2519
- if (!key) continue;
2520
- const idx = loadOwnerIndex(root, o, key);
2521
- const oset = new Set(byOwner[o]);
2522
- const next = idx.filter(function (e) { return !oset.has(e.file); });
2523
- if (next.length !== idx.length) saveOwnerIndex(root, o, key, next);
2524
- }
2620
+ return withIndexLock(root, function () {
2621
+ const set = new Set(files);
2622
+ const byOwner = {};
2623
+ const pub = [];
2624
+ for (const f of files) {
2625
+ if (String(f).indexOf(PRIVATE_DIR + '/') === 0) {
2626
+ const o = String(f).split('/')[1] || '';
2627
+ (byOwner[o] = byOwner[o] || []).push(f);
2628
+ } else pub.push(f);
2629
+ }
2630
+ if (pub.length) {
2631
+ const idx = getIndex(root);
2632
+ const next = idx.filter(function (e) { return !set.has(e.file); });
2633
+ if (next.length !== idx.length) saveIndex(root, next);
2634
+ }
2635
+ for (const o of Object.keys(byOwner)) {
2636
+ const key = getOwnerKeyFor(root, o);
2637
+ if (!key) continue;
2638
+ const idx = loadOwnerIndex(root, o, key);
2639
+ const oset = new Set(byOwner[o]);
2640
+ const next = idx.filter(function (e) { return !oset.has(e.file); });
2641
+ if (next.length !== idx.length) saveOwnerIndex(root, o, key, next);
2642
+ }
2643
+ });
2525
2644
  }
2526
2645
 
2527
2646
  // ---- 读取 / 写入记忆文件 ----
@@ -2631,13 +2750,15 @@ function touchIndex(root, relFiles, options) {
2631
2750
  for (const owner of Object.keys(byOwner)) {
2632
2751
  const key = getOwnerKeyFor(root, owner);
2633
2752
  if (!key) continue;
2634
- let ownerIdx;
2635
- try { ownerIdx = loadOwnerIndex(root, owner, key); } catch (e) { continue; }
2636
- let dirty = false;
2637
- for (const e of ownerIdx) {
2638
- if (set.has(e.file)) { bumpEntry(e); dirty = true; }
2639
- }
2640
- if (dirty) saveOwnerIndex(root, owner, key, ownerIdx);
2753
+ withIndexLock(root, function () {
2754
+ let ownerIdx;
2755
+ try { ownerIdx = loadOwnerIndex(root, owner, key); } catch (e) { return; }
2756
+ let dirty = false;
2757
+ for (const e of ownerIdx) {
2758
+ if (set.has(e.file)) { bumpEntry(e); dirty = true; }
2759
+ }
2760
+ if (dirty) saveOwnerIndex(root, owner, key, ownerIdx);
2761
+ });
2641
2762
  }
2642
2763
  }
2643
2764
  // v0.17.0 B3:带 --year 的检索只碰命中年份的分片(命中条目必然来自这些分片),
@@ -2646,27 +2767,31 @@ function touchIndex(root, relFiles, options) {
2646
2767
  const yearSet = filterYears.length ? new Set(filterYears) : null;
2647
2768
  const manifest = loadIndexManifest(root);
2648
2769
  if (manifest && Array.isArray(manifest.shards)) {
2649
- for (const sh of manifest.shards) {
2650
- if (!isSafeShardName(sh)) continue;
2651
- if (yearSet && !yearSet.has(shardYear(sh))) continue;
2652
- const sp = path.join(root, sh);
2653
- if (!fs.existsSync(sp)) continue;
2654
- let sd = null; try { sd = JSON.parse(fs.readFileSync(sp, 'utf8')); } catch (e) { continue; }
2655
- if (!sd || !Array.isArray(sd.entries)) continue;
2656
- let dirty = false;
2657
- for (const e of sd.entries) {
2658
- if (set.has(e.file)) { bumpEntry(e); dirty = true; }
2770
+ withIndexLock(root, function () {
2771
+ for (const sh of manifest.shards) {
2772
+ if (!isSafeShardName(sh)) continue;
2773
+ if (yearSet && !yearSet.has(shardYear(sh))) continue;
2774
+ const sp = path.join(root, sh);
2775
+ if (!fs.existsSync(sp)) continue;
2776
+ let sd = null; try { sd = JSON.parse(fs.readFileSync(sp, 'utf8')); } catch (e) { continue; }
2777
+ if (!sd || !Array.isArray(sd.entries)) continue;
2778
+ let dirty = false;
2779
+ for (const e of sd.entries) {
2780
+ if (set.has(e.file)) { bumpEntry(e); dirty = true; }
2781
+ }
2782
+ if (dirty) atomicWriteFileSync(sp, JSON.stringify(sd, null, 2), 'utf8');
2659
2783
  }
2660
- if (dirty) fs.writeFileSync(sp, JSON.stringify(sd, null, 2), 'utf8');
2661
- }
2784
+ });
2662
2785
  return;
2663
2786
  }
2664
- const idx = getIndex(root);
2665
- let dirty = false;
2666
- for (const e of idx) {
2667
- if (set.has(e.file)) { bumpEntry(e); dirty = true; }
2668
- }
2669
- if (dirty) saveIndex(root, idx);
2787
+ withIndexLock(root, function () {
2788
+ const idx = getIndex(root);
2789
+ let dirty = false;
2790
+ for (const e of idx) {
2791
+ if (set.has(e.file)) { bumpEntry(e); dirty = true; }
2792
+ }
2793
+ if (dirty) saveIndex(root, idx);
2794
+ });
2670
2795
  }
2671
2796
  function daysBetween(a, b) {
2672
2797
  const da = new Date(a);
@@ -2919,6 +3044,12 @@ function backupWindowsTaskXml(opts) {
2919
3044
  const principal = userId
2920
3045
  ? '<Principal id="Author"><UserId>' + xmlEscape(userId) + '</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal>'
2921
3046
  : '<Principal id="Author"><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal>';
3047
+ const cmdPath = opts.cmdPath || process.env.ComSpec || 'cmd.exe';
3048
+ // v0.22.5: Electron 宿主(如 YottaCode shim)下 process.execPath 是 Electron 可执行文件,
3049
+ // 任务需经 cmd /c + ELECTRON_RUN_AS_NODE=1 包装,否则拉起的是桌面程序本体而非 Node。
3050
+ const action = lanElectronHost(opts)
3051
+ ? '<Actions Context="Author"><Exec><Command>' + xmlEscape(cmdPath) + '</Command><Arguments>/c "set "ELECTRON_RUN_AS_NODE=1" &amp;&amp; "' + xmlEscape(nodePath) + '" "' + xmlEscape(scriptPath) + '" backup ensure-daily"</Arguments></Exec></Actions>'
3052
+ : '<Actions Context="Author"><Exec><Command>' + xmlEscape(nodePath) + '</Command><Arguments>"' + xmlEscape(scriptPath) + '" backup ensure-daily</Arguments></Exec></Actions>';
2922
3053
  return [
2923
3054
  '<?xml version="1.0" encoding="UTF-16"?>',
2924
3055
  '<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">',
@@ -2940,7 +3071,7 @@ function backupWindowsTaskXml(opts) {
2940
3071
  '<ExecutionTimeLimit>PT2H</ExecutionTimeLimit>',
2941
3072
  '<Priority>7</Priority>',
2942
3073
  '</Settings>',
2943
- '<Actions Context="Author"><Exec><Command>' + xmlEscape(nodePath) + '</Command><Arguments>"' + xmlEscape(scriptPath) + '" backup ensure-daily</Arguments></Exec></Actions>',
3074
+ action,
2944
3075
  '</Task>',
2945
3076
  ].join('\r\n');
2946
3077
  }
@@ -2948,16 +3079,20 @@ function backupSystemdServiceContent(opts) {
2948
3079
  opts = opts || {};
2949
3080
  const nodePath = opts.nodePath || process.execPath;
2950
3081
  const scriptPath = opts.scriptPath || runtimeManagedScript();
2951
- return [
3082
+ const lines = [
2952
3083
  '[Unit]',
2953
3084
  'Description=Yotta Memory daily backup',
2954
3085
  '',
2955
3086
  '[Service]',
2956
3087
  'Type=oneshot',
3088
+ ];
3089
+ if (lanElectronHost(opts)) lines.push('Environment=ELECTRON_RUN_AS_NODE=1');
3090
+ lines.push(
2957
3091
  'ExecStart=' + systemdEscapeArg(nodePath) + ' ' + systemdEscapeArg(scriptPath) + ' backup ensure-daily',
2958
3092
  'Nice=10',
2959
3093
  '',
2960
- ].join('\n');
3094
+ );
3095
+ return lines.join('\n');
2961
3096
  }
2962
3097
  function backupSystemdTimerContent(opts) {
2963
3098
  opts = opts || {};
@@ -2983,7 +3118,8 @@ function backupCronLine(opts) {
2983
3118
  const scriptPath = opts.scriptPath || runtimeManagedScript();
2984
3119
  const logPath = opts.logPath || '';
2985
3120
  const redirect = logPath ? ' >> ' + shQuote(logPath) + ' 2>&1' : '';
2986
- return time.minute + ' ' + time.hour + ' * * * ' + shQuote(nodePath) + ' ' + shQuote(scriptPath) + ' backup ensure-daily' + redirect + ' ' + BACKUP_CRON_MARKER;
3121
+ const env = lanElectronHost(opts) ? 'ELECTRON_RUN_AS_NODE=1 ' : '';
3122
+ return time.minute + ' ' + time.hour + ' * * * ' + env + shQuote(nodePath) + ' ' + shQuote(scriptPath) + ' backup ensure-daily' + redirect + ' ' + BACKUP_CRON_MARKER;
2987
3123
  }
2988
3124
  function backupLaunchdPlist(opts) {
2989
3125
  opts = opts || {};
@@ -2995,6 +3131,11 @@ function backupLaunchdPlist(opts) {
2995
3131
  '<?xml version="1.0" encoding="UTF-8"?>',
2996
3132
  '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
2997
3133
  '<plist version="1.0"><dict>',
3134
+ ];
3135
+ if (lanElectronHost(opts)) {
3136
+ lines.push('<key>EnvironmentVariables</key><dict><key>ELECTRON_RUN_AS_NODE</key><string>1</string></dict>');
3137
+ }
3138
+ lines.push(
2998
3139
  '<key>Label</key><string>' + BACKUP_LAUNCHD_LABEL + '</string>',
2999
3140
  '<key>ProgramArguments</key><array>',
3000
3141
  '<string>' + xmlEscape(nodePath) + '</string>',
@@ -3006,7 +3147,7 @@ function backupLaunchdPlist(opts) {
3006
3147
  '<key>Hour</key><integer>' + time.hour + '</integer>',
3007
3148
  '<key>Minute</key><integer>' + time.minute + '</integer>',
3008
3149
  '</dict>',
3009
- ];
3150
+ );
3010
3151
  if (logPath) {
3011
3152
  lines.push('<key>StandardOutPath</key><string>' + xmlEscape(logPath) + '</string>');
3012
3153
  lines.push('<key>StandardErrorPath</key><string>' + xmlEscape(logPath) + '</string>');
@@ -3046,7 +3187,7 @@ function backupScheduleEnableCore(opts) {
3046
3187
  const userId = opts.userId || (process.env.USERDOMAIN && process.env.USERNAME ? process.env.USERDOMAIN + '\\' + process.env.USERNAME : '');
3047
3188
  const taskName = opts.taskName || BACKUP_TASK_NAME;
3048
3189
  const xmlPath = opts.xmlPath || path.join(os.tmpdir(), taskName + '.xml');
3049
- fs.writeFileSync(xmlPath, '\ufeff' + backupWindowsTaskXml({ time: time, nodePath: nodePath, scriptPath: scriptPath, userId: userId }), 'utf16le');
3190
+ fs.writeFileSync(xmlPath, '\ufeff' + backupWindowsTaskXml({ time: time, nodePath: nodePath, scriptPath: scriptPath, userId: userId, electronHost: opts.electronHost }), 'utf16le');
3050
3191
  schedulerExec(opts, 'schtasks', ['/create', '/tn', taskName, '/xml', xmlPath, '/f']);
3051
3192
  return { error: false, scheduler: 'windows-task', taskName: taskName, text: '已注册 Windows 每日备份任务: ' + taskName };
3052
3193
  }
@@ -3055,7 +3196,7 @@ function backupScheduleEnableCore(opts) {
3055
3196
  const servicePath = path.join(unitDir, BACKUP_SYSTEMD_SERVICE);
3056
3197
  const timerPath = path.join(unitDir, BACKUP_SYSTEMD_TIMER);
3057
3198
  fs.mkdirSync(unitDir, { recursive: true });
3058
- fs.writeFileSync(servicePath, backupSystemdServiceContent({ nodePath: nodePath, scriptPath: scriptPath }), 'utf8');
3199
+ fs.writeFileSync(servicePath, backupSystemdServiceContent({ nodePath: nodePath, scriptPath: scriptPath, electronHost: opts.electronHost }), 'utf8');
3059
3200
  fs.writeFileSync(timerPath, backupSystemdTimerContent({ time: time }), 'utf8');
3060
3201
  const reload = schedulerTry(opts, 'systemctl', ['--user', 'daemon-reload']);
3061
3202
  const enable = reload.ok ? schedulerTry(opts, 'systemctl', ['--user', 'enable', '--now', BACKUP_SYSTEMD_TIMER]) : reload;
@@ -3063,7 +3204,7 @@ function backupScheduleEnableCore(opts) {
3063
3204
  return { error: false, scheduler: 'linux-systemd', unit: BACKUP_SYSTEMD_TIMER, text: '已注册 systemd 用户定时器: ' + BACKUP_SYSTEMD_TIMER };
3064
3205
  }
3065
3206
  const logPath = opts.logPath || path.join(os.homedir(), '.yottamemory', 'backup.log');
3066
- const line = backupCronLine({ time: time, nodePath: nodePath, scriptPath: scriptPath, logPath: logPath });
3207
+ const line = backupCronLine({ time: time, nodePath: nodePath, scriptPath: scriptPath, logPath: logPath, electronHost: opts.electronHost });
3067
3208
  const current = schedulerTry(opts, 'crontab', ['-l']);
3068
3209
  const kept = current.stdout.split(/\r?\n/).filter(function (item) {
3069
3210
  return item && item.indexOf(BACKUP_CRON_MARKER) === -1;
@@ -3087,6 +3228,7 @@ function backupScheduleEnableCore(opts) {
3087
3228
  nodePath: nodePath,
3088
3229
  scriptPath: scriptPath,
3089
3230
  logPath: opts.logPath || path.join(os.homedir(), 'Library', 'Logs', 'yotta-memory-backup.log'),
3231
+ electronHost: opts.electronHost,
3090
3232
  }), 'utf8');
3091
3233
  const uid = typeof process.getuid === 'function' ? process.getuid() : '';
3092
3234
  const bootstrap = schedulerTry(opts, 'launchctl', ['bootstrap', 'gui/' + uid, plistPath]);
@@ -3894,7 +4036,8 @@ function doctorCore(opts) {
3894
4036
  opts = opts || {};
3895
4037
  const root = path.resolve(opts.root || userRoot());
3896
4038
  const cfg = loadConfig();
3897
- const identity = identityDiagnostic(resolveIdentity(opts));
4039
+ const resolvedIdentity = resolveIdentity(opts);
4040
+ const identity = identityDiagnostic(resolvedIdentity);
3898
4041
  const checks = {};
3899
4042
  const warnings = [];
3900
4043
  const critical = [];
@@ -3930,6 +4073,23 @@ function doctorCore(opts) {
3930
4073
  } else {
3931
4074
  checks.index = { exists: true, valid: true };
3932
4075
  }
4076
+ // v0.22.6:索引一致性(文件为真源);发现漂移先静默校准,再报告对齐结果。
4077
+ if (exists) {
4078
+ let consistency = null;
4079
+ try { consistency = indexConsistencyReport(root, { identity: resolvedIdentity }); } catch (e) { consistency = null; }
4080
+ if (consistency && consistency.drift) {
4081
+ try { maybeHealIndex(root, { identity: resolvedIdentity, force: true }); } catch (e) { /* ignore */ }
4082
+ try { consistency = indexConsistencyReport(root, { identity: resolvedIdentity }); } catch (e) { consistency = null; }
4083
+ }
4084
+ if (consistency) {
4085
+ checks.index = Object.assign({}, checks.index || {}, {
4086
+ consistent: !consistency.drift,
4087
+ files: consistency.files,
4088
+ entries: consistency.entries,
4089
+ });
4090
+ if (consistency.drift) warnings.push('索引一致性待校准;可运行 yotta-memory reindex。');
4091
+ }
4092
+ }
3933
4093
 
3934
4094
  // v0.17.0 B2:规模体检(只读,不产生写入)
3935
4095
  if (exists) {
@@ -4063,6 +4223,9 @@ function doctorCore(opts) {
4063
4223
  if (checks.layout && (checks.layout.duplicates || checks.layout.conflicts || checks.layout.unverified)) {
4064
4224
  lines.push('- 布局: 同序号重复 ' + checks.layout.duplicates + ' 组 / 冲突 ' + checks.layout.conflicts + ' 组 / 待核 ' + checks.layout.unverified + ' 组');
4065
4225
  }
4226
+ if (checks.index && typeof checks.index.consistent === 'boolean') {
4227
+ lines.push('- 索引一致性: ' + (checks.index.consistent ? '正常(' + checks.index.entries + ' 条 = ' + checks.index.files + ' 个文件)' : '待校准'));
4228
+ }
4066
4229
  for (const message of critical) lines.push('- [严重] ' + message);
4067
4230
  for (const message of warnings) lines.push('- [警告] ' + message);
4068
4231
  if (!critical.length && !warnings.length) lines.push('- 检查项: 全部通过');
@@ -4511,9 +4674,6 @@ function rememberCore(type, subject, statement, opts) {
4511
4674
  }
4512
4675
  }
4513
4676
  }
4514
- const seq = nextSeqFor(typeDir, dir);
4515
- const suffix = (encrypted && scope === 'private') ? ENC_SUFFIX : '';
4516
- const file = path.join(dir, writeDate + '-' + seq + '.md' + suffix);
4517
4677
  const rec = {
4518
4678
  type: t, subject: subj, statement: stmt,
4519
4679
  confidence: 1.0, created: writeDate, updated: writeDate,
@@ -4524,8 +4684,25 @@ function rememberCore(type, subject, statement, opts) {
4524
4684
  weight: (parseFloat(opts.weight) > 0 ? parseFloat(opts.weight) : 1.0),
4525
4685
  access_count: 0, last_accessed: '',
4526
4686
  };
4527
- writeMemoryText(root, file, frontmatterToText(rec, stmt), owner);
4528
- upsertIndexEntry(root, readEntry(file, root));
4687
+ // v0.22.6:序号分配 + 文件落盘 + 索引更新同处一个临界区,防多进程算出同序号互相覆盖。
4688
+ let file = '';
4689
+ withIndexLock(root, function () {
4690
+ const suffix = (encrypted && scope === 'private') ? ENC_SUFFIX : '';
4691
+ let seqNum = parseInt(nextSeqFor(typeDir, dir), 10) || 1;
4692
+ for (let attempt = 0; attempt < 100; attempt++) {
4693
+ const candidate = path.join(dir, writeDate + '-' + String(seqNum).padStart(4, '0') + '.md' + suffix);
4694
+ try {
4695
+ writeMemoryText(root, candidate, frontmatterToText(rec, stmt), owner, { exclusive: true });
4696
+ file = candidate;
4697
+ break;
4698
+ } catch (e) {
4699
+ if (e && e.code === 'EEXIST') { seqNum += 1; continue; }
4700
+ throw e;
4701
+ }
4702
+ }
4703
+ if (!file) throw new Error('无法分配记忆文件名(连续冲突)');
4704
+ upsertIndexEntry(root, readEntry(file, root));
4705
+ });
4529
4706
  let text = '已记录: ' + file;
4530
4707
  if (opts.hint !== false && t === 'FACT') {
4531
4708
  const hit = HINT_KEYWORDS.filter(function (w) { return stmt.indexOf(w) !== -1; });
@@ -6106,7 +6283,8 @@ async function cmdIdentityRemove(id, opts) {
6106
6283
  function readPasswordFromStdin() {
6107
6284
  try {
6108
6285
  const text = fs.readFileSync(0, 'utf8');
6109
- return String(text || '').replace(/\r?\n$/, '');
6286
+ // Windows PowerShell 管道可能带 UTF-8 BOM;BOM 不能成为口令的一部分。
6287
+ return String(text || '').replace(/^\uFEFF/, '').replace(/\r?\n$/, '');
6110
6288
  } catch (e) {
6111
6289
  return '';
6112
6290
  }
@@ -6736,7 +6914,20 @@ function cmdKeyStatus(id, opts) {
6736
6914
  }
6737
6915
 
6738
6916
  // v0.8.1: 查看平台分页——服务端只返回当前页(offset/limit),避免记忆多了一次全量加载/渲染/传输
6739
- function viewEntriesCore(root, session, query, offset, limit, type) {
6917
+ // v0.22.6:列表按文件时间排序需要 stat 全库;3 秒 TTL 缓存让翻页 / 搜索重复请求保持轻快。
6918
+ const VIEW_STAT_CACHE = new Map();
6919
+ const VIEW_STAT_TTL_MS = 3000;
6920
+ function viewStatCached(fp) {
6921
+ const now = Date.now();
6922
+ const hit = VIEW_STAT_CACHE.get(fp);
6923
+ if (hit && now - hit.at < VIEW_STAT_TTL_MS) return hit.st;
6924
+ let st = null;
6925
+ try { st = fs.statSync(fp); } catch (err) { st = null; }
6926
+ if (VIEW_STAT_CACHE.size > 20000) VIEW_STAT_CACHE.clear();
6927
+ VIEW_STAT_CACHE.set(fp, { st: st, at: now });
6928
+ return st;
6929
+ }
6930
+ function viewEntriesCore(root, session, query, offset, limit, type, owner) {
6740
6931
  limit = Math.max(1, parseInt(limit, 10) || 50);
6741
6932
  offset = Math.max(0, parseInt(offset, 10) || 0);
6742
6933
  const q = String(query || '').toLowerCase();
@@ -6748,21 +6939,36 @@ function viewEntriesCore(root, session, query, offset, limit, type) {
6748
6939
  try { for (const e of loadOwnerIndex(root, o, key)) entries.push(e); } catch (err) {}
6749
6940
  }
6750
6941
  let all = q ? entries.filter(function (e) {
6751
- return ((e.subject || '') + ' ' + (e.statement || '') + ' ' + (e.tags || []).join(' ')).toLowerCase().indexOf(q) !== -1;
6942
+ const hay = (e.subject || '') + ' ' + (e.statement || '') + ' ' + (e.tags || []).join(' ') + ' ' + (e.owner || '') + ' ' + (e.file || '');
6943
+ return hay.toLowerCase().indexOf(q) !== -1;
6752
6944
  }) : entries;
6753
6945
  const typeFilter = String(type || '').toUpperCase();
6754
6946
  if (typeFilter && typeFilter !== 'ALL') {
6755
6947
  all = all.filter(function (e) { return String(e.type || '').toUpperCase() === typeFilter; });
6756
6948
  }
6757
- all.sort(function (a, b) { return String(b.updated || b.created || '').localeCompare(String(a.updated || a.created || '')); });
6758
- const count = all.length;
6759
- const page = all.slice(offset, offset + limit).map(function (e) {
6760
- const out = Object.assign({}, e);
6761
- try {
6762
- const st = fs.statSync(path.join(root, String(e.file || '')));
6763
- out.mtime = st.mtime ? st.mtime.toISOString() : '';
6764
- out.btime = st.birthtime && !isNaN(st.birthtime.getTime()) ? st.birthtime.toISOString() : '';
6765
- } catch (err) { /* 文件缺失不影响列表展示 */ }
6949
+ const ownerFilter = String(owner || '').trim();
6950
+ if (ownerFilter) {
6951
+ all = all.filter(function (e) { return String(e.owner || '') === ownerFilter; });
6952
+ }
6953
+ // v0.22.6:默认按时间倒序(文件 mtime 优先,其次 updated/created 与文件名),最新在最前。
6954
+ const decorated = all.map(function (e) {
6955
+ const st = viewStatCached(path.join(root, String(e.file || '')));
6956
+ return { e: e, st: st, t: st && st.mtimeMs ? st.mtimeMs : 0 };
6957
+ });
6958
+ decorated.sort(function (a, b) {
6959
+ if (b.t !== a.t) return b.t - a.t;
6960
+ const ka = String(a.e.updated || a.e.created || '');
6961
+ const kb = String(b.e.updated || b.e.created || '');
6962
+ if (ka !== kb) return kb.localeCompare(ka);
6963
+ return String(b.e.file || '').localeCompare(String(a.e.file || ''));
6964
+ });
6965
+ const count = decorated.length;
6966
+ const page = decorated.slice(offset, offset + limit).map(function (x) {
6967
+ const out = Object.assign({}, x.e);
6968
+ if (x.st) {
6969
+ out.mtime = x.st.mtime ? x.st.mtime.toISOString() : '';
6970
+ out.btime = x.st.birthtime && !isNaN(x.st.birthtime.getTime()) ? x.st.birthtime.toISOString() : '';
6971
+ }
6766
6972
  return out;
6767
6973
  });
6768
6974
  return { count: count, offset: offset, limit: limit, hasMore: offset + limit < count, entries: page };
@@ -7101,6 +7307,7 @@ function viewServerCore(root, port, host, opts) {
7101
7307
  }
7102
7308
  if (!verified) return json(401, { error: owners.length === 0 ? '口令错误;空加密库已用恢复钥匙校验失败。' : '口令错误。' });
7103
7309
  session = { umk: umk, ownerKeys: ownerKeys };
7310
+ try { VIEW_KEYS_CONTEXT.run({ keys: ownerKeys }, function () { maybeHealIndex(root, { force: true }); }); } catch (e) { /* 静默校准失败不阻断解锁 */ }
7104
7311
  json(200, {
7105
7312
  ok: true,
7106
7313
  owners: Object.keys(ownerKeys),
@@ -7120,15 +7327,21 @@ function viewServerCore(root, port, host, opts) {
7120
7327
  }
7121
7328
  if (req.method === 'POST' && pathname === '/api/entries') {
7122
7329
  if (!session.umk) return json(401, { error: '请先解锁。' });
7123
- return readBody(function (d) { json(200, viewEntriesCore(root, session, d.query, d.offset, d.limit, d.type)); });
7330
+ return readBody(function (d) {
7331
+ try { VIEW_KEYS_CONTEXT.run({ keys: session.ownerKeys }, function () { maybeHealIndex(root); }); } catch (e) { /* 静默校准失败不阻断查询 */ }
7332
+ json(200, viewEntriesCore(root, session, d.query, d.offset, d.limit, d.type, d.owner));
7333
+ });
7124
7334
  }
7125
7335
  if (req.method === 'POST' && pathname === '/api/help') {
7126
7336
  if (!session.umk) return json(401, { error: '请先解锁。' });
7127
- return json(200, { version: VERSION, quick: VIEW_HELP_QUICK, groups: HELP_MODEL });
7337
+ return json(200, { version: VERSION, quick: VIEW_HELP_QUICK, groups: HELP_MODEL, configKeys: CONFIG_VALUE_KEYS });
7128
7338
  }
7129
7339
  if (req.method === 'POST' && pathname === '/api/overview') {
7130
7340
  if (!session.umk) return json(401, { error: '请先解锁。' });
7131
- return json(200, VIEW_KEYS_CONTEXT.run({ keys: session.ownerKeys }, function () { return viewOverviewCore(root, session); }));
7341
+ return json(200, VIEW_KEYS_CONTEXT.run({ keys: session.ownerKeys }, function () {
7342
+ try { maybeHealIndex(root); } catch (e) { /* 静默校准失败不阻断概览 */ }
7343
+ return viewOverviewCore(root, session);
7344
+ }));
7132
7345
  }
7133
7346
  if (req.method === 'POST' && pathname === '/api/memory/create') {
7134
7347
  if (!session.umk) return json(401, { error: '请先解锁。' });
@@ -7278,6 +7491,7 @@ function viewServerCore(root, port, host, opts) {
7278
7491
  }
7279
7492
  console.error(message);
7280
7493
  });
7494
+ try { maybeHealIndex(root, { force: true }); } catch (e) { /* 启动静默校准失败不阻断服务 */ }
7281
7495
  server.listen(port, host, function () {
7282
7496
  console.log('yotta-memory 用户查看平台已启动(v' + VERSION + ')');
7283
7497
  console.log('URL: http://' + host + ':' + port + '(默认仅本机 127.0.0.1;远程需 --host 显式开启)');
@@ -10394,11 +10608,13 @@ const LAN_GEN_MARKER = 'Generated by yotta-memory lan enable';
10394
10608
  const LAN_UNIT_NAME = 'yotta-memory-serve.service';
10395
10609
  const LAN_CRONTAB_MARKER = '#YTM_LAN:yotta-memory-serve';
10396
10610
  function lanTaskRunCmd(opts) {
10611
+ opts = opts || {};
10397
10612
  const host = opts.host || '0.0.0.0';
10398
10613
  const port = opts.port || 8787;
10399
10614
  // schtasks /tr 不接受多余内嵌引号:路径无空格不加引号,含空格/引号用 \" 转义(0.5.2 修复)
10400
10615
  const q = (p) => /[\s"]/.test(p) ? '\\"' + p.replace(/"/g, '\\"') + '\\"' : p;
10401
- return q(process.execPath) + ' ' + q(runtimeManagedScript()) + ' serve --host ' + host + ' --port ' + port;
10616
+ const prefix = lanElectronHost(opts) ? 'set "ELECTRON_RUN_AS_NODE=1" && ' : '';
10617
+ return prefix + q(process.execPath) + ' ' + q(runtimeManagedScript()) + ' serve --host ' + host + ' --port ' + port;
10402
10618
  }
10403
10619
 
10404
10620
  function lanServeArgs(opts) {
@@ -10414,6 +10630,28 @@ function lanSpawn(bin, args, spawnOpts) {
10414
10630
  }
10415
10631
  // platform override for testing/advanced use (YOTTA_LAN_PLATFORM=linux/win32/...)
10416
10632
  function lanPlatform() { return process.env.YOTTA_LAN_PLATFORM || process.platform; }
10633
+ // ---- v0.22.5: Electron 宿主(YottaCode shim)适配 ----
10634
+ // 背景:宿主运行时为 Electron 时(如 YottaCode.exe 以 ELECTRON_RUN_AS_NODE=1 当 Node 运行本脚本),
10635
+ // process.execPath 指向 Electron 可执行文件;自启 / 定时任务环境不带 ELECTRON_RUN_AS_NODE=1 时,
10636
+ // 拉起的是桌面程序本体而非 Node 服务(0.22.4 后新机首例实测)。生成任务 / 兜底 / 定时配置时统一注入。
10637
+ function lanElectronHost(opts) {
10638
+ opts = opts || {};
10639
+ if (typeof opts.electronHost === 'boolean') return opts.electronHost;
10640
+ if (process.env.YOTTA_LAN_ELECTRON_HOST === '1') return true;
10641
+ if (process.env.YOTTA_LAN_ELECTRON_HOST === '0') return false;
10642
+ return !!(process.versions && process.versions.electron);
10643
+ }
10644
+ // 任务 / 兜底命令是否指向 Electron 可执行文件且缺少 ELECTRON_RUN_AS_NODE(旧任务告警用)
10645
+ function lanTaskCommandNeedsElectronEnv(text) {
10646
+ const t = String(text || '');
10647
+ if (!t || /ELECTRON_RUN_AS_NODE/.test(t)) return false;
10648
+ if (/electron|yottacode/i.test(t)) return true;
10649
+ if (lanElectronHost({})) {
10650
+ const exe = String(process.execPath || '');
10651
+ if (exe && t.toLowerCase().indexOf(exe.toLowerCase()) !== -1) return true;
10652
+ }
10653
+ return false;
10654
+ }
10417
10655
  // sh-style single-quote quoting (crontab line is executed via /bin/sh -c)
10418
10656
  function shQuote(s) {
10419
10657
  const p = String(s);
@@ -10448,13 +10686,16 @@ function lanLogPath(opts) {
10448
10686
  return path.join(os.homedir(), '.yottamemory', 'serve-' + (opts.port || 8787) + '.log');
10449
10687
  }
10450
10688
  function lanAutostartCmdContent(opts) {
10689
+ opts = opts || {};
10451
10690
  const host = opts.host || '0.0.0.0';
10452
10691
  const port = opts.port || 8787;
10453
10692
  const q = (p) => '"' + String(p).replace(/"/g, '""') + '"';
10454
10693
  // 生成文件用纯 ASCII(避免 cmd/VBS 按系统代码页读取中文注释乱码;VBS 自愈内联同一内容)
10694
+ // v0.22.5: Electron 宿主下需先设 ELECTRON_RUN_AS_NODE=1,否则拉起桌面程序本体而非 Node。
10455
10695
  return '@echo off\r\n'
10456
10696
  + 'chcp 65001 >nul\r\n'
10457
10697
  + 'rem ' + LAN_GEN_MARKER + '; remove with: yotta-memory lan disable (English only, keep ASCII)\r\n'
10698
+ + (lanElectronHost(opts) ? 'set "ELECTRON_RUN_AS_NODE=1"\r\n' : '')
10458
10699
  + q(process.execPath) + ' ' + q(runtimeManagedScript()) + ' serve --host ' + host + ' --port ' + port + ' >> ' + q(lanLogPath(opts)) + ' 2>&1\r\n';
10459
10700
  }
10460
10701
  function lanVbsContent(opts) {
@@ -10574,7 +10815,10 @@ function lanWindowsTaskXml(opts) {
10574
10815
  // 让 node/脚本/日志路径里的空格与中文都能安全传递;S4U 非交互会话下不弹窗口。
10575
10816
  // 注意:重定向 `2>&1` 的 & 在 XML 文本里必须写成 &amp;(DOM 解码后仍是 2>&1),
10576
10817
  // 否则 schtasks 报 "The task XML is malformed"。
10577
- const inner = '"' + xmlEscape(nodePath) + '" "' + xmlEscape(scriptPath) + '" serve --host ' + host + ' --port ' + port
10818
+ // v0.22.5: Electron 宿主(YottaCode shim)下先 set ELECTRON_RUN_AS_NODE=1,
10819
+ // 否则任务会拉起桌面程序本体(GUI)而不是 Node 服务。
10820
+ const envPrefix = lanElectronHost(opts) ? 'set "ELECTRON_RUN_AS_NODE=1" &amp;&amp; ' : '';
10821
+ const inner = envPrefix + '"' + xmlEscape(nodePath) + '" "' + xmlEscape(scriptPath) + '" serve --host ' + host + ' --port ' + port
10578
10822
  + ' >> "' + xmlEscape(logPath) + '" 2>&amp;1';
10579
10823
  const triggerXml = onstart
10580
10824
  ? '<BootTrigger><Enabled>true</Enabled></BootTrigger>'
@@ -10616,7 +10860,7 @@ function lanWindowsEnableCore(opts) {
10616
10860
  const scriptPath = opts.scriptPath || runtimeManagedScript();
10617
10861
  const logPath = lanLogPath(opts);
10618
10862
  const userId = lanWindowsTaskUser(opts);
10619
- const xml = lanWindowsTaskXml({ trigger: trigger, host: host, port: port, nodePath: nodePath, scriptPath: scriptPath, logPath: logPath, userId: userId });
10863
+ const xml = lanWindowsTaskXml({ trigger: trigger, host: host, port: port, nodePath: nodePath, scriptPath: scriptPath, logPath: logPath, userId: userId, electronHost: opts.electronHost });
10620
10864
  const xmlDir = opts.xmlDir || process.env.YOTTA_LAN_TASK_XML_DIR || os.tmpdir();
10621
10865
  const xmlPath = opts.xmlPath || path.join(xmlDir, LAN_TASK_NAME + '-task-' + process.pid + '-' + Date.now() + '.xml');
10622
10866
  let failure = null;
@@ -10668,7 +10912,7 @@ function lanLinuxExecStart(opts) {
10668
10912
  }
10669
10913
  function lanLinuxUnitContent(opts) {
10670
10914
  // keep comments ASCII (avoid locale/encoding issues); ExecStart uses systemd's own quote rules
10671
- return [
10915
+ const lines = [
10672
10916
  '# ' + LAN_GEN_MARKER + '; remove with: yotta-memory lan disable (English only, keep ASCII)',
10673
10917
  '',
10674
10918
  '[Unit]',
@@ -10676,6 +10920,9 @@ function lanLinuxUnitContent(opts) {
10676
10920
  '',
10677
10921
  '[Service]',
10678
10922
  'Type=simple',
10923
+ ];
10924
+ if (lanElectronHost(opts)) lines.push('Environment=ELECTRON_RUN_AS_NODE=1');
10925
+ lines.push(
10679
10926
  'ExecStart=' + lanLinuxExecStart(opts),
10680
10927
  'Restart=on-failure',
10681
10928
  'RestartSec=3',
@@ -10683,12 +10930,15 @@ function lanLinuxUnitContent(opts) {
10683
10930
  '[Install]',
10684
10931
  'WantedBy=default.target',
10685
10932
  '',
10686
- ].join('\n');
10933
+ );
10934
+ return lines.join('\n');
10687
10935
  }
10688
10936
  function lanCrontabBin() { return process.env.YOTTA_LAN_CRONTAB_BIN || 'crontab'; }
10689
10937
  function lanCrontabLine(opts) {
10690
- const parts = ['@reboot', shQuote(process.execPath), shQuote(runtimeManagedScript())].concat(lanServeArgs(opts));
10691
- return parts.join(' ') + ' >> ' + shQuote(lanLogPath(opts)) + ' 2>&1 ' + LAN_CRONTAB_MARKER;
10938
+ const parts = ['@reboot'];
10939
+ if (lanElectronHost(opts)) parts.push('ELECTRON_RUN_AS_NODE=1');
10940
+ parts.push(shQuote(process.execPath), shQuote(runtimeManagedScript()));
10941
+ return parts.concat(lanServeArgs(opts)).join(' ') + ' >> ' + shQuote(lanLogPath(opts)) + ' 2>&1 ' + LAN_CRONTAB_MARKER;
10692
10942
  }
10693
10943
  function lanCrontabRead() {
10694
10944
  try {
@@ -10906,17 +11156,22 @@ function cmdLanWinStatus() {
10906
11156
  if (q.status === 0) {
10907
11157
  taskFound = true;
10908
11158
  console.log('计划任务 ' + LAN_TASK_NAME + ': 已启用');
10909
- const re = /^(状态|Status|登录模式|Logon Mode|上次运行时间|Last Run Time|上次结果|Last Result)\s*:\s*(.+)$/;
11159
+ const re = /^(状态|Status|登录模式|Logon Mode|上次运行时间|Last Run Time|上次结果|Last Result|要运行的任务|Task To Run)\s*[:=]\s*(.+)$/;
10910
11160
  let logonMode = '';
11161
+ let taskRun = '';
10911
11162
  for (const line of String(q.stdout).split(/\r?\n/)) {
10912
11163
  const m = re.exec(line.trim());
10913
11164
  if (!m) continue;
10914
11165
  console.log(' ' + m[1] + ': ' + m[2]);
10915
11166
  if (/^(登录模式|Logon Mode)$/.test(m[1])) logonMode = m[2];
11167
+ if (/^(要运行的任务|Task To Run)$/.test(m[1])) taskRun = m[2];
10916
11168
  }
10917
11169
  if (/interactive|交互式/i.test(logonMode)) {
10918
11170
  console.log(' 提示: 登录模式为交互式(仅登录时运行,且在登录会话会弹窗口)——这是旧版任务;请重新执行 yotta-memory lan enable 修复为后台静默');
10919
11171
  }
11172
+ if (lanTaskCommandNeedsElectronEnv(taskRun)) {
11173
+ console.log(' 警告: 任务命令指向 Electron 可执行文件(如 YottaCode.exe)但缺少 ELECTRON_RUN_AS_NODE —— 开机将拉起桌面程序而非服务;请重新执行 yotta-memory lan enable 修复');
11174
+ }
10920
11175
  } else {
10921
11176
  console.log('计划任务 ' + LAN_TASK_NAME + ': 未启用');
10922
11177
  }
@@ -10926,6 +11181,12 @@ function cmdLanWinStatus() {
10926
11181
  console.log('Startup 静默自启(免管理员): 已启用');
10927
11182
  console.log(' 启动脚本: ' + lanVbsPath());
10928
11183
  console.log(' 启动命令: ' + lanAutostartCmdPath());
11184
+ try {
11185
+ const cmdText = fs.readFileSync(lanAutostartCmdPath(), 'utf8');
11186
+ if (lanTaskCommandNeedsElectronEnv(cmdText)) {
11187
+ console.log(' 警告: Startup 兜底脚本缺少 ELECTRON_RUN_AS_NODE —— Electron 宿主下会拉起桌面程序而非服务;请重新执行 yotta-memory lan enable 修复');
11188
+ }
11189
+ } catch (e) { /* 读取失败不影响状态输出 */ }
10929
11190
  } else if (vbsOk || cmdOk) {
10930
11191
  console.log('Startup 静默自启: 部分残留(建议执行 yotta-memory lan disable 后重新 lan enable)');
10931
11192
  } else {
@@ -12051,6 +12312,7 @@ if (require.main === module) { main().catch(function (e) { console.error('错误
12051
12312
  module.exports = {
12052
12313
  VERSION: VERSION,
12053
12314
  HELP_MODEL: HELP_MODEL,
12315
+ CONFIG_VALUE_KEYS: CONFIG_VALUE_KEYS,
12054
12316
  VIEW_HELP_QUICK: VIEW_HELP_QUICK,
12055
12317
  CLI_FLAG_OPTS: CLI_FLAG_OPTS,
12056
12318
  CLI_VALUE_OPTS: CLI_VALUE_OPTS,
@@ -12092,6 +12354,11 @@ module.exports = {
12092
12354
  saveTokens: saveTokens,
12093
12355
  collectEntryFiles: collectEntryFiles,
12094
12356
  walkEntryFiles: walkEntryFiles,
12357
+ withIndexLock: withIndexLock,
12358
+ indexConsistencyReport: indexConsistencyReport,
12359
+ maybeHealIndex: maybeHealIndex,
12360
+ viewEntriesCore: viewEntriesCore,
12361
+ viewOverviewCore: viewOverviewCore,
12095
12362
  migrateLayout: migrateLayout,
12096
12363
  loadIndexFor: loadIndexFor,
12097
12364
  indexEntriesFor: indexEntriesFor,
@@ -12160,6 +12427,7 @@ module.exports = {
12160
12427
  lanServeArgs: lanServeArgs,
12161
12428
  lanSpawn: lanSpawn,
12162
12429
  lanPlatform: lanPlatform,
12430
+ lanElectronHost: lanElectronHost,
12163
12431
  shQuote: shQuote,
12164
12432
  systemdEscapeArg: systemdEscapeArg,
12165
12433
  lanLinuxSystemdUserDir: lanLinuxSystemdUserDir,