dsh-memory-fish 0.0.0-stage → 0.7.0

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/lib/dream.mjs ADDED
@@ -0,0 +1,162 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { localDay, jaccard, bigrams, similar, CANDIDATE_CUES, strengthOf, nowIso } from './util.mjs';
3
+
4
+ /**
5
+ * 做梦:三阶段睡眠(P0)
6
+ * Phase 1 SWS:重放 journal + 压缩近重复记忆(合并、原文进 archive)
7
+ * Phase 2 SHY:按显著性加权衰减,长期低分条目降级归档
8
+ * Phase 3 REM:建立"相关记忆"链接(只加链接,不改内容)
9
+ * 并输出到期复习清单(P1)。
10
+ */
11
+ export async function buildDream(store, cfg, days = cfg.dreamDays) {
12
+ const records = await store.readJournal(days);
13
+ const sessions = new Set(records.map(r => r.session).filter(Boolean));
14
+ const userRecords = records.filter(r => r.role === 'user');
15
+ const cues = [];
16
+ const seen = [];
17
+ for (const r of userRecords) {
18
+ const text = String(r.text ?? '').trim();
19
+ if (!text || text.length < 4) continue;
20
+ if (!CANDIDATE_CUES.test(text)) continue;
21
+ const sig = bigrams(text);
22
+ if (seen.some(s => jaccard(s, sig) >= 0.6)) continue;
23
+ seen.push(sig);
24
+ cues.push({
25
+ session: r.session || '',
26
+ at: r.ts || '',
27
+ text: text.length > 240 ? text.slice(0, 240) + '…' : text,
28
+ explicit: /(记住|记一下|记下)/.test(text),
29
+ });
30
+ if (cues.length >= 20) break;
31
+ }
32
+
33
+ if (!store.entries.length) await store.load();
34
+ const now = Date.now();
35
+
36
+ // ---- Phase 1 · SWS:重放 + 压缩 ----
37
+ const active = store.entries.filter(e => e.status === 'active').sort((a, b) => String(b.updated).localeCompare(String(a.updated)));
38
+ const merged = [];
39
+ const removed = new Set();
40
+ for (let i = 0; i < active.length; i++) {
41
+ const a = active[i];
42
+ if (removed.has(a.id)) continue;
43
+ for (let j = i + 1; j < active.length; j++) {
44
+ const b = active[j];
45
+ if (removed.has(b.id)) continue;
46
+ const sim = similar(`${a.title}\n${a.content ?? ''}`, `${b.title}\n${b.content ?? ''}`);
47
+ if (sim < cfg.dedupeThreshold) continue;
48
+ const keep = String(a.updated).localeCompare(String(b.updated)) >= 0 ? a : b;
49
+ const drop = keep === a ? b : a;
50
+ merged.push({ kept: keep.id, dropped: drop.id, similarity: Number(sim.toFixed(2)) });
51
+ keep.supersedes = [...new Set([...(keep.supersedes || []), drop.id])];
52
+ keep.salience = Math.max(keep.salience ?? 5, drop.salience ?? 5);
53
+ try {
54
+ const text = await fs.readFile(drop.file, 'utf8');
55
+ await store.archiveContent(drop, text);
56
+ } catch { /* 忽略 */ }
57
+ drop.status = 'archived';
58
+ drop.validTo = drop.validTo || nowIso();
59
+ drop.pinned = false;
60
+ removed.add(drop.id);
61
+ }
62
+ }
63
+
64
+ // ---- Phase 2 · SHY:显著性加权衰减 ----
65
+ const demoted = [];
66
+ for (const e of store.entries) {
67
+ if (e.status !== 'active' || e.pinned) continue;
68
+ const ageDays = (now - new Date(e.updated || e.created).getTime()) / 86400000;
69
+ const strength = strengthOf(e, now);
70
+ const keepDays = (e.importance ?? 3) >= 4 ? cfg.archiveAfterDays * 3 : cfg.archiveAfterDays;
71
+ if (ageDays > keepDays && strength < 0.12) {
72
+ try {
73
+ const text = await fs.readFile(e.file, 'utf8');
74
+ await store.archiveContent(e, text);
75
+ } catch { /* 忽略 */ }
76
+ e.status = 'archived';
77
+ e.validTo = e.validTo || nowIso();
78
+ e.pinned = false;
79
+ demoted.push({ id: e.id, title: e.title, ageDays: Math.round(ageDays), strength: Number(strength.toFixed(3)) });
80
+ }
81
+ }
82
+
83
+ // ---- Phase 3 · REM:关联(tags 共现 + 相似度) ----
84
+ const links = [];
85
+ const live = store.entries.filter(e => e.status === 'active');
86
+ for (let i = 0; i < live.length; i++) {
87
+ const a = live[i];
88
+ if (a.salience !== undefined && a.salience < 4) continue; // 低显著性的不建图,省空间
89
+ const aTags = new Set((a.tags || []).map(t => t.toLowerCase()));
90
+ for (let j = i + 1; j < live.length; j++) {
91
+ const b = live[j];
92
+ const bTags = new Set((b.tags || []).map(t => t.toLowerCase()));
93
+ let shared = 0;
94
+ for (const t of aTags) if (bTags.has(t)) shared++;
95
+ const sim = similar(`${a.title}\n${a.content ?? ''}`, `${b.title}\n${b.content ?? ''}`);
96
+ if (shared >= 1 || sim >= 0.5) {
97
+ const rel = shared >= 1 ? `tag:${[...aTags].filter(t => bTags.has(t))[0]}` : `sim:${sim.toFixed(2)}`;
98
+ a.links = [...new Set([...(a.links || []), b.id])].slice(-12);
99
+ b.links = [...new Set([...(b.links || []), a.id])].slice(-12);
100
+ links.push({ a: a.id, b: b.id, relation: rel });
101
+ }
102
+ }
103
+ }
104
+
105
+ // ---- 复习清单(P1) ----
106
+ const due = store.due(now).map(e => ({
107
+ id: e.id, title: e.title, dueAt: e.nextReview,
108
+ stability: e.stability, hits: e.hits || 0, salience: e.salience ?? 5,
109
+ }));
110
+
111
+ const day = localDay();
112
+ const lines = [
113
+ `# 睡梦报告 ${day}`,
114
+ '',
115
+ `- 回顾范围:最近 ${days} 天`,
116
+ `- 会话数:${sessions.size},对话条数:${records.length}(用户 ${userRecords.length} 条)`,
117
+ '',
118
+ '## 🌊 Phase 1 · SWS 慢波:重放 + 压缩',
119
+ `- 合并近似记忆:${merged.length} 组`,
120
+ ...(merged.length ? merged.map(m => ` - 保留 ${m.kept} ← 归档 ${m.dropped}(相似度 ${m.similarity})`) : [' - (无需合并)']),
121
+ '',
122
+ '## 🌙 Phase 2 · SHY 稳态:显著性加权衰减',
123
+ `- 降级归档:${demoted.length} 条`,
124
+ ...(demoted.length ? demoted.map(d => ` - ${d.title}(${d.ageDays} 天未动,强度 ${d.strength})`) : [' - (没有需要遗忘的)']),
125
+ '',
126
+ '## 💭 Phase 3 · REM:联想链接',
127
+ `- 新建关联:${links.length} 条`,
128
+ ...(links.length ? links.slice(0, 10).map(l => ` - ${l.a} ↔ ${l.b}(${l.relation})`) : [' - (暂无可关联)']),
129
+ '',
130
+ '## ⏰ 待复习(间隔重复)',
131
+ ...(due.length ? due.map(d => `- [ ] ${d.title}(稳定 ${d.stability ?? '?'} 天,命中 ${d.hits} 次)`) : ['- (暂无到期)']),
132
+ '',
133
+ '## 候选(值得考虑写入长期记忆)',
134
+ ...(cues.length ? cues.map((c, i) => `${i + 1}. [${c.at ? c.at.slice(0, 16).replace('T', ' ') : '—'}] ${c.text}${c.explicit ? ' (明确要求记住)' : ''}`) : ['(没有发现候选)']),
135
+ '',
136
+ '## 处理建议',
137
+ '- 候选逐条判断:"一个月后仍成立"才写,明确"记住"的直接写。',
138
+ '- 待复习条目:主动以一句话回顾,能加固记忆(提取练习效应)。',
139
+ '- 然后执行 memory_consolidate 收尾。',
140
+ '',
141
+ ].join('\n');
142
+
143
+ await store.saveIndex();
144
+ const file = await store.saveDreamReport(day, lines);
145
+ store.stats.dreams = (store.stats.dreams || 0) + 1;
146
+ store.stats.lastDream = nowIso();
147
+ store.stats.capturedSinceDream = 0;
148
+ await store.saveStats();
149
+
150
+ return {
151
+ day, days,
152
+ sessions: sessions.size, messages: records.length, userMessages: userRecords.length,
153
+ cues,
154
+ phases: {
155
+ sws: { mergedCount: merged.length, merged },
156
+ shy: { demotedCount: demoted.length, demoted },
157
+ rem: { linkCount: links.length, links: links.slice(0, 30) },
158
+ },
159
+ due,
160
+ report: lines, file,
161
+ };
162
+ }
package/lib/inject.mjs ADDED
@@ -0,0 +1,83 @@
1
+ import fsSync from 'node:fs';
2
+ import { clampText, estTokens, dayOf } from './util.mjs';
3
+
4
+ // ---------------------------------------------------------------- 注入
5
+
6
+ /**
7
+ * C1 睡梦提醒:不依赖宿主调度器,在注入时按"欠一次梦"判断。
8
+ * 条件:距上次做梦 >= dreamAfterHours,或存在到期复习;且确有新对话/复习项。
9
+ */
10
+ function dreamStatus(store, config, now = Date.now()) {
11
+ if (config.dreamReminder === false) return null;
12
+ const last = store.stats?.lastDream ? new Date(store.stats.lastDream).getTime() : 0;
13
+ const ageHours = Number.isFinite(last) ? Math.floor((now - (last || 0)) / 3600000) : Infinity;
14
+ const captured = store.stats?.capturedSinceDream || 0;
15
+ const dueCount = store.due().length;
16
+ const overdue = !Number.isFinite(ageHours) ? true : ageHours >= Number(config.dreamAfterHours ?? 24);
17
+ if (!overdue && dueCount === 0) return null;
18
+ if (captured === 0 && dueCount === 0) return null;
19
+ return { ageHours: Number.isFinite(ageHours) ? ageHours : null, captured, dueCount, overdue };
20
+ }
21
+
22
+ export function buildInjection(store, config) {
23
+ const profileRaw = fsSync.existsSync(store.profileFile)
24
+ ? fsSync.readFileSync(store.profileFile, 'utf8')
25
+ : '';
26
+ const profile = clampText(profileRaw.replace(/^#.*\n+/, '').trim() || '(暂无画像)', config.profileTokens);
27
+
28
+ const dream = dreamStatus(store, config);
29
+ const reminder = [];
30
+ if (dream) {
31
+ reminder.push('### 睡梦');
32
+ const parts = [];
33
+ if (dream.ageHours !== null) parts.push(`距上次做梦 ${dream.ageHours} 小时`);
34
+ if (dream.captured > 0) parts.push(`新增对话 ${dream.captured} 条`);
35
+ if (dream.dueCount > 0) parts.push(`${dream.dueCount} 条记忆到期该复习`);
36
+ reminder.push(`- ${parts.join(' · ')} → 建议先 memory_dream 做三阶段整理`);
37
+ }
38
+ const reminderTokens = reminder.length ? estTokens(reminder.join('\n')) : 0;
39
+
40
+ const visible = store.visible();
41
+ const total = visible.length;
42
+ const lines = [];
43
+ let used = 0;
44
+ const limitLines = Math.min(config.maxHotLines, 20);
45
+ const due = store.due();
46
+ const dueIds = new Set(due.map(e => e.id));
47
+ for (const e of visible) {
48
+ if (lines.length >= limitLines) break;
49
+ const date = dayOf(e.updated || e.created);
50
+ const tags = (e.tags || []).slice(0, 2).map(t => `#${t}`).join(' ');
51
+ const mark = dueIds.has(e.id) ? '⏰' : (e.pinned ? '★' : '·');
52
+ const line = `- ${mark} ${date} ${String(e.title).slice(0, 60)}${tags ? ' ' + tags : ''}`;
53
+ const cost = estTokens(line);
54
+ if (used + cost > config.budgetTokens - config.profileTokens - reminderTokens) break;
55
+ lines.push(line);
56
+ used += cost;
57
+ }
58
+
59
+ const shown = lines.length;
60
+ const cold = Math.max(0, total - shown);
61
+ const body = [
62
+ '## 小肥鱼记忆(本地,跨会话)',
63
+ '### 画像',
64
+ profile,
65
+ '',
66
+ shown > 0 ? '### 热索引(置顶★ / 到期⏰ / 显著性排序)' : '### 热索引',
67
+ ...(shown > 0 ? lines : ['(还没有记忆)']),
68
+ cold > 0 ? `\n> 冷区还有 ${cold} 条,需要时 memory_search 检索;memory_read 取全文。` : '',
69
+ due.length > 0 ? `> ⏰ ${due.length} 条记忆已到复习期(睡眠摘要里会列出,主动回顾能加固记忆)。` : '',
70
+ reminder.join('\n'),
71
+ '> 规则:只在结论时刻 memory_save;不确定就 memory_search 查证,查不到就说不知道,不要编。',
72
+ ].filter(Boolean).join('\n');
73
+
74
+ return {
75
+ text: body.trim() + '\n',
76
+ l1Lines: shown,
77
+ cold,
78
+ due: due.length,
79
+ dreamDue: Boolean(dream),
80
+ l1Tokens: estTokens(body),
81
+ profileTokens: estTokens(profile),
82
+ };
83
+ }
package/lib/lock.mjs ADDED
@@ -0,0 +1,41 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * 跨进程串行化:用 lock 文件把"读索引 → 改 → 写索引"包起来。
6
+ * 同一时刻只允许一个 DSH 实例改记忆;拿不到锁会在 timeout 后报错,
7
+ * 调用方可以重试(不会永久卡死)。
8
+ */
9
+ export async function withLock(dir, fn, { timeoutMs = 5000, staleMs = 15000 } = {}) {
10
+ await fs.mkdir(dir, { recursive: true });
11
+ const lock = path.join(dir, '.index.lock');
12
+ const start = Date.now();
13
+ let handle;
14
+ for (;;) {
15
+ try {
16
+ handle = await fs.open(lock, 'wx');
17
+ break;
18
+ } catch (error) {
19
+ if (error.code !== 'EEXIST') throw error;
20
+ try {
21
+ const stat = await fs.stat(lock);
22
+ if (Date.now() - stat.mtimeMs > staleMs) {
23
+ await fs.unlink(lock).catch(() => {});
24
+ continue;
25
+ }
26
+ } catch {
27
+ continue; // 锁刚被释放
28
+ }
29
+ if (Date.now() - start > timeoutMs) {
30
+ throw new Error('memory-fish: index lock timeout (another DSH instance may be writing)');
31
+ }
32
+ await new Promise(r => setTimeout(r, 25));
33
+ }
34
+ }
35
+ try {
36
+ return await fn();
37
+ } finally {
38
+ try { await handle.close(); } catch { /* ignore */ }
39
+ await fs.unlink(lock).catch(() => {});
40
+ }
41
+ }
package/lib/log.mjs ADDED
@@ -0,0 +1,44 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * 极简调试日志:默认关闭(不产生任何 IO),
6
+ * 配置 debug: true 时把结构化日志追加到 <dir>/debug.log(超过 1MB 自动清空)。
7
+ */
8
+ let enabled = false;
9
+ let file = null;
10
+
11
+ export function configureLogger({ enabled: on = false, file: target = null } = {}) {
12
+ enabled = Boolean(on);
13
+ file = target || null;
14
+ if (enabled && file) {
15
+ try { fs.mkdirSync(path.dirname(file), { recursive: true }); } catch { /* ignore */ }
16
+ }
17
+ }
18
+
19
+ function format(part) {
20
+ if (typeof part === 'string') return part;
21
+ if (part instanceof Error) return part.message;
22
+ try { return JSON.stringify(part); } catch { return String(part); }
23
+ }
24
+
25
+ function write(level, parts) {
26
+ if (!enabled) return;
27
+ const line = JSON.stringify({
28
+ ts: new Date().toISOString(),
29
+ level,
30
+ msg: parts.map(format).join(' '),
31
+ });
32
+ try {
33
+ if (!file) return;
34
+ if (fs.existsSync(file) && fs.statSync(file).size > 1024 * 1024) fs.writeFileSync(file, '');
35
+ fs.appendFileSync(file, line + '\n');
36
+ } catch { /* 日志失败绝不影响主流程 */ }
37
+ }
38
+
39
+ export const log = {
40
+ debug: (...parts) => write('debug', parts),
41
+ info: (...parts) => write('info', parts),
42
+ warn: (...parts) => write('warn', parts),
43
+ error: (...parts) => write('error', parts),
44
+ };
package/lib/panel.mjs ADDED
@@ -0,0 +1,215 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import fsSync from 'node:fs';
3
+ import path from 'node:path';
4
+ import { dayOf, isDue, out } from './util.mjs';
5
+ import { buildInjection } from './inject.mjs';
6
+
7
+ // ---------------------------------------------------------------- 面板 / 做梦
8
+
9
+ /** 面板请求来源校验:只允许本机同源请求(防 DNS rebinding / 跨站写入)。 */
10
+ export function trustedRequest(request, expectedMethod) {
11
+ if (expectedMethod && request.method && request.method !== expectedMethod) return false;
12
+ const h = request.headers ?? {};
13
+ const site = h['sec-fetch-site'];
14
+ if (typeof site === 'string' && site !== 'same-origin' && site !== 'none') return false;
15
+ if (typeof h.origin === 'string') {
16
+ try { if (new URL(h.origin).host !== h.host) return false; } catch { return false; }
17
+ }
18
+ if (h.host !== undefined) {
19
+ const okHost = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i.test(h.host)
20
+ || /^\d{1,3}(\.\d{1,3}){3}(:\d+)?$/.test(h.host)
21
+ || /^\[[0-9a-f:]+\](:\d+)?$/i.test(h.host);
22
+ if (!okHost) return false;
23
+ }
24
+ return true;
25
+ }
26
+
27
+ export function replyJson(response, code, payload) {
28
+ response.writeHead(code, { 'content-type': 'application/json; charset=utf-8' });
29
+ response.end(JSON.stringify(payload));
30
+ }
31
+
32
+
33
+ const PANEL_HTML = `<!doctype html>
34
+ <html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
35
+ <title>小肥鱼记忆</title>
36
+ <style>
37
+ :root{--bg:#0f1419;--card:#171d26;--line:#263041;--fg:#e6edf3;--dim:#8b98a9;--accent:#4cc2ff;--warn:#ffb454;--ok:#5ee0a0}
38
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.6 system-ui,"Segoe UI",sans-serif}
39
+ .wrap{max-width:1080px;margin:0 auto;padding:24px}
40
+ h1{font-size:20px;margin:0 0 4px}h1 span{color:var(--dim);font-size:13px;font-weight:400}
41
+ .cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin:16px 0}
42
+ .card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px 14px}
43
+ .card .k{color:var(--dim);font-size:12px}.card .v{font-size:22px;font-weight:600;margin-top:2px}
44
+ .bar{height:8px;background:#0b0f14;border-radius:4px;overflow:hidden;margin-top:8px}
45
+ .bar i{display:block;height:100%;background:linear-gradient(90deg,var(--ok),var(--accent))}
46
+ .panel{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px;margin:14px 0}
47
+ .panel h2{font-size:14px;margin:0 0 10px;color:var(--accent);font-weight:600}
48
+ textarea,input{width:100%;background:#0b0f14;color:var(--fg);border:1px solid var(--line);border-radius:8px;padding:8px;font:inherit}
49
+ textarea{min-height:96px;resize:vertical}
50
+ button{background:#1e2a3a;color:var(--fg);border:1px solid var(--line);border-radius:8px;padding:6px 12px;cursor:pointer;font:inherit}
51
+ button:hover{border-color:var(--accent)}button.primary{background:var(--accent);color:#04121c;border-color:var(--accent);font-weight:600}
52
+ button.danger{color:#ff8b8b}
53
+ .row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
54
+ .mem{border-bottom:1px solid var(--line);padding:8px 0;display:flex;gap:10px;align-items:flex-start}
55
+ .mem:last-child{border-bottom:0}
56
+ .mem .t{flex:1}.mem .t b{font-weight:600}.mem .m{color:var(--dim);font-size:12px}
57
+ .tag{display:inline-block;background:#1b2636;color:var(--accent);border-radius:6px;padding:0 6px;font-size:11px;margin-left:6px}
58
+ .pin{color:var(--warn)}
59
+ pre{white-space:pre-wrap;background:#0b0f14;border:1px solid var(--line);border-radius:8px;padding:10px;max-height:260px;overflow:auto;font-size:12px}
60
+ .muted{color:var(--dim);font-size:12px}
61
+ </style></head><body><div class="wrap">
62
+ <h1>小肥鱼记忆 <span>dsh-memory-fish · 本地文件,零服务端</span></h1>
63
+ <div class="cards" id="cards"></div>
64
+ <div class="panel"><h2>L0 画像(常驻注入)</h2>
65
+ <textarea id="profile"></textarea>
66
+ <div class="row" style="margin-top:8px"><button class="primary" id="saveProfile">保存画像</button><span class="muted" id="profileHint"></span></div></div>
67
+ <div class="panel"><h2>记忆(热索引 + 冷区)</h2>
68
+ <div class="row"><input id="q" placeholder="搜索记忆(标题/标签/正文)" style="flex:1"><button id="searchBtn">搜索</button><button id="resetBtn">重置</button></div>
69
+ <div id="list" style="margin-top:8px"></div></div>
70
+ <div class="panel"><h2>睡梦机制(每天回顾对话,提炼记忆)</h2>
71
+ <div class="muted" id="dreamInfo">还没有做梦记录</div>
72
+ <div class="row" style="margin-top:8px"><button id="dreamBtn">现在做梦</button><button id="consolidateBtn">整理记忆</button><span class="muted" id="dreamHint"></span></div>
73
+ <pre id="dreamReport" style="margin-top:10px">—</pre></div>
74
+ </div>
75
+ <script>
76
+ const $=s=>document.querySelector(s);
77
+ const esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
78
+ async function api(path,body){const r=await fetch(path,{method:body?'POST':'GET',headers:body?{'content-type':'application/json'}:{},body:body?JSON.stringify(body):undefined});return r.json()}
79
+ function card(k,v,extra){return '<div class="card"><div class="k">'+esc(k)+'</div><div class="v">'+esc(v)+'</div>'+(extra||'')+'</div>'}
80
+ async function refresh(){
81
+ const s=await api('api/state');
82
+ const pct=Math.min(100,Math.round(s.injected/budgetMax*100));
83
+ $('#cards').innerHTML=card('注入 tokens',s.injected+' / '+budgetMax,'<div class="bar"><i style="width:'+pct+'%"></i></div>')
84
+ +card('活跃记忆',s.active)+card('置顶',s.pinned)+card('待复习',s.due||0)+card('归档/遗忘',s.archived)
85
+ +card('今日对话',s.todayMessages)+card('候选线索',s.dreamCues);
86
+ if(document.activeElement!==$('#profile'))$('#profile').value=s.profile;
87
+ $('#profileHint').textContent='约 '+s.profileTokens+' tokens';
88
+ $('#dreamInfo').textContent='最近一次做梦:'+(s.lastDream||'无')+' · 已做 '+s.dreams+' 次';
89
+ $('#dreamReport').textContent=s.dreamReport||'—';
90
+ render(s.entries);
91
+ }
92
+ const budgetMax=600;
93
+ function render(entries){
94
+ if(!entries.length){$('#list').innerHTML='<div class="muted">还没有记忆</div>';return}
95
+ $('#list').innerHTML=entries.map(e=>'<div class="mem"><div class="t"><b>'+((e.due&&!e.pinned)?'<span class="pin">⏰ </span>':(e.pinned?'<span class="pin">★ </span>':''))+esc(e.title)+'</b>'
96
+ +e.tags.map(t=>'<span class="tag">'+esc(t)+'</span>').join('')
97
+ +'<div class="m">'+esc(e.updated)+' · 生效 '+esc(e.validFrom||'')+' · 显著性 '+(e.salience??5)+' · 重要度 '+e.importance+' · 命中 '+e.hits+(e.nextReview?' · 复习 '+esc(e.nextReview.slice(0,10)):'')+'</div></div>'
98
+ +'<div class="row"><button onclick="act(\\''+(e.pinned?'unpin':'pin')+'\\',\\''+esc(e.id)+'\\')">'+(e.pinned?'取消置顶':'置顶')+'</button>'
99
+ +'<button class="danger" onclick="act(\\'forget\\',\\''+esc(e.id)+'\\')">遗忘</button></div></div>').join('');
100
+ }
101
+ async function act(action,id){
102
+ const r=await api('api/action',{action,id});
103
+ if(!r.ok)alert(r.message||'操作失败');
104
+ refresh();
105
+ }
106
+ $('#saveProfile').onclick=async()=>{const r=await api('api/action',{action:'profile',content:$('#profile').value});$('#profileHint').textContent=r.ok?'已保存(约 '+r.tokens+' tokens)':'失败';refresh()};
107
+ $('#searchBtn').onclick=async()=>{const q=$('#q').value.trim();if(!q)return refresh();const r=await api('api/action',{action:'search',query:q});render(r.matches||[])};
108
+ $('#resetBtn').onclick=()=>{$('#q').value='';refresh()};
109
+ $('#dreamBtn').onclick=async()=>{$('#dreamHint').textContent='做梦ing…';const r=await api('api/action',{action:'dream'});$('#dreamHint').textContent=r.ok?('SWS 合并 '+r.phases.sws+' · SHY 归档 '+r.phases.shy+' · REM 链接 '+r.phases.rem+' · 待复习 '+r.due):'失败';refresh()};
110
+ $('#consolidateBtn').onclick=async()=>{$('#dreamHint').textContent='整理中…';const r=await api('api/action',{action:'consolidate'});$('#dreamHint').textContent=r.ok?('合并 '+r.merged+' 条 / 归档 '+r.archived+' 条'):'失败';refresh()};
111
+ refresh();setInterval(refresh,8000);
112
+ </script></body></html>`;
113
+
114
+ // ---------------------------------------------------------------- 面板路由(模块级,便于测试)
115
+
116
+ export function registerPanelRoutes(webServer, store, cfg, toolImpl) {
117
+ if (!webServer || typeof webServer.register !== 'function') return;
118
+
119
+ const latestDream = async () => {
120
+ try {
121
+ const files = (await fs.readdir(store.dreamDir)).filter(f => f.endsWith('.md')).sort();
122
+ if (!files.length) return '';
123
+ const text = await fs.readFile(path.join(store.dreamDir, files[files.length - 1]), 'utf8');
124
+ return text.slice(0, 4000);
125
+ } catch { return ''; }
126
+ };
127
+
128
+ webServer.register({
129
+ kind: 'exact',
130
+ path: cfg.panelPath,
131
+ handler: async (request, response) => {
132
+ if (!trustedRequest(request, 'GET')) { replyJson(response, 403, { ok: false, error: 'cross-origin refused' }); return; }
133
+ response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
134
+ response.end(PANEL_HTML);
135
+ },
136
+ });
137
+
138
+ webServer.register({
139
+ kind: 'exact',
140
+ path: `${cfg.apiBase}/state`,
141
+ handler: async (request, response) => {
142
+ if (!trustedRequest(request, 'GET')) { replyJson(response, 403, { ok: false, error: 'cross-origin refused' }); return; }
143
+ try {
144
+ if (!store.entries.length) await store.load();
145
+ const hot = buildInjection(store, cfg);
146
+ const profileRaw = fsSync.existsSync(store.profileFile) ? fsSync.readFileSync(store.profileFile, 'utf8') : '';
147
+ const journal = await store.readJournal(1);
148
+ const dreamReport = await latestDream();
149
+ const entries = store.visible().slice(0, 200).map(e => out({
150
+ id: e.id, title: e.title, pinned: !!e.pinned, tags: e.tags || [],
151
+ importance: e.importance ?? 3, salience: e.salience ?? 5,
152
+ stability: e.stability ?? null, nextReview: e.nextReview ?? null,
153
+ due: isDue(e), reviews: e.reviews || 0,
154
+ validFrom: dayOf(e.validFrom || e.created), historyCount: (e.history || []).length,
155
+ updated: dayOf(e.updated), hits: e.hits || 0,
156
+ }));
157
+ replyJson(response, 200, {
158
+ ok: true,
159
+ injected: hot.l1Tokens,
160
+ budget: cfg.budgetTokens,
161
+ profileTokens: hot.profileTokens,
162
+ profile: profileRaw.replace(/^#.*\n+/, '').trim(),
163
+ active: store.entries.filter(e => e.status === 'active').length,
164
+ archived: store.entries.filter(e => e.status !== 'active').length,
165
+ pinned: store.entries.filter(e => e.status === 'active' && e.pinned).length,
166
+ due: store.due().length,
167
+ todayMessages: journal.length,
168
+ dreamCues: dreamReport ? (dreamReport.match(/^\d+\. /gm) || []).length : 0,
169
+ dreams: store.stats.dreams || 0,
170
+ lastDream: store.stats.lastDream || '',
171
+ dreamReport,
172
+ entries,
173
+ });
174
+ } catch (error) {
175
+ replyJson(response, 500, { ok: false, error: String(error?.message ?? error) });
176
+ }
177
+ },
178
+ });
179
+
180
+ webServer.register({
181
+ kind: 'exact',
182
+ path: `${cfg.apiBase}/action`,
183
+ handler: async (request, response) => {
184
+ if (!trustedRequest(request, 'POST')) { replyJson(response, 403, { ok: false, error: 'cross-origin refused' }); return; }
185
+ try {
186
+ const chunks = [];
187
+ for await (const chunk of request) chunks.push(chunk);
188
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
189
+ const result = await runPanelAction(String(body.action ?? ''), body, store, cfg, toolImpl);
190
+ replyJson(response, 200, result);
191
+ } catch (error) {
192
+ replyJson(response, 400, { ok: false, message: String(error?.message ?? error) });
193
+ }
194
+ },
195
+ });
196
+ }
197
+
198
+ export async function runPanelAction(action, body, store, cfg, toolImpl) {
199
+ const exec = { signal: { aborted: false } };
200
+ if (action === 'pin') return await toolImpl.memory_pin.execute({ id: body.id, pinned: true }, exec);
201
+ if (action === 'unpin') return await toolImpl.memory_pin.execute({ id: body.id, pinned: false }, exec);
202
+ if (action === 'forget') return await toolImpl.memory_forget.execute({ id: body.id }, exec);
203
+ if (action === 'profile') return await toolImpl.memory_profile_save.execute({ content: body.content }, exec);
204
+ if (action === 'save') return await toolImpl.memory_save.execute(body, exec);
205
+ if (action === 'search') return await toolImpl.memory_search.execute({ query: body.query, includeArchived: true }, exec);
206
+ if (action === 'dream') {
207
+ const r = await toolImpl.memory_dream.execute({ autoSave: body.autoSave === true }, exec);
208
+ return out({ ok: true, cues: r.cues, day: r.day, phases: r.phases, due: (r.dueReview || []).length });
209
+ }
210
+ if (action === 'consolidate') {
211
+ const r = await toolImpl.memory_consolidate.execute({}, exec);
212
+ return out({ ok: true, merged: r.mergedCount, archived: r.archivedCount });
213
+ }
214
+ return { ok: false, message: 'unknown action' };
215
+ }