dsh-memory-engine 0.7.0-beta.4 → 0.7.0-beta.6b

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.
@@ -0,0 +1,106 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { collectBridge } from './import.js';
4
+ import { collectFile } from './file-import.js';
5
+
6
+ function inside(root, file) {
7
+ const relative = path.relative(root, file);
8
+ return relative === '' || (!relative.startsWith('..' + path.sep) && relative !== '..' && !path.isAbsolute(relative));
9
+ }
10
+
11
+ export async function sourceFiles(source, warn) {
12
+ if (!path.isAbsolute(source.path)) throw new Error('自动导入 path 必须是绝对路径');
13
+ const requested = path.resolve(source.path);
14
+ const root = await fs.realpath(requested);
15
+ if (root !== requested) throw new Error('自动导入根路径包含软链接,请直接配置真实路径');
16
+ const info = await fs.stat(root);
17
+ const files = [];
18
+ async function checked(file) {
19
+ const real = await fs.realpath(file);
20
+ if (!inside(root, real)) throw new Error(`源文件软链接超出配置目录:${file}`);
21
+ return real;
22
+ }
23
+ async function walk(directory, extension, recursive) {
24
+ const real = await checked(directory);
25
+ for (const entry of (await fs.readdir(real, { withFileTypes: true })).sort((left, right) => left.name.localeCompare(right.name))) {
26
+ const file = path.join(real, entry.name);
27
+ try {
28
+ const resolved = await checked(file);
29
+ if (entry.isSymbolicLink()) continue;
30
+ if (entry.isDirectory() && recursive) await walk(resolved, extension, true);
31
+ else if (entry.isFile() && entry.name.toLowerCase().endsWith(extension) && (source.type !== 'diary' || !entry.name.startsWith('_'))) files.push(resolved);
32
+ } catch (error) { warn(error.message, { once: true }); }
33
+ }
34
+ }
35
+ if (['json', 'text'].includes(source.type)) {
36
+ if (!info.isFile()) throw new Error(`${source.type} 必须是文件`);
37
+ return { root, files: [root] };
38
+ }
39
+ if (!info.isDirectory()) throw new Error(`${source.type} 必须是目录`);
40
+ if (source.type === 'bridge') {
41
+ for (const name of ['state/knowledge.json', 'state/social-v2.json', 'state/starmap/stars.json', 'state/slang.json']) {
42
+ try { const file = await checked(path.join(root, name)); if ((await fs.stat(file)).isFile()) files.push(file); }
43
+ catch (error) { warn(`略过 ${path.join(root, name)}:${error.message}`, { once: true }); }
44
+ }
45
+ try { await walk(path.join(root, 'state/history'), '.jsonl', false); }
46
+ catch (error) { warn(`略过 history:${error.message}`, { once: true }); }
47
+ } else await walk(root, '.md', source.type === 'markdown');
48
+ return { root, files };
49
+ }
50
+
51
+ export async function verifySource(root, file) {
52
+ const realRoot = await fs.realpath(root);
53
+ const real = await fs.realpath(file);
54
+ if (realRoot !== root || !inside(root, real) || real !== file) throw new Error(`源路径发生变化,已略过:${file}`);
55
+ }
56
+
57
+ export async function readJsonl(file, previous, info) {
58
+ const reset = !previous || info.size < (previous.bytes ?? 0) || (info.size === previous.size && info.mtimeMs !== previous.mtime);
59
+ const base = reset ? {} : previous;
60
+ const start = base.bytes ?? 0;
61
+ const end = base.pending?.bytes ?? info.size;
62
+ const descriptor = await fs.open(file, 'r');
63
+ let buffer;
64
+ try {
65
+ buffer = Buffer.alloc(Math.max(0, end - start));
66
+ let offset = 0;
67
+ while (offset < buffer.length) {
68
+ const { bytesRead } = await descriptor.read(buffer, offset, buffer.length - offset, start + offset);
69
+ if (!bytesRead) throw new Error('读取期间源文件被截短');
70
+ offset += bytesRead;
71
+ }
72
+ } finally { await descriptor.close(); }
73
+ let content = buffer.toString('utf8');
74
+ const finalBreak = content.lastIndexOf('\n');
75
+ const tail = content.slice(finalBreak + 1);
76
+ if (tail.trim()) {
77
+ try { JSON.parse(tail); }
78
+ catch { content = content.slice(0, finalBreak + 1); }
79
+ }
80
+ const lines = content ? content.split('\n').length - (content.endsWith('\n') ? 1 : 0) : 0;
81
+ return { content, bytes: start + Buffer.byteLength(content), lines: (base.lines ?? 0) + lines,
82
+ offset: base.pending?.offset ?? 0, base: { bytes: start, lines: base.lines ?? 0 } };
83
+ }
84
+
85
+ export async function parseSourceFile(source, root, file, info, content, now) {
86
+ if (!['bridge', 'diary'].includes(source.type)) {
87
+ const collection = await collectFile({ source: source.type, path: source.type === 'markdown' ? root : file,
88
+ ...(source.type === 'markdown' ? { files: [file] } : {}) }, now);
89
+ return collection.entries.map(entry => ({ ...entry, origin: { source: source.type, file } }));
90
+ }
91
+ const isDiary = path.extname(file).toLowerCase() === '.md';
92
+ const selected = path.resolve(file);
93
+ const body = content ?? await fs.readFile(selected, 'utf8');
94
+ if (path.extname(file) === '.json') JSON.parse(body.replace(/^\uFEFF/u, ''));
95
+ const collection = collectBridge({ root, diaryDir: isDiary ? path.dirname(selected) : '', io: {
96
+ readFileSync(input) {
97
+ if (path.resolve(input) === selected) return body;
98
+ return /(?:knowledge|slang)\.json$/u.test(input) ? '[]' : '{}';
99
+ },
100
+ readdirSync(directory) {
101
+ return path.resolve(directory) === path.dirname(selected) ? [{ name: path.basename(selected), isFile: () => true }] : [];
102
+ },
103
+ statSync() { return info; },
104
+ } });
105
+ return collection.entries.map(entry => ({ ...entry, origin: { source: source.type, file } }));
106
+ }
@@ -0,0 +1,66 @@
1
+ export const memoryDefaults = Object.freeze({ enabled: true, provider: 'deepseek-account', model: 'deepseek-flash',
2
+ maxPerTurn: 2, minChars: 12, dailyCap: 40, cooldownSec: 30,
3
+ excludePatterns: Object.freeze(['密码', '身份证', '银行卡', '手机号', '验证码']) });
4
+
5
+ function object(value, name) {
6
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${name} 必须是对象`);
7
+ }
8
+
9
+ function enabled(value) {
10
+ if (typeof value !== 'boolean') throw new Error('enabled 必须是布尔值');
11
+ }
12
+
13
+ function range(value, name, minimum, maximum) {
14
+ if (!Number.isSafeInteger(value) || value < minimum || value > maximum) throw new Error(`${name} 必须是 ${minimum}~${maximum} 的整数`);
15
+ }
16
+
17
+ export function automationConfig(config = {}, warn = () => {}) {
18
+ let autoMemory = { ...memoryDefaults, excludePatterns: [...memoryDefaults.excludePatterns] };
19
+ const sources = [{ type: 'dsh-sessions' }];
20
+ if (config.bridgeRoot) sources.push({ type: 'bridge', path: config.bridgeRoot });
21
+ if (config.diaryDir) sources.push({ type: 'diary', path: config.diaryDir });
22
+ let autoImport = { enabled: true, importAssistant: false, noisePatterns: [], rescanAll: false,
23
+ sources, intervalMinutes: 60, maxPerRun: 200 };
24
+ try {
25
+ if (config.autoMemory !== undefined) object(config.autoMemory, 'autoMemory');
26
+ autoMemory = { ...autoMemory, ...config.autoMemory };
27
+ enabled(autoMemory.enabled);
28
+ for (const key of ['provider', 'model']) {
29
+ if (typeof autoMemory[key] !== 'string' || !autoMemory[key].trim()) throw new Error(`${key} 不能为空`);
30
+ autoMemory[key] = autoMemory[key].trim();
31
+ }
32
+ range(autoMemory.maxPerTurn, 'maxPerTurn', 1, 5);
33
+ range(autoMemory.minChars, 'minChars', 4, 200);
34
+ range(autoMemory.dailyCap, 'dailyCap', 0, 500);
35
+ range(autoMemory.cooldownSec, 'cooldownSec', 0, 3600);
36
+ if (!Array.isArray(autoMemory.excludePatterns) || autoMemory.excludePatterns.length > 20
37
+ || autoMemory.excludePatterns.some(pattern => typeof pattern !== 'string' || !pattern.trim())) throw new Error('excludePatterns 必须是最多 20 个非空字符串');
38
+ } catch (error) {
39
+ warn(`自动记忆配置无效,已停用:${error.message}`);
40
+ autoMemory = { ...memoryDefaults, enabled: false };
41
+ }
42
+ try {
43
+ if (config.autoImport !== undefined) object(config.autoImport, 'autoImport');
44
+ autoImport = { ...autoImport, ...config.autoImport };
45
+ enabled(autoImport.enabled);
46
+ if (typeof autoImport.importAssistant !== 'boolean') throw new Error('importAssistant 必须是布尔值');
47
+ if (typeof autoImport.rescanAll !== 'boolean') throw new Error('rescanAll 必须是布尔值');
48
+ if (autoImport.rescanFrom !== undefined && (typeof autoImport.rescanFrom !== 'string' || !autoImport.rescanFrom.trim())) throw new Error('rescanFrom 必须是非空 sessionId');
49
+ if (!Array.isArray(autoImport.noisePatterns) || autoImport.noisePatterns.some(pattern => typeof pattern !== 'string' || !pattern.trim())) throw new Error('noisePatterns 必须是非空字符串数组');
50
+ range(autoImport.intervalMinutes, 'intervalMinutes', 1, 10080);
51
+ range(autoImport.maxPerRun, 'maxPerRun', 1, 10000);
52
+ if (!Array.isArray(autoImport.sources)) throw new Error('sources 必须是数组');
53
+ autoImport.sources = autoImport.sources.map(source => {
54
+ object(source, 'source');
55
+ if (!['dsh-sessions', 'bridge', 'diary', 'json', 'markdown', 'text'].includes(source.type)) throw new Error(`未知来源 ${source.type}`);
56
+ if (source.type !== 'dsh-sessions' && (typeof source.path !== 'string' || !source.path.trim())) throw new Error(`${source.type} 缺少 path`);
57
+ return source.type === 'dsh-sessions' ? { type: source.type } : { type: source.type, path: source.path };
58
+ });
59
+ autoImport.sources.sort((left, right) => Number(right.type === 'dsh-sessions') - Number(left.type === 'dsh-sessions'));
60
+ } catch (error) {
61
+ warn(`自动导入配置无效,已停用:${error.message}`);
62
+ autoImport = { enabled: false, importAssistant: false, noisePatterns: [], rescanAll: false,
63
+ sources: [], intervalMinutes: 60, maxPerRun: 200 };
64
+ }
65
+ return { autoMemory, autoImport };
66
+ }
@@ -0,0 +1,43 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { randomBytes } from 'node:crypto';
4
+
5
+ export function readState(file, fallback) {
6
+ try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/u, '')); }
7
+ catch (error) { if (error.code === 'ENOENT') return fallback; throw error; }
8
+ }
9
+
10
+ export function writeState(file, value) {
11
+ fs.mkdirSync(path.dirname(file), { recursive: true });
12
+ const temporary = `${file}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
13
+ try {
14
+ fs.writeFileSync(temporary, JSON.stringify(value, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
15
+ fs.renameSync(temporary, file);
16
+ } finally {
17
+ try { fs.unlinkSync(temporary); } catch (error) { if (error.code !== 'ENOENT') throw error; }
18
+ }
19
+ }
20
+
21
+ export function localDay(time) {
22
+ const date = new Date(time);
23
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
24
+ }
25
+
26
+ export function warningLogger(warn, now = Date.now) {
27
+ const seen = new Set();
28
+ const times = new Map();
29
+ return (message, { once = false, key = 'runtime' } = {}) => {
30
+ if (once ? seen.has(message) : now() - (times.get(key) ?? -Infinity) < 60000) return;
31
+ if (once) seen.add(message); else times.set(key, now());
32
+ try { warn(message); } catch {}
33
+ };
34
+ }
35
+
36
+ export function service(ctx, name) {
37
+ try { return ctx?.get?.(name); } catch { return undefined; }
38
+ }
39
+
40
+ export function backupForAutomation(store, warn) {
41
+ try { store.backup(); }
42
+ catch (error) { warn(`自动记忆备份失败,继续写库:${error.message}`, { key: 'backup' }); }
43
+ }
@@ -0,0 +1,11 @@
1
+ export function classifyImport(text, sourceHint = '') {
2
+ const file = typeof sourceHint === 'string' ? sourceHint : sourceHint?.file ?? sourceHint?.path ?? '';
3
+ const value = typeof text === 'string' ? text.trim() : '';
4
+ if (/(?:^|[/\\])\d{4}-\d{2}-\d{2}\.md$/iu.test(file)) return { kind: '日记', heat: 2 };
5
+ if (/价格|多少钱|几块|块一杯|元/u.test(value)) return { kind: '知识', heat: 2 };
6
+ if (/^(?:是什么|怎么|为什么|意思是)/u.test(value)) return { kind: '知识', heat: 2 };
7
+ if (/过敏|不吃|不喜欢|怕|讨厌|最爱/u.test(value)) return { kind: '印象', heat: 1 };
8
+ if (/答应|约好|下次|明天|说好/u.test(value)) return { kind: '事件', heat: 2 };
9
+ if (value.startsWith('#') && /\d{4}(?:[-/.]\d{1,2}[-/.]\d{1,2}|年\d{1,2}月\d{1,2}日)/u.test(value)) return { kind: '日记', heat: 2 };
10
+ return { kind: '对话', heat: 2 };
11
+ }
package/lib/client.js CHANGED
@@ -22,15 +22,18 @@ window.__ModuleLoader__.load({
22
22
  if (!ctx) throw new Error('Canvas 2D 不可用');
23
23
  const colors = { '人格':[255,215,106], '印象':[143,216,255], '知识':[126,231,178], '日记':[190,160,255], '对话':[255,160,140], '情绪':[255,90,90] };
24
24
  const fallback = [145,205,255];
25
- let nodes = [], links = [], hubs = [], linkTwinkle = [];
25
+ let nodes = [], links = [], hubs = [], linkTwinkle = [], visibleLinks = [];
26
26
  const byId = new Map(), adjacency = new Map(), sprites = new Map();
27
27
  const pulses = [], firing = new Map(), selections = new Set();
28
+ // 默认不画连线(数量太大会卡),只画选中光点周围的线
29
+ let selectedId=null, selectedNeighbours=new Set();
28
30
  if (typeof options.onSelect === 'function') selections.add(options.onSelect);
29
31
  let seed = 987654321;
30
32
  const random = () => ((seed = seed * 16807 % 2147483647) - 1) / 2147483646;
31
33
  let width=0,height=0,dpr=1,rotX=0.3,rotY=0.15,zoom=data.nodes.length<50?1.6:1,autoSpin=true;
32
34
  let spinSpeed=data.spinSpeed??0.35,resumeAt=0;
33
- let drag=null,nextFire=0,lastTime=0,rafId=null,running=false,destroyed=false,generation=0,observer=null;
35
+ const frameInterval=Number.isFinite(options.frameInterval)?Math.max(0,options.frameInterval):0;
36
+ let drag=null,nextFire=0,lastTime=0,lastDrawAt=0,rafId=null,running=false,destroyed=false,generation=0,observer=null;
34
37
 
35
38
  function glowSprite(rgb) {
36
39
  const off = document.createElement('canvas');
@@ -62,6 +65,8 @@ function glowSprite(rgb) {
62
65
  nodes = nextData.nodes.map(node => ({ ...node, pos: { ...node.pos } }));
63
66
  links = nextData.links.map(link => [...link]);
64
67
  byId.clear(); adjacency.clear(); sprites.clear(); pulses.length = 0; firing.clear();
68
+ selectedId=typeof options.selectedId==='string'?options.selectedId:null;
69
+ selectedNeighbours=new Set(selectedId?(adjacency.get(selectedId)||[]):[]);
65
70
  seed = 987654321; nextFire = 0;
66
71
  for (const node of nextData.nodes) byId.set(node.id, node);
67
72
  for (const node of nodes) adjacency.set(node.id, []);
@@ -87,6 +92,23 @@ function glowSprite(rgb) {
87
92
  jitterEnd: ((Math.floor(h / 977) % 100) / 100 - 0.5) * 1.6,
88
93
  };
89
94
  });
95
+ // 连线数量会随记忆增长爆炸(332 条时 383 根,479 条时 1738 根)。
96
+ // 每帧全画会卡,所以按"两端留存"排序,最多画 LINK_DRAW_CAP 根最亮的,
97
+ // 其余的不进这一帧(数据没丢,只是不画,缩放/点击仍按全量算)。
98
+ const LINK_DRAW_CAP = 1200;
99
+ if (links.length <= LINK_DRAW_CAP) {
100
+ visibleLinks = links.map((_, index) => index);
101
+ } else {
102
+ const byIdLocal = new Map(nodes.map(node => [node.id, node]));
103
+ const strength = index => {
104
+ const [left, right] = links[index];
105
+ const a = byIdLocal.get(left), b = byIdLocal.get(right);
106
+ return Math.min(a?.retention ?? 0, b?.retention ?? 0) + Math.min(a?.degree ?? 0, b?.degree ?? 0) * 0.002;
107
+ };
108
+ visibleLinks = links.map((_, index) => index)
109
+ .sort((x, y) => strength(y) - strength(x))
110
+ .slice(0, LINK_DRAW_CAP);
111
+ }
90
112
  for (const star of nodes) {
91
113
  star._rgb = colors[star.kind] || fallback;
92
114
  const count = Math.min(9, 4 + Math.round(Math.sqrt(star.degree) * 1.2));
@@ -123,17 +145,19 @@ function project(pos) {
123
145
  function fire(id,now) {
124
146
  firing.set(id,1);
125
147
  const neighbours=adjacency.get(id)||[];
126
- // 不再"点亮整个扇面":只随机挑 2~4 条邻居。
127
- // 一次把几十条边同时点亮,看上去就是全图一起闪。
128
- const count=Math.min(neighbours.length,2+Math.floor(Math.random()*3));
148
+ // 选中某个光点时,把它的关联线都点亮几条脉冲(最多 24 条,避免又糊成一片);
149
+ // 未选中时的随机心跳只挑 2~4 条,避免整片区域同时亮。
150
+ const selected = id===selectedId;
151
+ const limit = selected ? 24 : 2+Math.floor(Math.random()*3);
152
+ const count=Math.min(neighbours.length,limit);
129
153
  const picked=new Set();
130
154
  let guard=0;
131
- while (picked.size<count && neighbours.length && guard++<200) {
155
+ while (picked.size<count && neighbours.length && guard++<400) {
132
156
  picked.add(neighbours[Math.floor(Math.random()*neighbours.length)]);
133
157
  }
134
158
  for (const other of picked) {
135
159
  // 邻居亮度也压低:原来 0.65 会让整片区域同时亮起来
136
- firing.set(other,Math.max(0.26,firing.get(other)||0));
160
+ firing.set(other,Math.max(selected?0.4:0.26,firing.get(other)||0));
137
161
  pulses.push({from:id,to:other,born:now,life:700+Math.random()*2400});
138
162
  }
139
163
  }
@@ -149,6 +173,8 @@ function fire(id,now) {
149
173
  if (score<bestScore) {bestScore=score;best=star;}
150
174
  }
151
175
  if (!best) return;
176
+ selectedId = best.id;
177
+ selectedNeighbours = new Set(adjacency.get(best.id) || []);
152
178
  fire(best.id,performance.now());
153
179
  for (const callback of selections) callback(byId.get(best.id));
154
180
  }
@@ -187,10 +213,16 @@ function frame(now) {
187
213
  if (!autoSpin && !drag && now>=resumeAt) autoSpin=true;
188
214
  if (autoSpin) rotY+=0.0011*spinSpeed*3*delta/16.7;
189
215
  if (now>nextFire) {
190
- // 心跳节奏别太机械:间隔随机,偶尔连发两个,偶尔安静久一点
191
- const burst=Math.random()<0.22 ? 2 : 1;
192
- for (let i=0;i<burst && hubs.length;i++) {
193
- fire(hubs[Math.floor(Math.random()*hubs.length)].id,now);
216
+ // 默认不画连线,所以也不撒"沿线的脉冲"——否则会出现飘来飘去的小点,找不到来源。
217
+ // 选中光点后,脉冲只在它的关联线上跑。
218
+ if (selectedId) {
219
+ fire(selectedId,now);
220
+ } else {
221
+ // 没选中时只让枢纽光点自己亮一下,不动线
222
+ if (hubs.length) {
223
+ const hub=hubs[Math.floor(Math.random()*hubs.length)];
224
+ firing.set(hub.id,1);
225
+ }
194
226
  }
195
227
  nextFire=now+1400+Math.random()*3200;
196
228
  }
@@ -208,37 +240,37 @@ function frame(now) {
208
240
  }
209
241
  points.sort((left,right)=>left.star.isCore?1:right.star.isCore?-1:right.p.depth-left.p.depth);
210
242
  const screen=new Map(points.map(point=>[point.star.id,point]));
211
- ctx.globalCompositeOperation='lighter';
212
- for (let linkIndex=0;linkIndex<links.length;linkIndex++) {
213
- const [left,right]=links[linkIndex];
214
- const first=screen.get(left),second=screen.get(right);
215
- if (!first||!second) continue;
216
- const act=Math.max(firing.get(left)||0,firing.get(right)||0);
217
- // 连线自己也会呼吸:每条线有独立相位和频率,不再"齐刷刷"地一起闪。
218
- const twinkle=linkTwinkle[linkIndex];
219
- const breathe=twinkle ? (0.5+0.5*Math.sin(now*twinkle.freq+twinkle.phase))*twinkle.amp : 0;
220
- // 度数补偿:枢纽节点周围几十条线会糊成一块光斑,
221
- // 按两端度数把这类线整体压暗,让"一条线"和"一堆线"的观感接近。
222
- const degSum=(first.star.degree||0)+(second.star.degree||0);
223
- const damp=1/(1+0.07*Math.max(0,degSum-6));
224
- const alpha=Math.min(.85,(.1*Math.min(first.star.retention,second.star.retention)+.12*act+breathe)*damp);
225
- if (alpha<.01) continue;
226
- // 端点沿法线错开,多条线进入同一节点时散成扇形
227
- const dx=second.p.x-first.p.x, dy=second.p.y-first.p.y;
228
- const len=Math.hypot(dx,dy)||1;
229
- const nx=-dy/len, ny=dx/len;
230
- const r1=Math.max(2,first.star.radius*first.p.scale*.55);
231
- const r2=Math.max(2,second.star.radius*second.p.scale*.55);
232
- const sx=first.p.x+nx*r1*0.9*(twinkle?twinkle.jitterStart:0);
233
- const sy=first.p.y+ny*r1*0.9*(twinkle?twinkle.jitterStart:0);
234
- const ex=second.p.x+nx*r2*0.9*(twinkle?twinkle.jitterEnd:0);
235
- const ey=second.p.y+ny*r2*0.9*(twinkle?twinkle.jitterEnd:0);
236
- // 弯曲方向和弯度都按连线各自的哈希来,避免所有线朝同一侧弯成漩涡
237
- const bow=twinkle?twinkle.bow:0.18;
238
- const mx=(sx+ex)/2+nx*len*bow*0.5;
239
- const my=(sy+ey)/2+ny*len*bow*0.5;
240
- ctx.strokeStyle='rgba(110,190,255,'+alpha+')';ctx.lineWidth=.7+act*1.6+breathe*1.4;
241
- ctx.beginPath();ctx.moveTo(sx,sy);ctx.quadraticCurveTo(mx,my,ex,ey);ctx.stroke();
243
+ // 连线只在"选中某个光点"时画,且只画和它相连的那些。
244
+ // 全量 1738 根每帧画会卡;默认干干净净,点谁看谁的关联。
245
+ const linkPaths=[];
246
+ if (selectedId) {
247
+ for (const [left,right] of links) {
248
+ if (left!==selectedId && right!==selectedId) continue;
249
+ const first=screen.get(left),second=screen.get(right);
250
+ if (!first||!second) continue;
251
+ const target = left===selectedId ? second : first;
252
+ const ret = Math.min(first.star.retention, second.star.retention);
253
+ const alpha = Math.min(.9, .22 + ret*.35);
254
+ const dx=second.p.x-first.p.x, dy=second.p.y-first.p.y;
255
+ const len=Math.hypot(dx,dy)||1;
256
+ const nx=-dy/len, ny=dx/len;
257
+ // 朝选中点那一侧弯,形成"从中心散开"的观感
258
+ const bow = (target===second ? 1 : -1) * .16;
259
+ const mx=(first.p.x+second.p.x)/2+nx*len*bow*.5;
260
+ const my=(first.p.y+second.p.y)/2+ny*len*bow*.5;
261
+ linkPaths.push([first.p.x,first.p.y,mx,my,second.p.x,second.p.y,alpha]);
262
+ }
263
+ }
264
+ if (linkPaths.length) {
265
+ ctx.strokeStyle='rgba(110,190,255,0.30)';
266
+ ctx.lineWidth=1;
267
+ ctx.beginPath();
268
+ for (const p of linkPaths) { ctx.moveTo(p[0],p[1]); ctx.quadraticCurveTo(p[2],p[3],p[4],p[5]); }
269
+ ctx.stroke();
270
+ // 更亮的一层,让关联线看得清
271
+ ctx.strokeStyle='rgba(160,220,255,0.22)';
272
+ ctx.lineWidth=2;
273
+ ctx.stroke();
242
274
  }
243
275
  for (let index=pulses.length-1;index>=0;index--) {
244
276
  const pulse=pulses[index],fraction=(now-pulse.born)/pulse.life;
@@ -323,6 +355,13 @@ function frame(now) {
323
355
  function tick(now) {
324
356
  if (!running || destroyed || currentGeneration !== generation) return;
325
357
  rafId=null;
358
+ // 帧率上限靠 options.frameInterval 控制(默认 0 = 不限)。
359
+ // 界面里按 40fps 传;测试里不传,保持逐帧确定性。
360
+ if (frameInterval > 0 && now - lastDrawAt < frameInterval) {
361
+ rafId=window.requestAnimationFrame(tick);
362
+ return;
363
+ }
364
+ lastDrawAt = now;
326
365
  frame(now);
327
366
  if (running && !destroyed && currentGeneration === generation) rafId=window.requestAnimationFrame(tick);
328
367
  }
@@ -537,7 +576,7 @@ function frame(now) {
537
576
  setError("");
538
577
  readApi("/memory-engine/api/map", controller.signal).then(function (nextData) {
539
578
  if (!active) return;
540
- renderer = createRenderer(canvas.current, nextData);
579
+ renderer = createRenderer(canvas.current, nextData, { frameInterval: 25 });
541
580
  props.rendererRef.current = renderer;
542
581
  renderer.onSelect(function (node) { props.selectionVersion.current++; props.onSelect(node); });
543
582
  renderer.start();
@@ -577,7 +616,7 @@ function frame(now) {
577
616
  React.createElement("span", { style: { width: "52px", height: "5px", borderRadius: "5px", background: "#335066", overflow: "hidden" } },
578
617
  React.createElement("i", { style: { display: "block", height: "100%", background: "#6bc9fa", width: Math.min(100, Math.max(0, item.retention * 100)) + "%" } })));
579
618
  })) : null,
580
- React.createElement("div", { style: { position: "absolute", right: "18px", bottom: "18px", pointerEvents: "none", color: "#7b99ad", font: "13px system-ui,sans-serif" } }, "拖拽旋转 · 滚轮缩放 · 点击选中神经元"),
619
+ React.createElement("div", { style: { position: "absolute", right: "18px", bottom: "18px", pointerEvents: "none", color: "#7b99ad", font: "13px system-ui,sans-serif" } }, "拖拽旋转 · 滚轮缩放 · 点光点看关联"),
581
620
  error ? React.createElement("div", { role: "alert", style: { position: "absolute", inset: "40% 20% auto", textAlign: "center", color: "#d8e9f8" } },
582
621
  React.createElement("p", null, "图谱加载失败:" + error),
583
622
  React.createElement("button", { type: "button", style: actionStyle, onClick: function () { setRetry(function (value) { return value + 1; }); } }, "重试")) : null);
package/lib/client.src.js CHANGED
@@ -188,7 +188,7 @@ window.__ModuleLoader__.load({
188
188
  setError("");
189
189
  readApi("/memory-engine/api/map", controller.signal).then(function (nextData) {
190
190
  if (!active) return;
191
- renderer = createRenderer(canvas.current, nextData);
191
+ renderer = createRenderer(canvas.current, nextData, { frameInterval: 25 });
192
192
  props.rendererRef.current = renderer;
193
193
  renderer.onSelect(function (node) { props.selectionVersion.current++; props.onSelect(node); });
194
194
  renderer.start();
@@ -228,7 +228,7 @@ window.__ModuleLoader__.load({
228
228
  React.createElement("span", { style: { width: "52px", height: "5px", borderRadius: "5px", background: "#335066", overflow: "hidden" } },
229
229
  React.createElement("i", { style: { display: "block", height: "100%", background: "#6bc9fa", width: Math.min(100, Math.max(0, item.retention * 100)) + "%" } })));
230
230
  })) : null,
231
- React.createElement("div", { style: { position: "absolute", right: "18px", bottom: "18px", pointerEvents: "none", color: "#7b99ad", font: "13px system-ui,sans-serif" } }, "拖拽旋转 · 滚轮缩放 · 点击选中神经元"),
231
+ React.createElement("div", { style: { position: "absolute", right: "18px", bottom: "18px", pointerEvents: "none", color: "#7b99ad", font: "13px system-ui,sans-serif" } }, "拖拽旋转 · 滚轮缩放 · 点光点看关联"),
232
232
  error ? React.createElement("div", { role: "alert", style: { position: "absolute", inset: "40% 20% auto", textAlign: "center", color: "#d8e9f8" } },
233
233
  React.createElement("p", null, "图谱加载失败:" + error),
234
234
  React.createElement("button", { type: "button", style: actionStyle, onClick: function () { setRetry(function (value) { return value + 1; }); } }, "重试")) : null);
package/lib/config.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
+ import { automationConfig } from './automation-config.js';
5
+ import { readLastError } from './auto-log.js';
4
6
 
5
7
  export const defaultHome = () => process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
6
8
 
@@ -11,7 +13,10 @@ export function resolveDiaryDir({ home = defaultHome(), envDir = process.env.QQ_
11
13
  export function readUiConfig(home = defaultHome(), options = {}) {
12
14
  const config = readConfig(home);
13
15
  return { hideButton: config.hideButton === true, showGuideOnEmpty: config.showGuideOnEmpty !== false,
14
- hasPersona: readPersona(config, options) !== null, personaTitle: config.personaTitle, spinSpeed: config.spinSpeed };
16
+ hasPersona: readPersona(config, options) !== null, personaTitle: config.personaTitle, spinSpeed: config.spinSpeed,
17
+ autoMemory: { enabled: config.autoMemory.enabled, maxPerTurn: config.autoMemory.maxPerTurn },
18
+ autoImport: { enabled: config.autoImport.enabled, sourceCount: config.autoImport.sources.length },
19
+ lastError: readLastError(path.join(home, 'memory-engine', 'auto-log.json'), options.warn) };
15
20
  }
16
21
 
17
22
  export function readPersona(config, { now = Date.now(), warn = message => console.warn(`[dsh-memory-engine] ${message}`) } = {}) {
@@ -34,7 +39,7 @@ export function readPersona(config, { now = Date.now(), warn = message => consol
34
39
  }
35
40
  }
36
41
 
37
- export function readConfig(home = defaultHome()) {
42
+ export function readConfig(home = defaultHome(), { warn } = {}) {
38
43
  const file = path.join(home, 'memory-engine', 'config.json');
39
44
  try {
40
45
  const value = JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/u, ''));
@@ -46,9 +51,9 @@ export function readConfig(home = defaultHome()) {
46
51
  if (value.spinSpeed !== undefined && (!Number.isFinite(value.spinSpeed) || value.spinSpeed < 0.05 || value.spinSpeed > 1.5)) {
47
52
  throw new Error('spinSpeed 必须是 0.05~1.5 的数字');
48
53
  }
49
- return { ...value, personaTitle: value.personaTitle?.trim() || '角色卡', spinSpeed: value.spinSpeed ?? 0.35 };
54
+ return { ...value, personaTitle: value.personaTitle?.trim() || '角色卡', spinSpeed: value.spinSpeed ?? 0.35, ...automationConfig(value, warn) };
50
55
  } catch (error) {
51
- if (error.code === 'ENOENT') return { personaTitle: '角色卡', spinSpeed: 0.35 };
56
+ if (error.code === 'ENOENT') return { personaTitle: '角色卡', spinSpeed: 0.35, ...automationConfig({}, warn) };
52
57
  throw new Error(`无法读取 ${file}:${error.message}`);
53
58
  }
54
59
  }
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) {