@yottameta/yotta-memory 0.22.5 → 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.
- package/CHANGELOG.md +11 -0
- package/SKILL.md +1 -1
- package/assets/view.html +33 -6
- package/bin/install.js +12 -1
- package/bin/yotta-memory.js +333 -130
- package/install.sh +10 -1
- package/package.json +1 -1
- package/skill-manifest.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
## v0.22.6 (2026-10-09)
|
|
2
|
+
|
|
3
|
+
- **并发写入加固**:索引更新改为跨进程互斥(`.index.lock`,mkdir 原子语义 + 陈旧锁自动回收)+ 原子落盘(临时文件 + rename);序号分配 / 记忆落盘 / 索引更新同处一个临界区,多智能体(多宿主、多 MCP 进程)同时写入更稳。
|
|
4
|
+
- **索引一致性维护**:以记忆文件为唯一真源,读取路径与用户平台在后台做轻量一致性校准(自动对齐,无需用户操作;不修改任何记忆文件)。
|
|
5
|
+
- **管理台搜索增强**:搜索支持按编号(文件名)与归属智能体查找(原支持标题 / 内容 / 标签)。
|
|
6
|
+
- **管理台 CLI 页补全配置键**:高级 / CLI 页新增 39 个配置键速查(`config set` 单一真源,含 `memory_home` / `backup_*` / `maintain_*` / `consolidate_*` / `scale_*` 等),可直接搜索与复制。
|
|
7
|
+
- **doctor**:新增索引一致性检查(条数 = 文件数)。
|
|
8
|
+
- **安装器洁净度**:顶层跳过并清理 `*.tgz` 与 `test-fixtures/`(与 npm 包白名单一致),安装副本不再携带源码侧残留。
|
|
9
|
+
- 新增 `test/index-consistency.test.js` 5 项(一致性检测与校准、幂等、6 进程并发 120 条零丢失、跨进程锁互斥、显示与实际对账 + 编号 / 归属搜索)。
|
|
10
|
+
- 新增 `test/password-stdin-bom.test.js`(Windows PowerShell 管道 BOM)与 `test/installer-hygiene.test.js`(薄片洁净度 + 残留清理)。
|
|
11
|
+
|
|
1
12
|
## v0.22.5 (2026-10-07)
|
|
2
13
|
|
|
3
14
|
- **修复 Electron 宿主(YottaCode shim)下开机自启拉起 GUI 而非服务**:宿主运行时为 Electron 时(`process.versions.electron`;`process.execPath` 指向 Electron 可执行文件),此前生成的任务 / 兜底 / 定时配置不带 `ELECTRON_RUN_AS_NODE=1`,开机拉起的是桌面程序本体、8787 无监听(新机首例实测)。现在自动检测并统一注入:Windows 主任务与每日备份任务(cmd /c + `set "ELECTRON_RUN_AS_NODE=1"`)、Windows Startup 兜底 .cmd(set 前缀)、Linux systemd 单元 / crontab @reboot / 备份定时器(`Environment=` / 行内前缀)、macOS 备份 LaunchAgent(EnvironmentVariables)。
|
package/SKILL.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: yotta-memory
|
|
3
3
|
description: 元忆 —— 有权限边界的文件式智能体记忆。文件式、零依赖、可 diff/可回滚:让任何 AI 智能体活过会话,开工 recall 恢复上下文、重要信息 remember 落盘、收工归档。类型体系 FACT(公共共享)/ PREF / BOUND / COMMIT(私密隔离)。触发:记住、别忘了、记一笔、记忆、remember、recall、跨会话、上次说到、续测、交接、归档、记忆盘、共享记忆、局域网记忆、画像、开工上下文、长期理解摘要、近期走廊、会话闭环、记忆守则、profile、context、越用越懂、语义检索、反馈、维护、蒸馏、feedback、maintain、distill、explain、自我学习、自我进化、自我提升、查看平台分页、recall 候选预过滤、任务相关记忆、--focus、--embedding、压缩遗忘、consolidate、周期摘要、自动合并、分类型衰减、回滚、备份、backup、防误删、doctor、事务快照、基线探针、恢复演练、记忆库安全扫描、scan、命中打点、容量水位、--capacity、--propose、上下文压缩审计、context --audit
|
|
4
|
-
version: 0.22.
|
|
4
|
+
version: 0.22.6
|
|
5
5
|
license: MIT
|
|
6
6
|
---
|
|
7
7
|
|
package/assets/view.html
CHANGED
|
@@ -250,7 +250,7 @@
|
|
|
250
250
|
|
|
251
251
|
<section class="view" id="view-memories">
|
|
252
252
|
<div class="card toolbar">
|
|
253
|
-
<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="搜索记忆(标题 / 内容 /
|
|
253
|
+
<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>
|
|
254
254
|
<div class="chips" id="typeChips">
|
|
255
255
|
<button class="chip active" data-type="all">全部</button>
|
|
256
256
|
<button class="chip" data-type="FACT">FACT</button>
|
|
@@ -258,6 +258,7 @@
|
|
|
258
258
|
<button class="chip" data-type="BOUND">BOUND</button>
|
|
259
259
|
<button class="chip" data-type="COMMIT">COMMIT</button>
|
|
260
260
|
</div>
|
|
261
|
+
<select id="ownerFilter" title="按智能体筛选" style="width:auto;min-width:140px"><option value="">全部智能体</option></select>
|
|
261
262
|
<div class="spacer"></div>
|
|
262
263
|
<button class="btn" id="btnArchived">已藏</button>
|
|
263
264
|
<button class="btn" id="btnTrash">最近删除</button>
|
|
@@ -329,7 +330,7 @@
|
|
|
329
330
|
(function () {
|
|
330
331
|
'use strict';
|
|
331
332
|
var $ = function (s) { return document.querySelector(s); };
|
|
332
|
-
var state = { view: 'overview', entries: [], count: 0, offset: 0, limit: 20, query: '', type: 'all', owners: [], overview: null, current: null, help: null };
|
|
333
|
+
var state = { view: 'overview', entries: [], count: 0, offset: 0, limit: 20, query: '', type: 'all', owner: '', owners: [], overview: null, current: null, help: null };
|
|
333
334
|
var typeNames = { FACT: 'FACT 事实', PREF: 'PREF 偏好', BOUND: 'BOUND 边界', COMMIT: 'COMMIT 承诺' };
|
|
334
335
|
|
|
335
336
|
function esc(s) {
|
|
@@ -437,14 +438,24 @@
|
|
|
437
438
|
$('#ovBytes').textContent = fmtBytes(d.bytes);
|
|
438
439
|
$('#accBackup').textContent = d.backupDir || '未配置';
|
|
439
440
|
var colors = { FACT: 'var(--gold)', PREF: '#b79cdd', BOUND: '#8fb4de', COMMIT: 'var(--ok)' };
|
|
441
|
+
var byType = d.byType || {};
|
|
442
|
+
var typeCounts = { all: d.total || 0, FACT: byType.FACT || 0, PREF: byType.PREF || 0, BOUND: byType.BOUND || 0, COMMIT: byType.COMMIT || 0 };
|
|
440
443
|
$('#typeBars').innerHTML = ['FACT', 'PREF', 'BOUND', 'COMMIT'].map(function (t) {
|
|
441
|
-
|
|
442
|
-
return '<div class="bar"><span class="dot" style="background:' + colors[t] + '"></span><span class="faint">' + typeNames[t] + '</span><b>' + n + '</b></div>';
|
|
444
|
+
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>';
|
|
443
445
|
}).join('');
|
|
446
|
+
Array.prototype.forEach.call(document.querySelectorAll('#typeChips .chip'), function (c) {
|
|
447
|
+
c.textContent = (c.dataset.type === 'all' ? '全部' : c.dataset.type) + ' ' + (typeCounts[c.dataset.type] || 0);
|
|
448
|
+
});
|
|
444
449
|
}
|
|
445
450
|
async function loadOwners() {
|
|
446
451
|
var d = await api('/api/owners');
|
|
447
452
|
state.owners = (d && d.owners) || [];
|
|
453
|
+
var sel = $('#ownerFilter');
|
|
454
|
+
if (sel) {
|
|
455
|
+
sel.innerHTML = '<option value="">全部智能体</option>' + state.owners.map(function (o) {
|
|
456
|
+
return '<option value="' + esc(o.owner) + '"' + (o.owner === state.owner ? ' selected' : '') + '>' + esc(o.owner) + '</option>';
|
|
457
|
+
}).join('');
|
|
458
|
+
}
|
|
448
459
|
var box = $('#ownerRows');
|
|
449
460
|
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; }
|
|
450
461
|
box.innerHTML = state.owners.map(function (o) {
|
|
@@ -456,7 +467,7 @@
|
|
|
456
467
|
}).join('');
|
|
457
468
|
}
|
|
458
469
|
async function loadEntries() {
|
|
459
|
-
var d = await api('/api/entries', { query: state.query, offset: state.offset, limit: state.limit, type: state.type === 'all' ? '' : state.type });
|
|
470
|
+
var d = await api('/api/entries', { query: state.query, offset: state.offset, limit: state.limit, type: state.type === 'all' ? '' : state.type, owner: state.owner || '' });
|
|
460
471
|
if (d.error) { toast(errText(d, '加载失败')); return; }
|
|
461
472
|
state.entries = d.entries || [];
|
|
462
473
|
state.count = d.count || 0;
|
|
@@ -669,6 +680,15 @@
|
|
|
669
680
|
Array.prototype.forEach.call(document.querySelectorAll('#typeChips .chip'), function (c) { c.classList.toggle('active', c === b); });
|
|
670
681
|
loadEntries();
|
|
671
682
|
});
|
|
683
|
+
$('#typeBars').addEventListener('click', function (e) {
|
|
684
|
+
var bar = e.target.closest('.bar[data-type]');
|
|
685
|
+
if (!bar) return;
|
|
686
|
+
state.type = bar.dataset.type; state.offset = 0;
|
|
687
|
+
Array.prototype.forEach.call(document.querySelectorAll('#typeChips .chip'), function (c) { c.classList.toggle('active', c.dataset.type === state.type); });
|
|
688
|
+
switchView('memories');
|
|
689
|
+
loadEntries();
|
|
690
|
+
});
|
|
691
|
+
$('#ownerFilter').addEventListener('change', function () { state.owner = this.value; state.offset = 0; loadEntries(); });
|
|
672
692
|
$('#prevPage').addEventListener('click', function () { if (state.offset > 0) { state.offset -= state.limit; loadEntries(); } });
|
|
673
693
|
$('#nextPage').addEventListener('click', function () { state.offset += state.limit; loadEntries(); });
|
|
674
694
|
|
|
@@ -803,7 +823,7 @@
|
|
|
803
823
|
}
|
|
804
824
|
function renderFull() {
|
|
805
825
|
if (!state.help) return;
|
|
806
|
-
|
|
826
|
+
var groupsHtml = (state.help.groups || []).map(function (g) {
|
|
807
827
|
var cmds = (g.commands || []).map(function (c) {
|
|
808
828
|
var subs = (c.subcommands || []).map(function (s) {
|
|
809
829
|
return '<div class="help-sub"><div class="cmd-head"><code class="cmd-name">' + esc(c.name + ' ' + s.name) + '</code></div>' +
|
|
@@ -817,6 +837,13 @@
|
|
|
817
837
|
}).join('');
|
|
818
838
|
return '<div class="quick-group"><h4>' + esc(g.group) + '</h4>' + cmds + '</div>';
|
|
819
839
|
}).join('');
|
|
840
|
+
var keys = state.help.configKeys || [];
|
|
841
|
+
var keysHtml = keys.length ? '<div class="quick-group" id="cliConfigKeys"><h4>配置键(yotta-memory config set <键> <值>)</h4><div class="quick-list">' +
|
|
842
|
+
keys.map(function (k) {
|
|
843
|
+
return '<div class="cmd-item"><div class="cmd-head"><code class="cmd-name">' + esc(k) + '</code></div>' +
|
|
844
|
+
codeLine('yotta-memory config set ' + k + ' <值>') + '</div>';
|
|
845
|
+
}).join('') + '</div></div>' : '';
|
|
846
|
+
$('#cliFull').innerHTML = groupsHtml + keysHtml;
|
|
820
847
|
}
|
|
821
848
|
async function ensureHelp() {
|
|
822
849
|
if (state.help) { renderQuick(); return; }
|
package/bin/install.js
CHANGED
|
@@ -27,7 +27,7 @@ const PKG_ROOT = path.join(__dirname, '..');
|
|
|
27
27
|
const TOP_SKIP = [
|
|
28
28
|
'package.json', 'package-lock.json', 'bin', 'lib', 'test',
|
|
29
29
|
'.github', '.git', '.gitignore', '.npmignore', '.gitattributes',
|
|
30
|
-
'.yotta', '.tmp', 'install.sh', 'node_modules',
|
|
30
|
+
'.yotta', '.tmp', 'install.sh', 'node_modules', 'test-fixtures',
|
|
31
31
|
];
|
|
32
32
|
const TOP_SKIP_SET = new Set(TOP_SKIP);
|
|
33
33
|
// 缓存 / 编译产物:任意层级跳过,安装时同样清理。
|
|
@@ -190,6 +190,7 @@ function copyDir(src, dst, topLevel) {
|
|
|
190
190
|
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
191
191
|
// 只跳过安装包顶层开发文件;template/ 等嵌套同名载荷必须保留。
|
|
192
192
|
if (topLevel && TOP_SKIP_SET.has(entry.name)) continue;
|
|
193
|
+
if (topLevel && entry.isFile() && /\.tgz$/i.test(entry.name)) continue;
|
|
193
194
|
if (shouldSkipCache(entry.name, entry.isFile())) continue;
|
|
194
195
|
const s = path.join(src, entry.name);
|
|
195
196
|
const d = path.join(dst, entry.name);
|
|
@@ -219,6 +220,16 @@ function cleanResidue(target) {
|
|
|
219
220
|
fs.rmSync(p, { recursive: true, force: true });
|
|
220
221
|
removed.push(name);
|
|
221
222
|
}
|
|
223
|
+
try {
|
|
224
|
+
for (const name of fs.readdirSync(target)) {
|
|
225
|
+
if (!/\.tgz$/i.test(name)) continue;
|
|
226
|
+
const p = path.join(target, name);
|
|
227
|
+
const entry = fs.lstatSync(p);
|
|
228
|
+
if (entry.isSymbolicLink() || !entry.isFile()) continue;
|
|
229
|
+
fs.rmSync(p, { force: true });
|
|
230
|
+
removed.push(name);
|
|
231
|
+
}
|
|
232
|
+
} catch (_) { /* ignore */ }
|
|
222
233
|
const walk = (dir) => {
|
|
223
234
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
224
235
|
const p = path.join(dir, entry.name);
|
package/bin/yotta-memory.js
CHANGED
|
@@ -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.
|
|
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 <钥匙></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 { '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[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 <钥匙></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 { '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[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 <键> <值>)</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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
const
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
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
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
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
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
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
|
-
|
|
2481
|
-
|
|
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
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
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
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
const
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
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
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
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
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
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
|
-
|
|
2661
|
-
}
|
|
2784
|
+
});
|
|
2662
2785
|
return;
|
|
2663
2786
|
}
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
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);
|
|
@@ -3911,7 +4036,8 @@ function doctorCore(opts) {
|
|
|
3911
4036
|
opts = opts || {};
|
|
3912
4037
|
const root = path.resolve(opts.root || userRoot());
|
|
3913
4038
|
const cfg = loadConfig();
|
|
3914
|
-
const
|
|
4039
|
+
const resolvedIdentity = resolveIdentity(opts);
|
|
4040
|
+
const identity = identityDiagnostic(resolvedIdentity);
|
|
3915
4041
|
const checks = {};
|
|
3916
4042
|
const warnings = [];
|
|
3917
4043
|
const critical = [];
|
|
@@ -3947,6 +4073,23 @@ function doctorCore(opts) {
|
|
|
3947
4073
|
} else {
|
|
3948
4074
|
checks.index = { exists: true, valid: true };
|
|
3949
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
|
+
}
|
|
3950
4093
|
|
|
3951
4094
|
// v0.17.0 B2:规模体检(只读,不产生写入)
|
|
3952
4095
|
if (exists) {
|
|
@@ -4080,6 +4223,9 @@ function doctorCore(opts) {
|
|
|
4080
4223
|
if (checks.layout && (checks.layout.duplicates || checks.layout.conflicts || checks.layout.unverified)) {
|
|
4081
4224
|
lines.push('- 布局: 同序号重复 ' + checks.layout.duplicates + ' 组 / 冲突 ' + checks.layout.conflicts + ' 组 / 待核 ' + checks.layout.unverified + ' 组');
|
|
4082
4225
|
}
|
|
4226
|
+
if (checks.index && typeof checks.index.consistent === 'boolean') {
|
|
4227
|
+
lines.push('- 索引一致性: ' + (checks.index.consistent ? '正常(' + checks.index.entries + ' 条 = ' + checks.index.files + ' 个文件)' : '待校准'));
|
|
4228
|
+
}
|
|
4083
4229
|
for (const message of critical) lines.push('- [严重] ' + message);
|
|
4084
4230
|
for (const message of warnings) lines.push('- [警告] ' + message);
|
|
4085
4231
|
if (!critical.length && !warnings.length) lines.push('- 检查项: 全部通过');
|
|
@@ -4528,9 +4674,6 @@ function rememberCore(type, subject, statement, opts) {
|
|
|
4528
4674
|
}
|
|
4529
4675
|
}
|
|
4530
4676
|
}
|
|
4531
|
-
const seq = nextSeqFor(typeDir, dir);
|
|
4532
|
-
const suffix = (encrypted && scope === 'private') ? ENC_SUFFIX : '';
|
|
4533
|
-
const file = path.join(dir, writeDate + '-' + seq + '.md' + suffix);
|
|
4534
4677
|
const rec = {
|
|
4535
4678
|
type: t, subject: subj, statement: stmt,
|
|
4536
4679
|
confidence: 1.0, created: writeDate, updated: writeDate,
|
|
@@ -4541,8 +4684,25 @@ function rememberCore(type, subject, statement, opts) {
|
|
|
4541
4684
|
weight: (parseFloat(opts.weight) > 0 ? parseFloat(opts.weight) : 1.0),
|
|
4542
4685
|
access_count: 0, last_accessed: '',
|
|
4543
4686
|
};
|
|
4544
|
-
|
|
4545
|
-
|
|
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
|
+
});
|
|
4546
4706
|
let text = '已记录: ' + file;
|
|
4547
4707
|
if (opts.hint !== false && t === 'FACT') {
|
|
4548
4708
|
const hit = HINT_KEYWORDS.filter(function (w) { return stmt.indexOf(w) !== -1; });
|
|
@@ -6123,7 +6283,8 @@ async function cmdIdentityRemove(id, opts) {
|
|
|
6123
6283
|
function readPasswordFromStdin() {
|
|
6124
6284
|
try {
|
|
6125
6285
|
const text = fs.readFileSync(0, 'utf8');
|
|
6126
|
-
|
|
6286
|
+
// Windows PowerShell 管道可能带 UTF-8 BOM;BOM 不能成为口令的一部分。
|
|
6287
|
+
return String(text || '').replace(/^\uFEFF/, '').replace(/\r?\n$/, '');
|
|
6127
6288
|
} catch (e) {
|
|
6128
6289
|
return '';
|
|
6129
6290
|
}
|
|
@@ -6753,7 +6914,20 @@ function cmdKeyStatus(id, opts) {
|
|
|
6753
6914
|
}
|
|
6754
6915
|
|
|
6755
6916
|
// v0.8.1: 查看平台分页——服务端只返回当前页(offset/limit),避免记忆多了一次全量加载/渲染/传输
|
|
6756
|
-
|
|
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) {
|
|
6757
6931
|
limit = Math.max(1, parseInt(limit, 10) || 50);
|
|
6758
6932
|
offset = Math.max(0, parseInt(offset, 10) || 0);
|
|
6759
6933
|
const q = String(query || '').toLowerCase();
|
|
@@ -6765,21 +6939,36 @@ function viewEntriesCore(root, session, query, offset, limit, type) {
|
|
|
6765
6939
|
try { for (const e of loadOwnerIndex(root, o, key)) entries.push(e); } catch (err) {}
|
|
6766
6940
|
}
|
|
6767
6941
|
let all = q ? entries.filter(function (e) {
|
|
6768
|
-
|
|
6942
|
+
const hay = (e.subject || '') + ' ' + (e.statement || '') + ' ' + (e.tags || []).join(' ') + ' ' + (e.owner || '') + ' ' + (e.file || '');
|
|
6943
|
+
return hay.toLowerCase().indexOf(q) !== -1;
|
|
6769
6944
|
}) : entries;
|
|
6770
6945
|
const typeFilter = String(type || '').toUpperCase();
|
|
6771
6946
|
if (typeFilter && typeFilter !== 'ALL') {
|
|
6772
6947
|
all = all.filter(function (e) { return String(e.type || '').toUpperCase() === typeFilter; });
|
|
6773
6948
|
}
|
|
6774
|
-
|
|
6775
|
-
|
|
6776
|
-
|
|
6777
|
-
|
|
6778
|
-
|
|
6779
|
-
|
|
6780
|
-
|
|
6781
|
-
|
|
6782
|
-
|
|
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
|
+
}
|
|
6783
6972
|
return out;
|
|
6784
6973
|
});
|
|
6785
6974
|
return { count: count, offset: offset, limit: limit, hasMore: offset + limit < count, entries: page };
|
|
@@ -7118,6 +7307,7 @@ function viewServerCore(root, port, host, opts) {
|
|
|
7118
7307
|
}
|
|
7119
7308
|
if (!verified) return json(401, { error: owners.length === 0 ? '口令错误;空加密库已用恢复钥匙校验失败。' : '口令错误。' });
|
|
7120
7309
|
session = { umk: umk, ownerKeys: ownerKeys };
|
|
7310
|
+
try { VIEW_KEYS_CONTEXT.run({ keys: ownerKeys }, function () { maybeHealIndex(root, { force: true }); }); } catch (e) { /* 静默校准失败不阻断解锁 */ }
|
|
7121
7311
|
json(200, {
|
|
7122
7312
|
ok: true,
|
|
7123
7313
|
owners: Object.keys(ownerKeys),
|
|
@@ -7137,15 +7327,21 @@ function viewServerCore(root, port, host, opts) {
|
|
|
7137
7327
|
}
|
|
7138
7328
|
if (req.method === 'POST' && pathname === '/api/entries') {
|
|
7139
7329
|
if (!session.umk) return json(401, { error: '请先解锁。' });
|
|
7140
|
-
return readBody(function (d) {
|
|
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
|
+
});
|
|
7141
7334
|
}
|
|
7142
7335
|
if (req.method === 'POST' && pathname === '/api/help') {
|
|
7143
7336
|
if (!session.umk) return json(401, { error: '请先解锁。' });
|
|
7144
|
-
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 });
|
|
7145
7338
|
}
|
|
7146
7339
|
if (req.method === 'POST' && pathname === '/api/overview') {
|
|
7147
7340
|
if (!session.umk) return json(401, { error: '请先解锁。' });
|
|
7148
|
-
return json(200, VIEW_KEYS_CONTEXT.run({ keys: session.ownerKeys }, function () {
|
|
7341
|
+
return json(200, VIEW_KEYS_CONTEXT.run({ keys: session.ownerKeys }, function () {
|
|
7342
|
+
try { maybeHealIndex(root); } catch (e) { /* 静默校准失败不阻断概览 */ }
|
|
7343
|
+
return viewOverviewCore(root, session);
|
|
7344
|
+
}));
|
|
7149
7345
|
}
|
|
7150
7346
|
if (req.method === 'POST' && pathname === '/api/memory/create') {
|
|
7151
7347
|
if (!session.umk) return json(401, { error: '请先解锁。' });
|
|
@@ -7295,6 +7491,7 @@ function viewServerCore(root, port, host, opts) {
|
|
|
7295
7491
|
}
|
|
7296
7492
|
console.error(message);
|
|
7297
7493
|
});
|
|
7494
|
+
try { maybeHealIndex(root, { force: true }); } catch (e) { /* 启动静默校准失败不阻断服务 */ }
|
|
7298
7495
|
server.listen(port, host, function () {
|
|
7299
7496
|
console.log('yotta-memory 用户查看平台已启动(v' + VERSION + ')');
|
|
7300
7497
|
console.log('URL: http://' + host + ':' + port + '(默认仅本机 127.0.0.1;远程需 --host 显式开启)');
|
|
@@ -12115,6 +12312,7 @@ if (require.main === module) { main().catch(function (e) { console.error('错误
|
|
|
12115
12312
|
module.exports = {
|
|
12116
12313
|
VERSION: VERSION,
|
|
12117
12314
|
HELP_MODEL: HELP_MODEL,
|
|
12315
|
+
CONFIG_VALUE_KEYS: CONFIG_VALUE_KEYS,
|
|
12118
12316
|
VIEW_HELP_QUICK: VIEW_HELP_QUICK,
|
|
12119
12317
|
CLI_FLAG_OPTS: CLI_FLAG_OPTS,
|
|
12120
12318
|
CLI_VALUE_OPTS: CLI_VALUE_OPTS,
|
|
@@ -12156,6 +12354,11 @@ module.exports = {
|
|
|
12156
12354
|
saveTokens: saveTokens,
|
|
12157
12355
|
collectEntryFiles: collectEntryFiles,
|
|
12158
12356
|
walkEntryFiles: walkEntryFiles,
|
|
12357
|
+
withIndexLock: withIndexLock,
|
|
12358
|
+
indexConsistencyReport: indexConsistencyReport,
|
|
12359
|
+
maybeHealIndex: maybeHealIndex,
|
|
12360
|
+
viewEntriesCore: viewEntriesCore,
|
|
12361
|
+
viewOverviewCore: viewOverviewCore,
|
|
12159
12362
|
migrateLayout: migrateLayout,
|
|
12160
12363
|
loadIndexFor: loadIndexFor,
|
|
12161
12364
|
indexEntriesFor: indexEntriesFor,
|
package/install.sh
CHANGED
|
@@ -127,7 +127,7 @@ assert_safe_target() {
|
|
|
127
127
|
|
|
128
128
|
# 清理旧版安装残留(fail-closed 白名单):仅在目标目录已存在且含 SKILL.md 时触发;
|
|
129
129
|
# 只删顶层开发项 + 任意层级缓存;不整目录删除、不跟随符号链接。
|
|
130
|
-
DEV_SKIP="package.json package-lock.json bin lib test .github .git .gitignore .npmignore .gitattributes .yotta .tmp install.sh node_modules"
|
|
130
|
+
DEV_SKIP="package.json package-lock.json bin lib test .github .git .gitignore .npmignore .gitattributes .yotta .tmp install.sh node_modules test-fixtures"
|
|
131
131
|
clean_residue() {
|
|
132
132
|
local dest="$1" name p
|
|
133
133
|
[ -d "$dest" ] || return 0
|
|
@@ -140,6 +140,12 @@ clean_residue() {
|
|
|
140
140
|
echo "已清理残留: $name"
|
|
141
141
|
fi
|
|
142
142
|
done
|
|
143
|
+
for p in "$dest"/*.tgz; do
|
|
144
|
+
[ -e "$p" ] || continue
|
|
145
|
+
[ -L "$p" ] && continue
|
|
146
|
+
rm -f -- "$p"
|
|
147
|
+
echo "已清理残留: $(basename "$p")"
|
|
148
|
+
done
|
|
143
149
|
find "$dest" -type d \( -name '__pycache__' -o -name '.pytest_cache' -o -name '.mypy_cache' \) -prune -exec rm -rf -- {} + 2>/dev/null || true
|
|
144
150
|
find "$dest" -type f \( -name '*.pyc' -o -name '*.pyo' \) -delete 2>/dev/null || true
|
|
145
151
|
}
|
|
@@ -156,6 +162,9 @@ copy_tree() {
|
|
|
156
162
|
__pycache__|.pytest_cache|.mypy_cache) continue ;;
|
|
157
163
|
esac
|
|
158
164
|
if [ "$top" = "1" ]; then
|
|
165
|
+
case "$base" in
|
|
166
|
+
*.tgz) continue ;;
|
|
167
|
+
esac
|
|
159
168
|
case " $DEV_SKIP " in
|
|
160
169
|
*" $base "*) continue ;;
|
|
161
170
|
esac
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yottameta/yotta-memory",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.6",
|
|
4
4
|
"description": "Yuanyi (元忆) — boundary-aware, file-based memory for AI agents. File-based, zero-dependency, diff/rollback-able; FACT/PREF/BOUND/COMMIT types (public shared / private isolated), user-level + project-level storage; v0.12 reliability baseline (init guard, trash-based deletion, independent-volume backup/list/doctor/restore, start-of-work doctor, transactional snapshots before destructive writes), v0.10 consolidation (periodic summaries with provenance, near-duplicate auto-merge, per-type decay, batch audit + rollback), v0.9 recall quality + context focus + optional local embedding plugin, plus v0.8 semantic search, feedback loop, self-organization and distillation.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|