dsh-memory-engine 0.0.0-stage → 0.5.1

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/index.js ADDED
@@ -0,0 +1,179 @@
1
+ import { MemoryStore } from './store.js';
2
+ import { chars, titleOf } from './decay.js';
3
+ import { generateMap, mapReport, openMap } from './map.js';
4
+ import { startMapServer } from './serve.js';
5
+
6
+ export const name = 'memory-engine';
7
+ export const inject = ['tools'];
8
+
9
+ function report(ctx, error) {
10
+ try {
11
+ const logger = typeof ctx?.logger === 'function' ? ctx.logger(name) : ctx?.logger;
12
+ logger?.error?.(`memory-engine: ${error?.message ?? String(error)}`);
13
+ } catch { /* Error reporting must not take down the host. */ }
14
+ }
15
+
16
+ function warn(ctx, message) {
17
+ try {
18
+ const logger = typeof ctx?.logger === 'function' ? ctx.logger(name) : ctx?.logger;
19
+ if (logger?.warn) logger.warn(message);
20
+ else console.warn(`[dsh-memory-engine] ${message}`);
21
+ } catch { /* Preview failures must not affect the memory tools. */ }
22
+ }
23
+
24
+ export function createTools(defineTool, store = new MemoryStore(), { map = generateMap, warning = message => warn(null, message) } = {}) {
25
+ const output = { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] };
26
+ return [
27
+ defineTool({
28
+ name: 'memory_save',
29
+ description: '把值得记住的事保存到跨会话的本地记忆。与 QQ 桥接记忆独立。重复正文自动合并。',
30
+ parameters: {
31
+ text: { type: 'string', required: true, description: '记忆正文,最多 2000 字' },
32
+ kind: { type: 'string', description: '分类,默认对话;可用知识/印象/日记/对话/事件或自定义' },
33
+ heat: { type: 'number', description: '1=热,2=温,3=冷;省略按情绪推断' },
34
+ who: { type: 'string', description: '跟谁有关' },
35
+ ts: { type: 'number', description: '事情发生的毫秒时间戳,默认现在' },
36
+ }, output,
37
+ async execute(args) {
38
+ const memory = store.save(args);
39
+ if (memory.merged) return `已合并到既有记忆(第 ${memory.merged.index} 条,相似度 ${memory.merged.similarity.toFixed(2)}):${chars(memory.text, 40)}`;
40
+ return `已记住:${chars(memory.text, 40)}(重要度 ${['', '热', '温', '冷'][memory.heat]})`;
41
+ },
42
+ }),
43
+ defineTool({
44
+ name: 'memory_recall',
45
+ description: '按话题查跨会话的本地记忆,并联想相关条目。q 使用具体关键词,如「古茗」「光遇 蜡烛」。与 QQ 桥接记忆独立。',
46
+ parameters: {
47
+ q: { type: 'string', required: true, description: '要查的话题或关键词' },
48
+ limit: { type: 'number', description: '默认 8,最大 20' },
49
+ hops: { type: 'number', description: '联想跳数,默认 1;0 只查直接命中,最大 2' },
50
+ kind: { type: 'string', description: '只看某一分类' },
51
+ }, output,
52
+ async execute(args) {
53
+ const hits = store.recall(args);
54
+ if (!hits.length) return `没有找到跟「${args.q}」相关的记忆。`;
55
+ let index = 0;
56
+ return ['direct', 'spread'].map(via => {
57
+ const group = hits.filter(hit => hit.via === via);
58
+ if (!group.length) return '';
59
+ return [via === 'direct' ? '【直接命中】' : '【联想到的】', ...group.map(hit =>
60
+ `${++index}. [${hit.kind}] ${titleOf(hit)}(留存 ${hit.retention.toFixed(2)};id=${hit.id}):${chars(hit.text, 120)}`)].join('\n');
61
+ }).filter(Boolean).join('\n\n');
62
+ },
63
+ }),
64
+ defineTool({
65
+ name: 'memory_forget',
66
+ description: '删除本地记忆。id/q/older_than_days 三选一。q 批量删除先报告数量,用户确认后带 confirm=true 再调用。',
67
+ parameters: {
68
+ id: { type: 'string', description: '精确删除的记忆 id' },
69
+ q: { type: 'string', description: '按关键词批量删除,必须二次确认' },
70
+ older_than_days: { type: 'number', description: '仅删除超过此天数且留存低于 0.15 的条目' },
71
+ confirm: { type: 'boolean', description: '收到用户对本次关键词批删的确认后才设为 true' },
72
+ }, output,
73
+ async execute(args) {
74
+ const result = store.forget(args);
75
+ return result.needsConfirmation ? `匹配到 ${result.count} 条,确认请带 confirm=true 再调一次。`
76
+ : `已删除 ${result.count} 条记忆。`;
77
+ },
78
+ }),
79
+ defineTool({
80
+ name: 'memory_dedupe', description: '检测全库相似重复记忆。默认只预览;用户确认后显式 dry_run=false 才备份并合并。',
81
+ parameters: {
82
+ threshold: { type: 'number', description: 'LCS 相似度阈值,默认 0.7,范围 0.5–0.95' },
83
+ dry_run: { type: 'boolean', description: '默认 true,只报告;false 备份后执行合并' },
84
+ }, output,
85
+ async execute(args) {
86
+ const result = store.dedupe(args);
87
+ if (!result.dryRun) return `已合并 ${result.groups.length} 组,删除 ${result.removed} 条。备份:${result.backup}(耗时 ${result.elapsedMs.toFixed(0)}ms)`;
88
+ const involved = result.groups.reduce((sum, group) => sum + group.texts.length, 0);
89
+ return [`发现 ${result.groups.length} 组重复(共 ${involved} 条 → 可合并为 ${result.groups.length} 条)`,
90
+ ...result.groups.map((group, index) => `组 ${index + 1}:${group.texts.length} 条 → 保留「${chars(group.memory.text, 80)}」(boosts 合计 ${group.memory.boosts})\n`
91
+ + group.texts.map(text => ` - ${chars(text, 80)}`).join('\n')),
92
+ '确认执行请带 dry_run=false', `耗时 ${result.elapsedMs.toFixed(0)}ms`].join('\n');
93
+ },
94
+ }),
95
+ defineTool({
96
+ name: 'memory_import', description: '从 JSON、Markdown 目录、逐行纯文本或 qq-bridge 导入本地记忆。默认只预览,显式 dry_run=false 才备份并导入;不会修改来源。新增后自动生成图谱。',
97
+ parameters: {
98
+ source: { type: 'string', required: true, description: 'qq-bridge / json / markdown / text' },
99
+ path: { type: 'string', description: 'json/text 的本地文件绝对路径,或 markdown 的目录绝对路径;qq-bridge 无需此参数' },
100
+ dry_run: { type: 'boolean', description: '默认 true,只报告;false 先备份本插件记忆再导入' },
101
+ }, output,
102
+ async execute(args) {
103
+ const result = args.source === 'qq-bridge' ? store.importBridge(args) : await store.importFile(args);
104
+ if (result.missing) return '找不到桥接目录,可用 QQ_BRIDGE_DIR 环境变量指定';
105
+ let mapLine;
106
+ if (!result.dryRun && result.imported > 0) {
107
+ try { mapLine = `已同步生成神经网络图:${map(store).file}`; }
108
+ catch (error) { warning(`图谱生成失败,记忆已成功导入:${error.message}`); }
109
+ }
110
+ return [result.dryRun ? `将从 ${args.source} 导入:` : `已从 ${args.source} 导入:`,
111
+ ...Object.entries(result.counts).map(([kind, count]) => `- ${kind} ${count} 条(${result.dryRun ? '可新增' : '新增'} ${result.added[kind]} 条)`),
112
+ `合计 ${result.candidates} 条;其中 ${result.skipped} 条与现有或本批重复会被跳过。`,
113
+ `${result.dryRun ? '预计导入后' : '导入后'}总数:${result.total} 条(新增 ${result.imported} 条)`,
114
+ ...(result.dryRun ? ['确认执行请带 dry_run=false'] : [`备份:${result.backup}`]),
115
+ ...(result.warnings.length ? ['来源提示:', ...result.warnings] : []),
116
+ `耗时 ${(result.elapsedMs / 1000).toFixed(2)} 秒`, ...(mapLine ? [mapLine] : [])].join('\n');
117
+ },
118
+ }),
119
+ defineTool({
120
+ name: 'memory_map', description: '把当前本地记忆生成可离线打开的神经网络 HTML 图谱,返回文件路径、节点和关联边数量。',
121
+ parameters: {
122
+ out: { type: 'string', description: '输出 HTML 路径,默认 DSH_HOME/memory-engine/starmap.html' },
123
+ title: { type: 'string', description: '页面标题,默认“我的记忆神经网络”' },
124
+ open: { type: 'boolean', description: '默认 false;true 时用系统默认浏览器打开' },
125
+ }, output,
126
+ async execute(args) {
127
+ const result = map(store, args);
128
+ if (args.open === true) await openMap(result.file);
129
+ return mapReport(result);
130
+ },
131
+ }),
132
+ defineTool({
133
+ name: 'memory_stats', description: '查看本地记忆总数、分类、衰减状态、最老日期和存储路径及体积。',
134
+ parameters: {}, output,
135
+ async execute() {
136
+ const stats = store.stats();
137
+ const kinds = Object.entries(stats.kinds).map(([kind, count]) => `${kind} ${count}`).join(' / ');
138
+ return `记忆引擎状态\n- 总条数:${stats.total}${kinds ? `(${kinds})` : ''}\n`
139
+ + `- 快忘了的(留存<0.3):${stats.fading} 条\n`
140
+ + `- 最老:${stats.oldest === null ? '暂无' : `${new Date(stats.oldest).toISOString().slice(0, 10)}(${stats.ageDays} 天前)`}\n`
141
+ + `- 存储:${stats.file}(${(stats.bytes / 1024).toFixed(1)} KB)`;
142
+ },
143
+ }),
144
+ ];
145
+ }
146
+
147
+ export function apply(ctx) {
148
+ try {
149
+ if (typeof ctx?.tools?.register !== 'function') throw new Error('tools 服务不可用');
150
+ if (typeof ctx.effect !== 'function') throw new Error('ctx.effect 不可用');
151
+ const registration = ctx.effect(async () => {
152
+ const disposers = [];
153
+ try {
154
+ // The host resolves this public SDK; no bundled copy or runtime dependency.
155
+ const { defineTool } = await import('@deepseek-ai/dsh-tools');
156
+ const store = new MemoryStore();
157
+ for (const tool of createTools(defineTool, store, { warning: message => warn(ctx, message) })) disposers.push(ctx.tools.register(tool));
158
+ try {
159
+ const server = await startMapServer({ store, warn: message => warn(ctx, message) });
160
+ disposers.push(() => server.close());
161
+ } catch (error) {
162
+ warn(ctx, `图谱服务启动失败,记忆工具仍可用:${error.message}`);
163
+ }
164
+ const timer = setTimeout(() => {
165
+ try { if (store.read().length > 0) generateMap(store); }
166
+ catch (error) { warn(ctx, `启动时图谱生成失败:${error.message}`); }
167
+ }, 3000);
168
+ timer.unref();
169
+ disposers.push(() => clearTimeout(timer));
170
+ return () => { for (const dispose of disposers.reverse()) dispose(); };
171
+ } catch (error) {
172
+ for (const dispose of disposers.reverse()) { try { dispose(); } catch { /* keep unwinding */ } }
173
+ report(ctx, error);
174
+ }
175
+ }, 'memory-engine.tools');
176
+ // Await setup without returning its disposer as a second plugin teardown.
177
+ return Promise.resolve(registration).then(() => undefined, error => { report(ctx, error); });
178
+ } catch (error) { report(ctx, error); }
179
+ }
package/lib/map.js ADDED
@@ -0,0 +1,57 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { randomBytes } from 'node:crypto';
4
+ import { spawn } from 'node:child_process';
5
+ import { pathToFileURL } from 'node:url';
6
+ import { scoreMemories, layoutMemories, renderHtml } from './render/index.mjs';
7
+ import { readConfig } from './config.js';
8
+
9
+ export function generateMap(store, { out, title = '我的记忆神经网络' } = {}) {
10
+ const started = performance.now();
11
+ if (typeof title !== 'string' || !title.trim()) throw new Error('title 必须是非空字符串');
12
+ if (out !== undefined && (typeof out !== 'string' || !out.trim())) throw new Error('out 必须是 HTML 文件路径');
13
+ const file = path.resolve(out ?? path.join(path.dirname(store.file), 'starmap.html'));
14
+ if (path.extname(file).toLowerCase() !== '.html') throw new Error('输出文件必须以 .html 结尾');
15
+ const home = path.dirname(path.dirname(store.file));
16
+ const bridge = process.env.QQ_BRIDGE_DIR || readConfig(home).bridgeRoot;
17
+ if (bridge) {
18
+ const relative = path.relative(path.resolve(bridge), file);
19
+ if (relative === '' || (!relative.startsWith('..' + path.sep) && relative !== '..' && !path.isAbsolute(relative))) {
20
+ throw new Error('桥接目录只读,请把图谱输出到插件数据目录或其他目录');
21
+ }
22
+ }
23
+ const items = store.read().map(({ id, text, title: nodeTitle, ts, kind, heat }) => ({ id, text,
24
+ title: nodeTitle || Array.from(text).slice(0, 20).join(''), ts, kind, heat }));
25
+ const now = store.now();
26
+ const { nodes, links, review } = scoreMemories(items, now);
27
+ const stars = layoutMemories(nodes);
28
+ const kinds = Object.fromEntries([...new Set(stars.map(star => star.kind))].map(kind => [kind, stars.filter(star => star.kind === kind).length]));
29
+ const payload = { generatedAt: now, stats: { total: stars.length, byKind: kinds,
30
+ forgettingSoon: stars.filter(star => star.retention < 0.3).length }, stars, links, review };
31
+ const html = renderHtml(payload, title);
32
+ fs.mkdirSync(path.dirname(file), { recursive: true });
33
+ const temporary = `${file}.${process.pid}.${randomBytes(5).toString('hex')}.tmp`;
34
+ try {
35
+ fs.writeFileSync(temporary, html, { flag: 'wx', mode: 0o600 });
36
+ fs.renameSync(temporary, file);
37
+ } finally {
38
+ try { fs.unlinkSync(temporary); } catch (error) { if (error.code !== 'ENOENT') throw error; }
39
+ }
40
+ return { file, total: stars.length, edges: links.length, kinds, bytes: Buffer.byteLength(html), elapsedMs: performance.now() - started };
41
+ }
42
+
43
+ export function mapReport(result) {
44
+ const kinds = Object.entries(result.kinds).map(([kind, count]) => `${kind} ${count}`).join(' / ');
45
+ return `已生成记忆神经网络图\n- 节点 ${result.total} 个${kinds ? `(${kinds})` : ''}\n- 关联边 ${result.edges} 条\n`
46
+ + `- 文件:${result.file}(${(result.bytes / 1024 / 1024).toFixed(2)} MB)\n双击打开即可(断网也能看)`;
47
+ }
48
+
49
+ export function openMap(file) {
50
+ const command = process.platform === 'win32' ? 'rundll32.exe' : process.platform === 'darwin' ? 'open' : 'xdg-open';
51
+ const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', pathToFileURL(file).href] : [file];
52
+ return new Promise((resolve, reject) => {
53
+ const child = spawn(command, args, { windowsHide: true, stdio: 'ignore' });
54
+ child.once('error', reject);
55
+ child.once('exit', code => code === 0 ? resolve() : reject(new Error(`浏览器启动失败:退出码 ${code}`)));
56
+ });
57
+ }
package/lib/recall.js ADDED
@@ -0,0 +1,59 @@
1
+ import { chars, retention, titleOf } from './decay.js';
2
+
3
+ export function relevance(memory, query) {
4
+ const q = query.trim().toLocaleLowerCase();
5
+ if (!q) return 0;
6
+ const title = titleOf(memory).toLocaleLowerCase();
7
+ const text = memory.text.toLocaleLowerCase();
8
+ const terms = q.split(/[\s\p{P}\p{S}]+/u).filter(term => Array.from(term).length >= 2);
9
+ let rel = 0;
10
+ for (const term of terms) {
11
+ if (title.includes(term)) rel += 3;
12
+ if (text.includes(term)) rel += 2;
13
+ if (title.startsWith(term)) rel += 2;
14
+ }
15
+ if (title.includes(q)) rel += 4;
16
+ if (text.includes(q)) rel += 2;
17
+ return rel;
18
+ }
19
+
20
+ function related(a, b) {
21
+ return [a.title, a.prefix].some(key => key && b.text.includes(key))
22
+ || [b.title, b.prefix].some(key => key && a.text.includes(key));
23
+ }
24
+
25
+ export function recall(memories, { q, limit = 8, hops = 1, kind }, now = Date.now()) {
26
+ if (typeof q !== 'string' || !q.trim()) throw new Error('q 必须是非空字符串');
27
+ limit = Math.max(1, Math.min(20, Math.trunc(Number.isFinite(limit) ? limit : 8)));
28
+ hops = Math.max(0, Math.min(2, Math.trunc(Number.isFinite(hops) ? hops : 1)));
29
+ const pool = memories.filter(memory => kind === undefined || memory.kind === kind)
30
+ .map(memory => ({ memory, retention: retention(memory, now),
31
+ title: titleOf(memory), prefix: chars(memory.text, 12), text: memory.text }));
32
+ const sorted = rows => rows.sort((a, b) => b.score - a.score || b.memory.ts - a.memory.ts);
33
+ const direct = sorted(pool.map(row => ({ ...row, rel: relevance(row.memory, q), via: 'direct' }))
34
+ .filter(row => row.rel > 0).map(row => ({ ...row, score: row.rel * (0.35 + 0.65 * row.retention) })))
35
+ .slice(0, limit);
36
+ const best = new Map(direct.map(row => [row.memory.id, row]));
37
+ let frontier = direct;
38
+ for (let hop = 0; hop < hops; hop++) {
39
+ const next = new Map();
40
+ // Lower-scored paths only decay further, so they cannot enter the final
41
+ // top N once their best possible next score is below its existing floor.
42
+ const floor = best.size >= limit ? sorted([...best.values()])[limit - 1].score : 0;
43
+ for (const source of frontier) {
44
+ if (source.score * 0.45 < floor) continue;
45
+ for (const neighbor of pool) {
46
+ if (neighbor.memory.id === source.memory.id || neighbor.retention <= 0.4
47
+ || !related(source, neighbor)) continue;
48
+ const score = source.score * 0.45;
49
+ if (score <= (best.get(neighbor.memory.id)?.score ?? 0)) continue;
50
+ const hit = { ...neighbor, score, via: 'spread' };
51
+ best.set(neighbor.memory.id, hit);
52
+ next.set(neighbor.memory.id, hit);
53
+ }
54
+ }
55
+ frontier = [...next.values()];
56
+ }
57
+ return sorted([...best.values()]).slice(0, limit)
58
+ .map(({ memory, score, retention: value, via }) => ({ ...memory, score, retention: value, via }));
59
+ }
@@ -0,0 +1,22 @@
1
+ import { loadMemories } from './load.mjs';
2
+ import { scoreMemories } from './score.mjs';
3
+ import { layoutMemories } from './layout.mjs';
4
+ import { renderHtml } from './render.mjs';
5
+
6
+ export { loadMemories, scoreMemories, layoutMemories, renderHtml };
7
+
8
+ export async function generate(input, { title = '记忆神经网络', now = Date.now() } = {}) {
9
+ const items = await loadMemories(input, now);
10
+ const { nodes, links, review } = scoreMemories(items, now);
11
+ const stars = layoutMemories(nodes);
12
+ const payload = {
13
+ generatedAt: now,
14
+ stats: {
15
+ total: stars.length,
16
+ byKind: stars.reduce((counts, star) => ({ ...counts, [star.kind]: (counts[star.kind] || 0) + 1 }), {}),
17
+ forgettingSoon: stars.filter((star) => star.retention < 0.3).length,
18
+ },
19
+ stars, links, review,
20
+ };
21
+ return { payload, html: renderHtml(payload, title) };
22
+ }
@@ -0,0 +1,22 @@
1
+ export function layoutMemories(nodes) {
2
+ let seed = 20261030;
3
+ const random = () => ((seed = (seed * 16807) % 2147483647) - 1) / 2147483646;
4
+ function brainPoint(radiusScale) {
5
+ const radius = Math.pow(random(), 1 / 3) * radiusScale;
6
+ const theta = random() * Math.PI * 2;
7
+ const phi = Math.acos(2 * random() - 1);
8
+ return {
9
+ x: radius * Math.sin(phi) * Math.cos(theta) * 1.18,
10
+ y: radius * Math.cos(phi) * 0.92,
11
+ z: radius * Math.sin(phi) * Math.sin(theta) * 0.78,
12
+ };
13
+ }
14
+ return nodes.map((node) => {
15
+ const inner = ['人格', '印象', '知识'].includes(node.kind) || node.majorEmotion;
16
+ const distance = inner ? 14 + Math.pow(random(), 0.65) * 36 : 48 + Math.min(1, node.ageDays / 30) * 58 + random() * 12;
17
+ const baseRadius = node.majorEmotion ? 3.2 : inner
18
+ ? (node.heat === 1 ? 2.4 : node.heat === 2 ? 1.8 : 1.3)
19
+ : (node.heat === 1 ? 2.1 : node.heat === 2 ? 1.5 : 1.0);
20
+ return { ...node, pos: brainPoint(distance), radius: baseRadius + Math.min(1.4, Math.sqrt(node.degree) * 0.18) };
21
+ });
22
+ }
@@ -0,0 +1,68 @@
1
+ import { readFile, readdir, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ const short = (text, length) => [...text].slice(0, length).join('');
5
+
6
+ function normalize(item, source, now) {
7
+ if (!item || typeof item !== 'object' || Array.isArray(item)) throw new Error(`${source}: 记忆必须是对象`);
8
+ if (typeof item.text !== 'string' || !item.text.trim()) throw new Error(`${source}: text 必须是非空字符串`);
9
+ const text = item.text.trim();
10
+ const id = String(item.id ?? source).trim();
11
+ if (!id) throw new Error(`${source}: id 不能为空`);
12
+ const date = item.ts == null ? new Date(now) : new Date(item.ts);
13
+ if (!Number.isFinite(date.getTime())) throw new Error(`${source}: ts 不是有效时间`);
14
+ const heat = item.heat == null || item.heat === '' ? undefined : Number(item.heat);
15
+ if (heat !== undefined && ![1, 2, 3].includes(heat)) throw new Error(`${source}: heat 必须是 1、2 或 3`);
16
+ return { id, text, ts: date.toISOString(), kind: String(item.kind ?? '记忆').trim() || '记忆', title: String(item.title ?? short(text, 20)).trim() || short(text, 20), heat };
17
+ }
18
+
19
+ function frontMatter(content) {
20
+ if (!content.startsWith('---\n') && !content.startsWith('---\r\n')) return { fields: {}, text: content };
21
+ const lines = content.split(/\r?\n/);
22
+ const end = lines.findIndex((line, index) => index > 0 && line.trim() === '---');
23
+ if (end < 0) throw new Error('front-matter 缺少结束的 ---');
24
+ const fields = {};
25
+ for (const line of lines.slice(1, end)) {
26
+ const match = /^([\w-]+):\s*(.*)$/.exec(line);
27
+ if (!match) continue;
28
+ let value = match[2].trim();
29
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
30
+ fields[match[1]] = value;
31
+ }
32
+ return { fields, text: lines.slice(end + 1).join('\n') };
33
+ }
34
+
35
+ async function markdownFiles(directory) {
36
+ const files = [];
37
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
38
+ const full = path.join(directory, entry.name);
39
+ if (entry.isDirectory()) files.push(...await markdownFiles(full));
40
+ else if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) files.push(full);
41
+ }
42
+ return files.sort((left, right) => left.localeCompare(right));
43
+ }
44
+
45
+ export async function loadMemories(input, now = Date.now()) {
46
+ const info = await stat(input);
47
+ let items;
48
+ if (info.isDirectory()) {
49
+ const files = await markdownFiles(input);
50
+ items = await Promise.all(files.map(async (file) => {
51
+ const { fields, text } = frontMatter(await readFile(file, 'utf8'));
52
+ const relative = path.relative(input, file).split(path.sep).join('/');
53
+ const fileInfo = await stat(file);
54
+ return normalize({ ...fields, id: fields.id ?? relative, title: fields.title ?? path.basename(file, path.extname(file)), ts: fields.ts ?? fileInfo.mtime.toISOString(), text }, relative, now);
55
+ }));
56
+ } else if (info.isFile() && path.extname(input).toLowerCase() === '.json') {
57
+ const parsed = JSON.parse(await readFile(input, 'utf8'));
58
+ if (!Array.isArray(parsed)) throw new Error('JSON 根节点必须是记忆数组');
59
+ items = parsed.map((item, index) => normalize(item, `第 ${index + 1} 条`, now));
60
+ } else throw new Error('输入必须是 JSON 文件或 Markdown 目录');
61
+ if (!items.length) throw new Error('没有找到记忆');
62
+ const ids = new Set();
63
+ for (const item of items) {
64
+ if (ids.has(item.id)) throw new Error(`重复的 id: ${item.id}`);
65
+ ids.add(item.id);
66
+ }
67
+ return items;
68
+ }
@@ -0,0 +1,222 @@
1
+ function escapeHtml(value) {
2
+ return String(value).replace(/[&<>"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]);
3
+ }
4
+
5
+ export function renderHtml(data, title = '记忆神经网络') {
6
+ const embedded = JSON.stringify(data).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
7
+ const safeTitle = escapeHtml(title);
8
+ return `<!doctype html>
9
+ <html lang="zh-CN">
10
+ <head>
11
+ <meta charset="utf-8">
12
+ <meta name="viewport" content="width=device-width,initial-scale=1">
13
+ <title>${safeTitle}</title>
14
+ <style>
15
+ :root{color-scheme:dark}*{box-sizing:border-box}html,body{margin:0;width:100%;height:100%}
16
+ body{overflow:hidden;background:#03060c;color:#d8e9f8;font:13px/1.65 system-ui,"Microsoft YaHei",sans-serif}
17
+ canvas{display:block;touch-action:none;cursor:grab}canvas:active{cursor:grabbing}
18
+ .hud{position:fixed;top:16px;left:18px;pointer-events:none;text-shadow:0 0 12px #3388bb88}
19
+ .hud strong{color:#a4e6ff;font-size:16px}.sub{color:#7491a8;font-size:12px}
20
+ .panel,.review{position:fixed;background:#07111fee;border:1px solid #3976a055;border-radius:14px;box-shadow:0 0 40px #1763a033;padding:15px;backdrop-filter:blur(8px)}
21
+ .panel{display:none;top:16px;right:18px;width:min(350px,calc(100vw - 36px));max-height:78vh;overflow:auto}
22
+ .panel h2{font-size:16px;color:#b7e9ff;margin:5px 0}.panel .meta{color:#8ba8c2;margin:7px 0}.panel .text{white-space:pre-wrap;overflow-wrap:anywhere}
23
+ .close{float:right;border:0;background:transparent;color:#a4bad0;font-size:18px;cursor:pointer}
24
+ .review{bottom:16px;left:18px;width:min(400px,calc(100vw - 36px));max-height:32vh;overflow:auto;font-size:12px}
25
+ .review h2{font-size:14px;color:#ffdb82;margin:0 0 8px}.row{display:flex;gap:7px;align-items:center;margin:5px 0;color:#b1c6d9}
26
+ .row .name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.bar{width:52px;height:5px;border-radius:5px;background:#335066;overflow:hidden}.bar i{display:block;height:100%;background:#6bc9fa}
27
+ .tip{position:fixed;right:18px;bottom:18px;color:#7b99ad;pointer-events:none}
28
+ @media(max-width:700px){.hud{top:8px;left:10px}.hud .sub,.tip{display:none}.review{left:10px;bottom:10px;max-height:24vh}.panel{top:8px;right:8px;max-height:55vh}}
29
+ </style>
30
+ </head>
31
+ <body>
32
+ <canvas id="map" aria-label="记忆神经网络"></canvas>
33
+ <div class="hud"><strong>${safeTitle}</strong><div><span id="total"></span> 个神经元 · <span id="linkCount"></span> 条突触</div><div class="sub">大小=重要度和连接数 · 亮度=留存</div></div>
34
+ <section class="panel" id="panel"><button class="close" id="close" aria-label="关闭">×</button><div id="kind"></div><h2 id="title"></h2><div class="meta" id="meta"></div><div class="text" id="body"></div></section>
35
+ <section class="review"><h2>复习清单 · 留存最低的记忆</h2><div id="reviewList"></div></section>
36
+ <div class="tip">拖拽旋转 · 滚轮缩放 · 点击查看记忆</div>
37
+ <script>
38
+ const DATA = ${embedded};
39
+ const canvas = document.getElementById('map');
40
+ const ctx = canvas.getContext('2d', { alpha: false });
41
+ const colors = { '人格':[255,215,106], '印象':[143,216,255], '知识':[126,231,178], '日记':[190,160,255], '对话':[255,160,140], '情绪':[255,90,90] };
42
+ const fallback = [145,205,255];
43
+ const byId = new Map(DATA.stars.map(star => [star.id, star]));
44
+ const adjacency = new Map(DATA.stars.map(star => [star.id, []]));
45
+ for (const [left,right] of DATA.links) {
46
+ if (adjacency.has(left) && adjacency.has(right)) { adjacency.get(left).push(right); adjacency.get(right).push(left); }
47
+ }
48
+ const hubs = DATA.stars.filter(star => star.degree > 0).sort((left,right) => right.degree-left.degree).slice(0,6);
49
+ const pulses = [];
50
+ const firing = new Map();
51
+ let seed = 987654321;
52
+ const random = () => ((seed = seed * 16807 % 2147483647) - 1) / 2147483646;
53
+ for (const star of DATA.stars) {
54
+ star._rgb = colors[star.kind] || fallback;
55
+ const count = Math.min(9, 4 + Math.round(Math.sqrt(star.degree) * 1.2));
56
+ star._dendrites = Array.from({length:count},(_,index) => ({
57
+ angle:index/count*Math.PI*2+random()*0.7,
58
+ length:0.9+random()*1.5, bend:(random()-0.5)*0.9, width:0.5+random()*0.7,
59
+ fork:random()<0.55 ? (random()-0.5)*1.5 : null
60
+ }));
61
+ }
62
+ function glowSprite(rgb) {
63
+ const off = document.createElement('canvas');
64
+ off.width = off.height = 96;
65
+ const brush = off.getContext('2d');
66
+ const gradient = brush.createRadialGradient(48,48,1,48,48,48);
67
+ gradient.addColorStop(0,'rgba('+rgb.join(',')+',0.7)');
68
+ gradient.addColorStop(0.4,'rgba('+rgb.join(',')+',0.19)');
69
+ gradient.addColorStop(1,'rgba('+rgb.join(',')+',0)');
70
+ brush.fillStyle = gradient; brush.fillRect(0,0,96,96);
71
+ return off;
72
+ }
73
+ const sprites = new Map();
74
+ for (const star of DATA.stars) {
75
+ const key = star._rgb.join(',');
76
+ if (!sprites.has(key)) sprites.set(key,glowSprite(star._rgb));
77
+ star._glow = sprites.get(key);
78
+ }
79
+ let width=0,height=0,dpr=1,rotX=0.3,rotY=0.15,zoom=DATA.stars.length<50?1.6:1,autoSpin=true;
80
+ function resize() {
81
+ width=innerWidth; height=innerHeight; dpr=Math.min(1.5,devicePixelRatio||1);
82
+ canvas.width=Math.round(width*dpr); canvas.height=Math.round(height*dpr);
83
+ canvas.style.width=width+'px'; canvas.style.height=height+'px';
84
+ ctx.setTransform(dpr,0,0,dpr,0,0);
85
+ }
86
+ addEventListener('resize',resize); resize();
87
+ let drag=null;
88
+ canvas.addEventListener('pointerdown',event => { autoSpin=false; drag={x:event.clientX,y:event.clientY,moved:0}; canvas.setPointerCapture(event.pointerId); });
89
+ canvas.addEventListener('pointermove',event => {
90
+ if (!drag) return;
91
+ const dx=event.clientX-drag.x,dy=event.clientY-drag.y;
92
+ drag.x=event.clientX; drag.y=event.clientY; drag.moved+=Math.abs(dx)+Math.abs(dy);
93
+ rotY+=dx*0.006; rotX=Math.max(-1.5,Math.min(1.5,rotX+dy*0.006));
94
+ });
95
+ canvas.addEventListener('pointerup',event => { if (drag && drag.moved<6) select(event.clientX,event.clientY); drag=null; });
96
+ canvas.addEventListener('pointercancel',() => { drag=null; });
97
+ canvas.addEventListener('wheel',event => { event.preventDefault(); zoom=Math.max(0.3,Math.min(4.5,zoom*(event.deltaY>0?0.92:1.08))); },{passive:false});
98
+ document.getElementById('close').onclick=() => { document.getElementById('panel').style.display='none'; };
99
+ document.getElementById('total').textContent=DATA.stats.total;
100
+ document.getElementById('linkCount').textContent=DATA.links.length;
101
+ for (const [index,item] of DATA.review.entries()) {
102
+ const row=document.createElement('div'); row.className='row';
103
+ const name=document.createElement('span'); name.className='name'; name.textContent=(index+1)+'. ['+item.kind+'] '+item.title;
104
+ const bar=document.createElement('span'); bar.className='bar';
105
+ const fill=document.createElement('i'); fill.style.width=Math.min(100,Math.max(0,item.retention*100))+'%'; bar.append(fill);
106
+ row.append(name,bar); document.getElementById('reviewList').append(row);
107
+ }
108
+ function project(pos) {
109
+ const cy=Math.cos(rotY),sy=Math.sin(rotY),cx=Math.cos(rotX),sx=Math.sin(rotX);
110
+ const x=pos.x*cy-pos.z*sy,z=pos.x*sy+pos.z*cy;
111
+ const y=pos.y*cx-z*sx,depth=pos.y*sx+z*cx+250;
112
+ if (depth<=12) return null;
113
+ const scale=760/depth*zoom;
114
+ return {x:width/2+x*scale,y:height/2+y*scale,scale,depth};
115
+ }
116
+ function fire(id,now) {
117
+ firing.set(id,1);
118
+ for (const other of adjacency.get(id)||[]) {
119
+ firing.set(other,Math.max(0.65,firing.get(other)||0));
120
+ pulses.push({from:id,to:other,born:now,life:900+Math.random()*700});
121
+ }
122
+ }
123
+ function select(x,y) {
124
+ let best=null,distance=Infinity;
125
+ for (const star of DATA.stars) {
126
+ if (!star._screen) continue;
127
+ const value=Math.hypot(star._screen.x-x,star._screen.y-y);
128
+ if (value<star._screen.r && value<distance) {best=star;distance=value;}
129
+ }
130
+ if (!best) return;
131
+ fire(best.id,performance.now());
132
+ document.getElementById('panel').style.display='block';
133
+ document.getElementById('kind').textContent=best.kind;
134
+ document.getElementById('title').textContent=best.title;
135
+ document.getElementById('meta').textContent='重要度 '+best.heat+' · 留存 '+best.retention.toFixed(2)+' · 连接数 '+best.degree+(best.emotion.length?' · '+best.emotion.map(item=>item.tag).join('/'):'');
136
+ document.getElementById('body').textContent=best.text;
137
+ }
138
+ let nextFire=0,lastTime=0;
139
+ function frame(now) {
140
+ const delta=Math.min(50,now-lastTime||16.7); lastTime=now;
141
+ if (autoSpin) rotY+=0.0011*delta/16.7;
142
+ if (now>nextFire) { nextFire=now+1000+Math.random()*1500; if (hubs.length) fire(hubs[Math.floor(Math.random()*hubs.length)].id,now); }
143
+ ctx.globalCompositeOperation='source-over'; ctx.fillStyle='#03060c'; ctx.fillRect(0,0,width,height);
144
+ ctx.strokeStyle='rgba(40,90,140,0.055)';ctx.lineWidth=1;
145
+ for (let index=0;index<20;index++) {
146
+ const y=index/20*height;
147
+ ctx.beginPath();ctx.moveTo(0,y);ctx.bezierCurveTo(width*.3,y+Math.sin(now*.0003+index)*30,width*.7,y-Math.cos(now*.00025+index)*30,width,y);ctx.stroke();
148
+ }
149
+ const points=[];
150
+ for (const star of DATA.stars) {
151
+ const p=project(star.pos);
152
+ if (!p || p.x< -100 || p.x>width+100 || p.y< -100 || p.y>height+100) {star._screen=null;continue;}
153
+ points.push({star,p});
154
+ }
155
+ points.sort((left,right)=>right.p.depth-left.p.depth);
156
+ const screen=new Map(points.map(point=>[point.star.id,point]));
157
+ ctx.globalCompositeOperation='lighter';
158
+ for (const [left,right] of DATA.links) {
159
+ const first=screen.get(left),second=screen.get(right);
160
+ if (!first||!second) continue;
161
+ const act=Math.max(firing.get(left)||0,firing.get(right)||0);
162
+ const alpha=Math.min(.85,.1*Math.min(first.star.retention,second.star.retention)+.35*act);
163
+ if (alpha<.01) continue;
164
+ const mx=(first.p.x+second.p.x)/2+(first.p.y-second.p.y)*.18;
165
+ const my=(first.p.y+second.p.y)/2+(second.p.x-first.p.x)*.18;
166
+ ctx.strokeStyle='rgba(110,190,255,'+alpha+')';ctx.lineWidth=.7+act*1.6;
167
+ ctx.beginPath();ctx.moveTo(first.p.x,first.p.y);ctx.quadraticCurveTo(mx,my,second.p.x,second.p.y);ctx.stroke();
168
+ }
169
+ for (let index=pulses.length-1;index>=0;index--) {
170
+ const pulse=pulses[index],fraction=(now-pulse.born)/pulse.life;
171
+ if (fraction>=1) {pulses.splice(index,1);continue;}
172
+ const first=screen.get(pulse.from),second=screen.get(pulse.to);
173
+ if (!first||!second) continue;
174
+ const mx=(first.p.x+second.p.x)/2+(first.p.y-second.p.y)*.18,my=(first.p.y+second.p.y)/2+(second.p.x-first.p.x)*.18;
175
+ const inverse=1-fraction;
176
+ const x=inverse*inverse*first.p.x+2*inverse*fraction*mx+fraction*fraction*second.p.x;
177
+ const y=inverse*inverse*first.p.y+2*inverse*fraction*my+fraction*fraction*second.p.y;
178
+ ctx.fillStyle='rgba(205,245,255,'+(.9*inverse)+')';ctx.beginPath();ctx.arc(x,y,2.5+2*inverse,0,Math.PI*2);ctx.fill();
179
+ }
180
+ for (const {star,p} of points) {
181
+ const act=firing.get(star.id)||0,ret=Math.max(.05,Math.min(1.2,star.retention));
182
+ const intensity=Math.min(1,.22+ret*.72)*(1+act*1.1);
183
+ const radius=Math.max(2,star.radius*p.scale*.55)*(1+Math.sin(now*.0016+(star.pos.x+star.pos.z)*.3)*.06)*(1+act*.5);
184
+ const rgb=star._rgb.join(',');
185
+ ctx.strokeStyle='rgba('+rgb+','+Math.min(1,.3*intensity)+')';
186
+ if (radius>2.5) for (const branch of star._dendrites) {
187
+ const length=radius*2.2*branch.length,dx=Math.cos(branch.angle),dy=Math.sin(branch.angle);
188
+ const ex=p.x+dx*length,ey=p.y+dy*length;
189
+ const cx=p.x+dx*length*.55-dy*length*branch.bend*.5;
190
+ const cy=p.y+dy*length*.55+dx*length*branch.bend*.5;
191
+ ctx.lineWidth=branch.width;ctx.beginPath();ctx.moveTo(p.x,p.y);ctx.quadraticCurveTo(cx,cy,ex,ey);ctx.stroke();
192
+ if (branch.fork!==null) {
193
+ const angle=branch.angle+branch.fork;
194
+ ctx.lineWidth=branch.width*.65;ctx.beginPath();ctx.moveTo(cx,cy);ctx.quadraticCurveTo(cx+Math.cos(angle)*length*.2,cy+Math.sin(angle)*length*.2,cx+Math.cos(angle)*length*.34,cy+Math.sin(angle)*length*.34);ctx.stroke();
195
+ }
196
+ }
197
+ const glowRadius=radius*3.6;
198
+ ctx.globalAlpha=Math.min(1,.4+intensity*.7);ctx.drawImage(star._glow,p.x-glowRadius,p.y-glowRadius,glowRadius*2,glowRadius*2);ctx.globalAlpha=1;
199
+ ctx.fillStyle='rgba('+rgb+','+Math.min(1,.42*intensity)+')';ctx.beginPath();ctx.arc(p.x,p.y,radius,0,Math.PI*2);ctx.fill();
200
+ ctx.strokeStyle='rgba('+rgb+','+Math.min(1,.75*intensity)+')';ctx.lineWidth=1;ctx.stroke();
201
+ ctx.fillStyle='rgba(255,255,255,'+Math.min(1,.6*intensity)+')';ctx.beginPath();ctx.arc(p.x,p.y,radius*.42,0,Math.PI*2);ctx.fill();
202
+ if (star.retention<.25) {
203
+ ctx.strokeStyle='rgba(255,110,110,'+Math.max(0,.45-star.retention)+')';ctx.setLineDash([3,4]);
204
+ ctx.beginPath();ctx.arc(p.x,p.y,radius*3.2,0,Math.PI*2);ctx.stroke();ctx.setLineDash([]);
205
+ }
206
+ star._screen={x:p.x,y:p.y,r:Math.max(8,radius*2.6)};
207
+ }
208
+ for (const [id,value] of firing) {
209
+ const next=value*Math.pow(.965,delta/16.7);
210
+ if (next<.02) firing.delete(id); else firing.set(id,next);
211
+ }
212
+ ctx.globalCompositeOperation='source-over';
213
+ const vignette=ctx.createRadialGradient(width/2,height/2,Math.min(width,height)*.34,width/2,height/2,Math.max(width,height)*.8);
214
+ vignette.addColorStop(0,'rgba(0,0,0,0)');vignette.addColorStop(1,'rgba(0,0,0,.78)');
215
+ ctx.fillStyle=vignette;ctx.fillRect(0,0,width,height);
216
+ requestAnimationFrame(frame);
217
+ }
218
+ requestAnimationFrame(frame);
219
+ </script>
220
+ </body>
221
+ </html>`;
222
+ }