dsh-memory-engine 0.7.0-beta.4 → 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/dedupe.js CHANGED
@@ -49,10 +49,10 @@ export function similarity(a, b, threshold = 0.7) {
49
49
  return { duplicate: score >= threshold, score, rule: 'lcs' };
50
50
  }
51
51
 
52
- export function findDuplicate(rows, candidate, prepared = rows.map(row => prepare(row.text))) {
52
+ export function findDuplicate(rows, candidate, prepared = rows.map(row => prepare(row.text)), threshold = 0.7) {
53
53
  const text = prepare(candidate.text);
54
54
  for (let index = 0; index < rows.length; index++) {
55
- const match = similarity(prepared[index], text);
55
+ const match = similarity(prepared[index], text, threshold);
56
56
  if (match.duplicate) return { index, ...match };
57
57
  }
58
58
  return null;
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import { loadMemories } from './render/index.mjs';
5
5
  import { inferEmotion } from './decay.js';
6
6
 
7
- export async function collectFile({ source, path: input }, now = Date.now()) {
7
+ export async function collectFile({ source, path: input, files: selectedFiles }, now = Date.now()) {
8
8
  if (!['json', 'markdown', 'text'].includes(source)) throw new Error('source 支持 qq-bridge / json / markdown / text');
9
9
  if (typeof input !== 'string' || !path.isAbsolute(input)) throw new Error('path 必须是本地文件或目录的绝对路径');
10
10
  const info = await fs.stat(input);
@@ -13,15 +13,16 @@ export async function collectFile({ source, path: input }, now = Date.now()) {
13
13
  try {
14
14
  let loaderInput = input;
15
15
  if (source === 'markdown') {
16
- const files = (await fs.readdir(input, { recursive: true, withFileTypes: true }))
16
+ const files = selectedFiles ?? (await fs.readdir(input, { recursive: true, withFileTypes: true }))
17
17
  .filter(entry => entry.isFile() && path.extname(entry.name).toLowerCase() === '.md')
18
18
  .map(entry => path.join(entry.parentPath, entry.name));
19
19
  const contents = await Promise.all(files.map(async file => ({ file, text: await fs.readFile(file, 'utf8'), info: await fs.stat(file) })));
20
- if (contents.some(item => item.text.startsWith('\uFEFF'))) {
20
+ if (selectedFiles || contents.some(item => item.text.startsWith('\uFEFF'))) {
21
21
  temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-memory-input-'));
22
22
  loaderInput = temporary;
23
23
  for (const item of contents) {
24
24
  const target = path.join(temporary, path.relative(input, item.file));
25
+ if (path.relative(temporary, target).startsWith('..') || path.isAbsolute(path.relative(temporary, target))) throw new Error('Markdown 文件超出导入目录');
25
26
  await fs.mkdir(path.dirname(target), { recursive: true });
26
27
  await fs.writeFile(target, item.text.replace(/^\uFEFF/u, ''));
27
28
  await fs.utimes(target, item.info.atime, item.info.mtime);
package/lib/import.js CHANGED
@@ -34,15 +34,18 @@ export function resolveBridgeRoot({ defaultRoot = DEFAULT_BRIDGE_ROOT, envRoot =
34
34
  }
35
35
 
36
36
  export function collectBridge(options = {}) {
37
- const root = resolveBridgeRoot(options);
37
+ const root = options.root ?? resolveBridgeRoot(options);
38
38
  if (!root) return { missing: true, entries: [], warnings: [], counts: {} };
39
+ const readSource = options.io?.readFileSync ?? readFileSync;
40
+ const listSource = options.io?.readdirSync ?? readdirSync;
41
+ const statSource = options.io?.statSync ?? statSync;
39
42
  const warnings = [];
40
43
  const readJson = (file, fallback) => {
41
- try { return JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/u, '')); }
44
+ try { return JSON.parse(readSource(file, 'utf8').replace(/^\uFEFF/u, '')); }
42
45
  catch (error) { warnings.push(`${file}:${error.code === 'ENOENT' ? '未找到,已略过' : '无法读取或 JSON 损坏,已略过'}`); return fallback; }
43
46
  };
44
47
  const files = directory => {
45
- try { return readdirSync(directory, { withFileTypes: true }).filter(entry => entry.isFile()).map(entry => entry.name).sort(); }
48
+ try { return listSource(directory, { withFileTypes: true }).filter(entry => entry.isFile()).map(entry => entry.name).sort(); }
46
49
  catch { warnings.push(`${directory}:无法读取目录,已略过`); return []; }
47
50
  };
48
51
  const state = path.join(root, 'state');
@@ -117,7 +120,7 @@ export function collectBridge(options = {}) {
117
120
  for (const file of (diaryDir ? files(diaryDir) : []).filter(name => !name.startsWith('_') && name.toLowerCase().endsWith('.md'))) {
118
121
  const full = path.join(diaryDir, file);
119
122
  try {
120
- add('日记', { text: readFileSync(full, 'utf8'), kind: '日记', ts: statSync(full).mtimeMs, title: path.basename(file, path.extname(file)) },
123
+ add('日记', { text: readSource(full, 'utf8'), kind: '日记', ts: statSource(full).mtimeMs, title: path.basename(file, path.extname(file)) },
121
124
  full, [`diary-${file}`]);
122
125
  } catch { warnings.push(`${file}:日记无法读取,已略过`); }
123
126
  }
@@ -132,7 +135,7 @@ export function collectBridge(options = {}) {
132
135
  const todayMessages = [];
133
136
  for (const file of files(path.join(state, 'history')).filter(name => name.endsWith('.jsonl'))) {
134
137
  let lines;
135
- try { lines = readFileSync(path.join(state, 'history', file), 'utf8').replace(/^\uFEFF/u, '').split(/\r?\n/u); }
138
+ try { lines = readSource(path.join(state, 'history', file), 'utf8').replace(/^\uFEFF/u, '').split(/\r?\n/u); }
136
139
  catch { warnings.push(`${file}:历史文件无法读取,已略过`); continue; }
137
140
  let broken = 0;
138
141
  for (const line of lines) {
package/lib/index.js CHANGED
@@ -2,6 +2,8 @@ import { MemoryStore } from './store.js';
2
2
  import { chars, titleOf } from './decay.js';
3
3
  import { generateMap, mapReport, openMap } from './map.js';
4
4
  import { registerMemoryRoutes } from './routes.js';
5
+ import { createAutoMemory } from './auto-memory.js';
6
+ import { createAutoImport } from './auto-import.js';
5
7
 
6
8
  export const name = 'memory-engine';
7
9
  export const inject = ['tools', 'webServer'];
@@ -44,7 +46,7 @@ export function createTools(defineTool, store = new MemoryStore(), { map = gener
44
46
  }),
45
47
  defineTool({
46
48
  name: 'memory_recall',
47
- description: '按话题查跨会话的本地记忆,并联想相关条目。q 使用具体关键词,如「古茗」「光遇 蜡烛」。与 QQ 桥接记忆独立。',
49
+ description: '按话题查跨会话的本地记忆,并联想相关条目。q 使用具体关键词,如「手冲」「阅读 书签」。与 QQ 桥接记忆独立。',
48
50
  parameters: {
49
51
  q: { type: 'string', required: true, description: '要查的话题或关键词' },
50
52
  limit: { type: 'number', description: '默认 8,最大 20' },
@@ -53,14 +55,28 @@ export function createTools(defineTool, store = new MemoryStore(), { map = gener
53
55
  }, output,
54
56
  async execute(args) {
55
57
  const hits = store.recall(args);
56
- if (!hits.length) return `没有找到跟「${args.q}」相关的记忆。`;
58
+ if (!hits.length) return `【没有找到】「${args.q}」在记忆库里没有相关内容。`;
59
+ const quote = words => words.map(word => `「${word}」`).join('');
57
60
  let index = 0;
58
- return ['direct', 'spread'].map(via => {
61
+ const describe = hit => `${++index}. [${hit.kind}] ${titleOf(hit)}(留存 ${hit.retention.toFixed(2)};id=${hit.id})`;
62
+ const sections = [];
63
+ for (const via of ['direct', 'partial']) {
59
64
  const group = hits.filter(hit => hit.via === via);
60
- if (!group.length) return '';
61
- return [via === 'direct' ? '【直接命中】' : '【联想到的】', ...group.map(hit =>
62
- `${++index}. [${hit.kind}] ${titleOf(hit)}(留存 ${hit.retention.toFixed(2)};id=${hit.id}):${chars(hit.text, 120)}`)].join('\n');
63
- }).filter(Boolean).join('\n\n');
65
+ if (!group.length) continue;
66
+ const terms = group[0].terms;
67
+ const matched = terms.filter(term => group.some(hit => hit.matched.includes(term)));
68
+ const missed = terms.filter(term => !matched.includes(term));
69
+ const label = via === 'direct' ? '直接命中' : '部分命中';
70
+ const summary = `【${label} ${group.length} 条】` + (via === 'partial' ? '(覆盖不足 50%,不是直接命中)' : '')
71
+ + `命中${quote(matched)}` + (missed.length ? `;未命中${quote(missed)}` : ';全部查询词均命中');
72
+ sections.push([summary, ...group.map(hit => `${describe(hit)}\n`
73
+ + ` 覆盖 ${hit.matched.length}/${hit.terms.length};命中${quote(hit.matched)}`
74
+ + (hit.missed.length ? `;未命中${quote(hit.missed)}` : '') + `\n ${hit.snippet}`)].join('\n\n'));
75
+ }
76
+ const spread = hits.filter(hit => hit.via === 'spread');
77
+ if (spread.length) sections.push(['【联想到的(不是直接命中)】', ...spread.map(hit => `${describe(hit)}\n`
78
+ + ` 从「${hit.from.title}」(id=${hit.from.id})联想:完整标题「${hit.from.term}」出现在关联正文中。\n ${hit.snippet}`)].join('\n\n'));
79
+ return sections.join('\n\n');
64
80
  },
65
81
  }),
66
82
  defineTool({
@@ -188,6 +204,12 @@ export function apply(ctx) {
188
204
  } catch (error) {
189
205
  warn(ctx, `图谱路由注册失败,记忆工具仍可用:${error.message}`);
190
206
  }
207
+ for (const create of [createAutoMemory, createAutoImport]) {
208
+ try {
209
+ const automation = create({ ctx, store, warn: message => warn(ctx, message) });
210
+ disposers.push(() => automation.dispose());
211
+ } catch (error) { warn(ctx, `自动任务初始化失败,记忆工具仍可用:${error.message}`); }
212
+ }
191
213
  const timer = setTimeout(() => {
192
214
  try { if (store.read().length > 0) generateMap(store); }
193
215
  catch (error) { warn(ctx, `启动时图谱生成失败:${error.message}`); }
package/lib/noise.js ADDED
@@ -0,0 +1,20 @@
1
+ export const DEFAULT_NOISE_PATTERNS = Object.freeze([
2
+ '重复唤醒', '已经回过', '回过了', '先不重复', '继续潜水', '潜水状态',
3
+ '标成已读', '已读水位', '先不发送', '没再发', '等他说下一句',
4
+ '看一眼情况', '没有新消息', '这轮是', '我先不', '对话停在这里',
5
+ '对话就停在这', '等有人 @', '再回来就是', '未读 0',
6
+ ]);
7
+
8
+ const REPORT_PREFIX = /^\s*(已回|已回复|已发送|已处理|已确认|已读|已标记|回过了|发过了)\s*[::]/u;
9
+
10
+ export function isNoise(text, extra = []) {
11
+ if (typeof text !== 'string') return false;
12
+ if (REPORT_PREFIX.test(Array.from(text).slice(0, 12).join(''))) return true;
13
+ const features = text.replace(/(?:他|她|对方|朋友|同事)(?:说|表示|告诉我)[\s“「『"']*(?:已经回过|回过了)/gu, '');
14
+ const patterns = new Set([...DEFAULT_NOISE_PATTERNS, ...extra].filter(pattern => typeof pattern === 'string' && pattern.length > 0));
15
+ let hits = 0;
16
+ for (const pattern of patterns) {
17
+ if (features.includes(pattern) && ++hits >= 2) return true;
18
+ }
19
+ return hits === 1 && Array.from(text).length < 150;
20
+ }
package/lib/recall.js CHANGED
@@ -1,25 +1,56 @@
1
1
  import { chars, retention, titleOf } from './decay.js';
2
2
 
3
+ export function queryTerms(query) {
4
+ return [...new Set(query.trim().toLocaleLowerCase().split(/[\s\p{P}\p{S}]+/u)
5
+ .filter(term => Array.from(term).length >= 2))];
6
+ }
7
+
3
8
  export function relevance(memory, query) {
4
- const q = query.trim().toLocaleLowerCase();
5
- if (!q) return 0;
9
+ const normalized = query.trim().toLocaleLowerCase();
6
10
  const title = titleOf(memory).toLocaleLowerCase();
7
11
  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;
12
+ const terms = queryTerms(query);
13
+ const matched = terms.filter(term => title.includes(term) || text.includes(term));
14
+ const missed = terms.filter(term => !matched.includes(term));
15
+ let score = 0;
16
+ for (const term of terms) {
17
+ if (title.includes(term)) score += 3;
18
+ if (text.includes(term)) score += 2;
19
+ if (title.startsWith(term)) score += 2;
20
+ }
21
+ if (normalized && title.includes(normalized)) score += 4;
22
+ if (normalized && text.includes(normalized)) score += 2;
23
+ return Object.defineProperty({ score, coverage: terms.length ? matched.length / terms.length : 0, terms, matched, missed },
24
+ Symbol.toPrimitive, { value: () => score });
25
+ }
26
+
27
+ function snippet(text, terms) {
28
+ const characters = Array.from(text);
29
+ const positions = [];
10
30
  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;
31
+ const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
32
+ for (const match of text.matchAll(new RegExp(escaped, 'giu'))) {
33
+ const start = Array.from(text.slice(0, match.index)).length;
34
+ positions.push({ start, end: start + Array.from(match[0]).length });
35
+ }
36
+ }
37
+ positions.sort((first, second) => first.start - second.start);
38
+ let selected = chars(text, 120), bestCoverage = 0;
39
+ for (const position of positions) {
40
+ const start = Math.max(0, position.start - 45);
41
+ const end = Math.min(characters.length, position.end + 45);
42
+ const context = characters.slice(start, end).join('');
43
+ const coverage = terms.filter(term => context.toLocaleLowerCase().includes(term)).length;
44
+ if (coverage <= bestCoverage) continue;
45
+ bestCoverage = coverage;
46
+ selected = (start > 0 ? '…' : '') + context + (end < characters.length ? '…' : '');
14
47
  }
15
- if (title.includes(q)) rel += 4;
16
- if (text.includes(q)) rel += 2;
17
- return rel;
48
+ return selected;
18
49
  }
19
50
 
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));
51
+ function related(first, second) {
52
+ return (Array.from(first.title).length >= 4 && second.text.includes(first.title))
53
+ || (Array.from(second.title).length >= 4 && first.text.includes(second.title));
23
54
  }
24
55
 
25
56
  export function recall(memories, { q, limit = 8, hops = 1, kind }, now = Date.now()) {
@@ -28,32 +59,40 @@ export function recall(memories, { q, limit = 8, hops = 1, kind }, now = Date.no
28
59
  hops = Math.max(0, Math.min(2, Math.trunc(Number.isFinite(hops) ? hops : 1)));
29
60
  const pool = memories.filter(memory => kind === undefined || memory.kind === kind)
30
61
  .map(memory => ({ memory, retention: retention(memory, now),
31
- title: titleOf(memory), prefix: chars(memory.text, 12), text: memory.text }));
62
+ title: titleOf(memory).toLocaleLowerCase(), text: memory.text.toLocaleLowerCase(), ...relevance(memory, q) }));
32
63
  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]));
64
+ const matches = sorted(pool.filter(row => row.matched.length > 0)
65
+ .map(row => ({ ...row, via: row.coverage >= 0.5 ? 'direct' : 'partial', score: row.score * (0.35 + 0.65 * row.retention) })));
66
+ const direct = matches.filter(row => row.via === 'direct').slice(0, limit);
67
+ const partial = matches.filter(row => row.via === 'partial').slice(0, limit - direct.length);
68
+ if (!direct.length && !partial.length) return [];
69
+ const spreadLimit = Math.min(limit - direct.length - partial.length, Math.floor(direct.length / 2));
70
+ const matchedIds = new Set(matches.map(row => row.memory.id));
71
+ const threshold = direct.length ? direct[0].score * 0.35 : Infinity;
72
+ const best = new Map();
37
73
  let frontier = direct;
38
- for (let hop = 0; hop < hops; hop++) {
74
+ for (let hop = 0; hop < hops && spreadLimit > 0; hop++) {
39
75
  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
76
  for (const source of frontier) {
44
- if (source.score * 0.45 < floor) continue;
77
+ const score = source.score * 0.45;
78
+ if (score < threshold) continue;
45
79
  for (const neighbor of pool) {
46
- if (neighbor.memory.id === source.memory.id || neighbor.retention <= 0.4
80
+ if (matchedIds.has(neighbor.memory.id) || neighbor.memory.id === source.memory.id || neighbor.retention <= 0.4
47
81
  || !related(source, neighbor)) continue;
48
- const score = source.score * 0.45;
49
82
  if (score <= (best.get(neighbor.memory.id)?.score ?? 0)) continue;
50
- const hit = { ...neighbor, score, via: 'spread' };
83
+ const sharedTitle = Array.from(source.title).length >= 4 && neighbor.text.includes(source.title)
84
+ ? titleOf(source.memory) : titleOf(neighbor.memory);
85
+ const hit = { ...neighbor, score, via: 'spread', from: { id: source.memory.id, title: titleOf(source.memory), term: sharedTitle } };
51
86
  best.set(neighbor.memory.id, hit);
52
87
  next.set(neighbor.memory.id, hit);
53
88
  }
54
89
  }
55
90
  frontier = [...next.values()];
56
91
  }
57
- return sorted([...best.values()]).slice(0, limit)
58
- .map(({ memory, score, retention: value, via }) => ({ ...memory, score, retention: value, via }));
92
+ return [...direct, ...partial, ...sorted([...best.values()]).slice(0, spreadLimit)]
93
+ .map(({ memory, score, retention: value, via, coverage, terms, matched, missed, from }) => ({
94
+ ...memory, score, retention: value, via, coverage, terms, matched, missed,
95
+ snippet: snippet(memory.text, via === 'spread' ? [from.term.toLocaleLowerCase()] : matched),
96
+ ...(from ? { from } : {}),
97
+ }));
59
98
  }
@@ -6,15 +6,18 @@ export function createRenderer(canvas, data, options = {}) {
6
6
  if (!ctx) throw new Error('Canvas 2D 不可用');
7
7
  const colors = { '人格':[255,215,106], '印象':[143,216,255], '知识':[126,231,178], '日记':[190,160,255], '对话':[255,160,140], '情绪':[255,90,90] };
8
8
  const fallback = [145,205,255];
9
- let nodes = [], links = [], hubs = [], linkTwinkle = [];
9
+ let nodes = [], links = [], hubs = [], linkTwinkle = [], visibleLinks = [];
10
10
  const byId = new Map(), adjacency = new Map(), sprites = new Map();
11
11
  const pulses = [], firing = new Map(), selections = new Set();
12
+ // 默认不画连线(数量太大会卡),只画选中光点周围的线
13
+ let selectedId=null, selectedNeighbours=new Set();
12
14
  if (typeof options.onSelect === 'function') selections.add(options.onSelect);
13
15
  let seed = 987654321;
14
16
  const random = () => ((seed = seed * 16807 % 2147483647) - 1) / 2147483646;
15
17
  let width=0,height=0,dpr=1,rotX=0.3,rotY=0.15,zoom=data.nodes.length<50?1.6:1,autoSpin=true;
16
18
  let spinSpeed=data.spinSpeed??0.35,resumeAt=0;
17
- let drag=null,nextFire=0,lastTime=0,rafId=null,running=false,destroyed=false,generation=0,observer=null;
19
+ const frameInterval=Number.isFinite(options.frameInterval)?Math.max(0,options.frameInterval):0;
20
+ let drag=null,nextFire=0,lastTime=0,lastDrawAt=0,rafId=null,running=false,destroyed=false,generation=0,observer=null;
18
21
 
19
22
  function glowSprite(rgb) {
20
23
  const off = document.createElement('canvas');
@@ -46,6 +49,8 @@ function glowSprite(rgb) {
46
49
  nodes = nextData.nodes.map(node => ({ ...node, pos: { ...node.pos } }));
47
50
  links = nextData.links.map(link => [...link]);
48
51
  byId.clear(); adjacency.clear(); sprites.clear(); pulses.length = 0; firing.clear();
52
+ selectedId=typeof options.selectedId==='string'?options.selectedId:null;
53
+ selectedNeighbours=new Set(selectedId?(adjacency.get(selectedId)||[]):[]);
49
54
  seed = 987654321; nextFire = 0;
50
55
  for (const node of nextData.nodes) byId.set(node.id, node);
51
56
  for (const node of nodes) adjacency.set(node.id, []);
@@ -71,6 +76,23 @@ function glowSprite(rgb) {
71
76
  jitterEnd: ((Math.floor(h / 977) % 100) / 100 - 0.5) * 1.6,
72
77
  };
73
78
  });
79
+ // 连线数量会随记忆增长爆炸(332 条时 383 根,479 条时 1738 根)。
80
+ // 每帧全画会卡,所以按"两端留存"排序,最多画 LINK_DRAW_CAP 根最亮的,
81
+ // 其余的不进这一帧(数据没丢,只是不画,缩放/点击仍按全量算)。
82
+ const LINK_DRAW_CAP = 1200;
83
+ if (links.length <= LINK_DRAW_CAP) {
84
+ visibleLinks = links.map((_, index) => index);
85
+ } else {
86
+ const byIdLocal = new Map(nodes.map(node => [node.id, node]));
87
+ const strength = index => {
88
+ const [left, right] = links[index];
89
+ const a = byIdLocal.get(left), b = byIdLocal.get(right);
90
+ return Math.min(a?.retention ?? 0, b?.retention ?? 0) + Math.min(a?.degree ?? 0, b?.degree ?? 0) * 0.002;
91
+ };
92
+ visibleLinks = links.map((_, index) => index)
93
+ .sort((x, y) => strength(y) - strength(x))
94
+ .slice(0, LINK_DRAW_CAP);
95
+ }
74
96
  for (const star of nodes) {
75
97
  star._rgb = colors[star.kind] || fallback;
76
98
  const count = Math.min(9, 4 + Math.round(Math.sqrt(star.degree) * 1.2));
@@ -107,17 +129,19 @@ function project(pos) {
107
129
  function fire(id,now) {
108
130
  firing.set(id,1);
109
131
  const neighbours=adjacency.get(id)||[];
110
- // 不再"点亮整个扇面":只随机挑 2~4 条邻居。
111
- // 一次把几十条边同时点亮,看上去就是全图一起闪。
112
- const count=Math.min(neighbours.length,2+Math.floor(Math.random()*3));
132
+ // 选中某个光点时,把它的关联线都点亮几条脉冲(最多 24 条,避免又糊成一片);
133
+ // 未选中时的随机心跳只挑 2~4 条,避免整片区域同时亮。
134
+ const selected = id===selectedId;
135
+ const limit = selected ? 24 : 2+Math.floor(Math.random()*3);
136
+ const count=Math.min(neighbours.length,limit);
113
137
  const picked=new Set();
114
138
  let guard=0;
115
- while (picked.size<count && neighbours.length && guard++<200) {
139
+ while (picked.size<count && neighbours.length && guard++<400) {
116
140
  picked.add(neighbours[Math.floor(Math.random()*neighbours.length)]);
117
141
  }
118
142
  for (const other of picked) {
119
143
  // 邻居亮度也压低:原来 0.65 会让整片区域同时亮起来
120
- firing.set(other,Math.max(0.26,firing.get(other)||0));
144
+ firing.set(other,Math.max(selected?0.4:0.26,firing.get(other)||0));
121
145
  pulses.push({from:id,to:other,born:now,life:700+Math.random()*2400});
122
146
  }
123
147
  }
@@ -133,6 +157,8 @@ function fire(id,now) {
133
157
  if (score<bestScore) {bestScore=score;best=star;}
134
158
  }
135
159
  if (!best) return;
160
+ selectedId = best.id;
161
+ selectedNeighbours = new Set(adjacency.get(best.id) || []);
136
162
  fire(best.id,performance.now());
137
163
  for (const callback of selections) callback(byId.get(best.id));
138
164
  }
@@ -171,10 +197,16 @@ function frame(now) {
171
197
  if (!autoSpin && !drag && now>=resumeAt) autoSpin=true;
172
198
  if (autoSpin) rotY+=0.0011*spinSpeed*3*delta/16.7;
173
199
  if (now>nextFire) {
174
- // 心跳节奏别太机械:间隔随机,偶尔连发两个,偶尔安静久一点
175
- const burst=Math.random()<0.22 ? 2 : 1;
176
- for (let i=0;i<burst && hubs.length;i++) {
177
- fire(hubs[Math.floor(Math.random()*hubs.length)].id,now);
200
+ // 默认不画连线,所以也不撒"沿线的脉冲"——否则会出现飘来飘去的小点,找不到来源。
201
+ // 选中光点后,脉冲只在它的关联线上跑。
202
+ if (selectedId) {
203
+ fire(selectedId,now);
204
+ } else {
205
+ // 没选中时只让枢纽光点自己亮一下,不动线
206
+ if (hubs.length) {
207
+ const hub=hubs[Math.floor(Math.random()*hubs.length)];
208
+ firing.set(hub.id,1);
209
+ }
178
210
  }
179
211
  nextFire=now+1400+Math.random()*3200;
180
212
  }
@@ -192,37 +224,37 @@ function frame(now) {
192
224
  }
193
225
  points.sort((left,right)=>left.star.isCore?1:right.star.isCore?-1:right.p.depth-left.p.depth);
194
226
  const screen=new Map(points.map(point=>[point.star.id,point]));
195
- ctx.globalCompositeOperation='lighter';
196
- for (let linkIndex=0;linkIndex<links.length;linkIndex++) {
197
- const [left,right]=links[linkIndex];
198
- const first=screen.get(left),second=screen.get(right);
199
- if (!first||!second) continue;
200
- const act=Math.max(firing.get(left)||0,firing.get(right)||0);
201
- // 连线自己也会呼吸:每条线有独立相位和频率,不再"齐刷刷"地一起闪。
202
- const twinkle=linkTwinkle[linkIndex];
203
- const breathe=twinkle ? (0.5+0.5*Math.sin(now*twinkle.freq+twinkle.phase))*twinkle.amp : 0;
204
- // 度数补偿:枢纽节点周围几十条线会糊成一块光斑,
205
- // 按两端度数把这类线整体压暗,让"一条线"和"一堆线"的观感接近。
206
- const degSum=(first.star.degree||0)+(second.star.degree||0);
207
- const damp=1/(1+0.07*Math.max(0,degSum-6));
208
- const alpha=Math.min(.85,(.1*Math.min(first.star.retention,second.star.retention)+.12*act+breathe)*damp);
209
- if (alpha<.01) continue;
210
- // 端点沿法线错开,多条线进入同一节点时散成扇形
211
- const dx=second.p.x-first.p.x, dy=second.p.y-first.p.y;
212
- const len=Math.hypot(dx,dy)||1;
213
- const nx=-dy/len, ny=dx/len;
214
- const r1=Math.max(2,first.star.radius*first.p.scale*.55);
215
- const r2=Math.max(2,second.star.radius*second.p.scale*.55);
216
- const sx=first.p.x+nx*r1*0.9*(twinkle?twinkle.jitterStart:0);
217
- const sy=first.p.y+ny*r1*0.9*(twinkle?twinkle.jitterStart:0);
218
- const ex=second.p.x+nx*r2*0.9*(twinkle?twinkle.jitterEnd:0);
219
- const ey=second.p.y+ny*r2*0.9*(twinkle?twinkle.jitterEnd:0);
220
- // 弯曲方向和弯度都按连线各自的哈希来,避免所有线朝同一侧弯成漩涡
221
- const bow=twinkle?twinkle.bow:0.18;
222
- const mx=(sx+ex)/2+nx*len*bow*0.5;
223
- const my=(sy+ey)/2+ny*len*bow*0.5;
224
- ctx.strokeStyle='rgba(110,190,255,'+alpha+')';ctx.lineWidth=.7+act*1.6+breathe*1.4;
225
- ctx.beginPath();ctx.moveTo(sx,sy);ctx.quadraticCurveTo(mx,my,ex,ey);ctx.stroke();
227
+ // 连线只在"选中某个光点"时画,且只画和它相连的那些。
228
+ // 全量 1738 根每帧画会卡;默认干干净净,点谁看谁的关联。
229
+ const linkPaths=[];
230
+ if (selectedId) {
231
+ for (const [left,right] of links) {
232
+ if (left!==selectedId && right!==selectedId) continue;
233
+ const first=screen.get(left),second=screen.get(right);
234
+ if (!first||!second) continue;
235
+ const target = left===selectedId ? second : first;
236
+ const ret = Math.min(first.star.retention, second.star.retention);
237
+ const alpha = Math.min(.9, .22 + ret*.35);
238
+ const dx=second.p.x-first.p.x, dy=second.p.y-first.p.y;
239
+ const len=Math.hypot(dx,dy)||1;
240
+ const nx=-dy/len, ny=dx/len;
241
+ // 朝选中点那一侧弯,形成"从中心散开"的观感
242
+ const bow = (target===second ? 1 : -1) * .16;
243
+ const mx=(first.p.x+second.p.x)/2+nx*len*bow*.5;
244
+ const my=(first.p.y+second.p.y)/2+ny*len*bow*.5;
245
+ linkPaths.push([first.p.x,first.p.y,mx,my,second.p.x,second.p.y,alpha]);
246
+ }
247
+ }
248
+ if (linkPaths.length) {
249
+ ctx.strokeStyle='rgba(110,190,255,0.30)';
250
+ ctx.lineWidth=1;
251
+ ctx.beginPath();
252
+ for (const p of linkPaths) { ctx.moveTo(p[0],p[1]); ctx.quadraticCurveTo(p[2],p[3],p[4],p[5]); }
253
+ ctx.stroke();
254
+ // 更亮的一层,让关联线看得清
255
+ ctx.strokeStyle='rgba(160,220,255,0.22)';
256
+ ctx.lineWidth=2;
257
+ ctx.stroke();
226
258
  }
227
259
  for (let index=pulses.length-1;index>=0;index--) {
228
260
  const pulse=pulses[index],fraction=(now-pulse.born)/pulse.life;
@@ -307,6 +339,13 @@ function frame(now) {
307
339
  function tick(now) {
308
340
  if (!running || destroyed || currentGeneration !== generation) return;
309
341
  rafId=null;
342
+ // 帧率上限靠 options.frameInterval 控制(默认 0 = 不限)。
343
+ // 界面里按 40fps 传;测试里不传,保持逐帧确定性。
344
+ if (frameInterval > 0 && now - lastDrawAt < frameInterval) {
345
+ rafId=window.requestAnimationFrame(tick);
346
+ return;
347
+ }
348
+ lastDrawAt = now;
310
349
  frame(now);
311
350
  if (running && !destroyed && currentGeneration === generation) rafId=window.requestAnimationFrame(tick);
312
351
  }
@@ -39,7 +39,7 @@ export function renderHtml(data, title = '记忆神经网络') {
39
39
  <div class="hud"><strong>${safeTitle}</strong><div><span id="total"></span> 个神经元 · <span id="linkCount"></span> 条突触</div><div class="sub">大小=重要度和连接数 · 亮度=留存</div></div>
40
40
  <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><button class="copy" id="copy" hidden>复制全文</button></section>
41
41
  <section class="review"><h2>复习清单 · 留存最低的记忆</h2><div id="reviewList"></div></section>
42
- <div class="tip">拖拽旋转 · 滚轮缩放 · 点击查看记忆</div>
42
+ <div class="tip">拖拽旋转 · 滚轮缩放 · 点击光点看关联</div>
43
43
  <script>
44
44
  const DATA = ${embedded};
45
45
  const canvas = document.getElementById('map');
package/lib/routes.js CHANGED
@@ -3,6 +3,7 @@ import { computeMapData } from './map.js';
3
3
  import { renderHtml } from './render/render.mjs';
4
4
  import { ONBOARD_PROMPT, ONBOARD_STEPS } from './onboard.js';
5
5
  import { readUiConfig } from './config.js';
6
+ import { updateConfig } from './config-write.js';
6
7
  import { chars, retention, titleOf } from './decay.js';
7
8
  import { scoreMemories } from './render/score.mjs';
8
9
 
@@ -74,7 +75,7 @@ function listMemories(store, query) {
74
75
  items: items.slice(offset, offset + size).map(row => ({ ...row, preview: chars(row.text, 60) + (Array.from(row.text).length > 60 ? '…' : '') })) };
75
76
  }
76
77
 
77
- export function createMemoryHandler({ store, warn = () => {} }) {
78
+ export function createMemoryHandler({ store, warn = () => {}, beforeConfigUpdate = () => {} }) {
78
79
  return async (request, response) => {
79
80
  let methods = ['GET'];
80
81
  const reply = (status, payload, html = false) => {
@@ -98,12 +99,16 @@ export function createMemoryHandler({ store, warn = () => {} }) {
98
99
  catch { return fail(404, '找不到这个记忆接口'); }
99
100
  const item = /^\/memory-engine\/api\/item\/([^/%\\]+)$/u.exec(pathname);
100
101
  if (pathname.includes('..') || pathname.includes('\\') || (!paths.has(pathname) && !item)) return fail(404, '找不到这个记忆接口');
101
- methods = item ? ['GET', 'PUT', 'DELETE'] : pathname === `${base}/api/clear` ? ['POST'] : ['GET'];
102
+ methods = item ? ['GET', 'PUT', 'DELETE'] : pathname === `${base}/api/clear` ? ['POST'] : pathname === `${base}/api/config` ? ['GET', 'PUT'] : ['GET'];
102
103
  if (!allowedMethods.has(request.method) || !methods.includes(request.method)) return fail(405, `此接口仅支持 ${methods.join('、')}`);
103
104
  if (Number(request.headers['content-length']) > 1024 * 1024) return fail(413, '请求体不能超过 1MB');
104
105
  if (request.method === 'PUT' || request.method === 'POST') {
105
106
  if (!/^application\/json(?:\s*;|$)/i.test(request.headers['content-type'] ?? '')) return fail(400, '请求体必须使用 application/json');
106
107
  const body = await readBody(request);
108
+ if (pathname === `${base}/api/config`) {
109
+ beforeConfigUpdate(body);
110
+ return reply(200, { ok: true, data: updateConfig(path.dirname(path.dirname(store.file)), body, { warn }) });
111
+ }
107
112
  return reply(200, { ok: true, data: item ? store.updateItem(item[1], body) : store.clear(body) });
108
113
  }
109
114
  if (request.headers['transfer-encoding'] || Number(request.headers['content-length']) > 0) return fail(400, request.method === 'GET' ? '此只读接口不接受请求体' : '此接口不接受请求体');
@@ -130,7 +135,8 @@ export function createMemoryHandler({ store, warn = () => {} }) {
130
135
  } catch (error) {
131
136
  if ([400, 404, 413].includes(error.status)) return fail(error.status, error.message);
132
137
  warn(`图谱请求失败:${error.message}`);
133
- return fail(500, '读取记忆失败,请检查本地插件日志');
138
+ return fail(500, request.method === 'PUT' && (request.url ?? '').split('?')[0] === `${base}/api/config`
139
+ ? '配置保存失败,请检查数据目录权限或本地插件日志' : '读取记忆失败,请检查本地插件日志');
134
140
  }
135
141
  };
136
142
  }
@@ -0,0 +1,44 @@
1
+ export function eventText(event) {
2
+ let content;
3
+ if (event?.type === 'user/message' && (!event.data?.role || event.data.role === 'user')) content = event.data?.content;
4
+ if (event?.type === 'assistant/message' && (!event.data?.message?.role || event.data.message.role === 'assistant')) content = event.data?.message?.content;
5
+ if (!Array.isArray(content)) return '';
6
+ return content.filter(block => block?.type === 'text' && typeof block.text === 'string').map(block => block.text).join('\n').trim();
7
+ }
8
+
9
+ export function usefulText(text) {
10
+ return Array.from(text).length > 1 && !/^(?:嗯|哦|噢|啊|好|好的|好呀|好哒|行|哈哈|呵呵|嘿嘿|谢谢|收到|ok|[\s,。!!~~])+$/iu.test(text)
11
+ && text.replace(/```[\s\S]*?```|~~~[\s\S]*?~~~/gu, '').trim().length > 0;
12
+ }
13
+
14
+ export function createTurnCollector(onTurn) {
15
+ const sessions = new Map();
16
+ return {
17
+ accept(session, event) {
18
+ const id = session?.id;
19
+ if (typeof id !== 'string' || !event) return;
20
+ if (!['turn/start', 'turn/end', 'user/message', 'assistant/message'].includes(event.type)) return;
21
+ let state = sessions.get(id);
22
+ if (event.type === 'turn/start' && state?.turn !== undefined && state.turn !== event.data?.turn) state = undefined;
23
+ if (!state) {
24
+ state = { text: '', userText: '', turn: undefined };
25
+ sessions.set(id, state);
26
+ if (sessions.size > 256) sessions.delete(sessions.keys().next().value);
27
+ }
28
+ if (event.type === 'turn/start') state.turn = event.data?.turn;
29
+ if (event.type === 'assistant/message' && state.turn !== undefined && event.data?.turn !== state.turn) return;
30
+ const text = eventText(event);
31
+ if (text) {
32
+ const role = event.type === 'user/message' ? 'user' : 'assistant';
33
+ state.text = Array.from(`${state.text}\n${role}: ${text}`).slice(-4000).join('');
34
+ if (role === 'user') state.userText = Array.from(`${state.userText}\n${text}`).slice(-4000).join('');
35
+ }
36
+ if (event.type === 'turn/end') {
37
+ if (state.turn !== undefined && event.data?.turn !== state.turn) return;
38
+ sessions.delete(id);
39
+ if (state.userText.trim()) onTurn({ sessionId: id, text: state.text, userText: state.userText, time: event.time });
40
+ }
41
+ },
42
+ clear() { sessions.clear(); },
43
+ };
44
+ }
package/lib/store.js CHANGED
@@ -55,7 +55,7 @@ export class MemoryStore {
55
55
  }
56
56
  }
57
57
 
58
- save({ text, kind = '对话', heat, who, ts = this.now() }) {
58
+ save({ text, kind = '对话', heat, who, ts = this.now() }, { threshold = 0.7 } = {}) {
59
59
  if (!nonempty(text) || Array.from(text).length > 2000) throw new Error('text 必须为 1–2000 字');
60
60
  if (!nonempty(kind)) throw new Error('kind 必须是非空字符串');
61
61
  if (heat !== undefined && ![1, 2, 3].includes(heat)) throw new Error('heat 只能为 1/2/3');
@@ -67,7 +67,7 @@ export class MemoryStore {
67
67
  let memory = { id: `m-${now}-${randomBytes(3).toString('hex')}`, text, kind, ts,
68
68
  heat: heat ?? inferred.heat, emotion: inferred.emotion,
69
69
  ...(who !== undefined ? { who } : {}), boosts: 0, createdAt: now };
70
- const match = findDuplicate(rows, memory);
70
+ const match = findDuplicate(rows, memory, undefined, threshold);
71
71
  if (match) {
72
72
  // A repeated save is one reinforcement; batch merges simply sum counts.
73
73
  memory.boosts = 1;