dsh-memory-engine 0.6.1 → 0.7.0-beta.4

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.
@@ -1,3 +1,8 @@
1
+ import fs from 'node:fs';
2
+ import { stripRendererExport } from '../../scripts/lib/strip-export.mjs';
3
+
4
+ const rendererSource = stripRendererExport(fs.readFileSync(new URL('./canvas.js', import.meta.url), 'utf8'));
5
+
1
6
  function escapeHtml(value) {
2
7
  return String(value).replace(/[&<>"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]);
3
8
  }
@@ -14,12 +19,13 @@ export function renderHtml(data, title = '记忆神经网络') {
14
19
  <style>
15
20
  :root{color-scheme:dark}*{box-sizing:border-box}html,body{margin:0;width:100%;height:100%}
16
21
  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}
22
+ canvas{display:block;width:100%;height:100%;touch-action:none;cursor:grab}canvas:active{cursor:grabbing}
18
23
  .hud{position:fixed;top:16px;left:18px;pointer-events:none;text-shadow:0 0 12px #3388bb88}
19
24
  .hud strong{color:#a4e6ff;font-size:16px}.sub{color:#7491a8;font-size:12px}
20
25
  .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
26
  .panel{display:none;top:16px;right:18px;width:min(350px,calc(100vw - 36px));max-height:78vh;overflow:auto}
22
27
  .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}
28
+ .panel.core .text{max-height:52vh;overflow:auto}.copy{margin-top:12px;padding:7px 12px;border:1px solid #6999b8;border-radius:8px;background:#102236;color:#e2f4ff;font:inherit;cursor:pointer}
23
29
  .close{float:right;border:0;background:transparent;color:#a4bad0;font-size:18px;cursor:pointer}
24
30
  .review{bottom:16px;left:18px;width:min(400px,calc(100vw - 36px));max-height:32vh;overflow:auto;font-size:12px}
25
31
  .review h2{font-size:14px;color:#ffdb82;margin:0 0 8px}.row{display:flex;gap:7px;align-items:center;margin:5px 0;color:#b1c6d9}
@@ -31,70 +37,14 @@ export function renderHtml(data, title = '记忆神经网络') {
31
37
  <body>
32
38
  <canvas id="map" aria-label="记忆神经网络"></canvas>
33
39
  <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>
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>
35
41
  <section class="review"><h2>复习清单 · 留存最低的记忆</h2><div id="reviewList"></div></section>
36
42
  <div class="tip">拖拽旋转 · 滚轮缩放 · 点击查看记忆</div>
37
43
  <script>
38
44
  const DATA = ${embedded};
39
45
  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});
46
+ ${rendererSource}
47
+ const renderer = createRenderer(canvas, { ...DATA, nodes: DATA.stars });
98
48
  document.getElementById('close').onclick=() => { document.getElementById('panel').style.display='none'; };
99
49
  document.getElementById('total').textContent=DATA.stats.total;
100
50
  document.getElementById('linkCount').textContent=DATA.links.length;
@@ -105,123 +55,34 @@ for (const [index,item] of DATA.review.entries()) {
105
55
  const fill=document.createElement('i'); fill.style.width=Math.min(100,Math.max(0,item.retention*100))+'%'; bar.append(fill);
106
56
  row.append(name,bar); document.getElementById('reviewList').append(row);
107
57
  }
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
- // 命中判定:先按「离光标最近的节点」选,而不是「第一个框住光标的节点」。
125
- // 原来要求 值<该节点半径,小节点容易被邻居抢走 → 点错。
126
- // 现在给每个节点一个最小容差,再用距离减去一点半径作为打分(同样近时偏向大的那个)。
127
- let best=null,bestScore=Infinity;
128
- for (const star of DATA.stars) {
129
- if (!star._screen) continue;
130
- const value=Math.hypot(star._screen.x-x,star._screen.y-y);
131
- const tolerance=Math.max(14,star._screen.r*1.15);
132
- if (value>tolerance) continue;
133
- const score=value-star._screen.r*0.22;
134
- if (score<bestScore) {bestScore=score;best=star;}
135
- }
136
- if (!best) return;
137
- fire(best.id,performance.now());
58
+ renderer.onSelect(node => {
138
59
  document.getElementById('panel').style.display='block';
139
- document.getElementById('kind').textContent=best.kind;
140
- document.getElementById('title').textContent=best.title;
141
- document.getElementById('meta').textContent='重要度 '+best.heat+' · 留存 '+best.retention.toFixed(2)+' · 连接数 '+best.degree+(best.emotion.length?' · '+best.emotion.map(item=>item.tag).join('/'):'');
142
- document.getElementById('body').textContent=best.text;
143
- }
144
- let nextFire=0,lastTime=0;
145
- function frame(now) {
146
- const delta=Math.min(50,now-lastTime||16.7); lastTime=now;
147
- if (autoSpin) rotY+=0.0011*delta/16.7;
148
- if (now>nextFire) { nextFire=now+1000+Math.random()*1500; if (hubs.length) fire(hubs[Math.floor(Math.random()*hubs.length)].id,now); }
149
- ctx.globalCompositeOperation='source-over'; ctx.fillStyle='#03060c'; ctx.fillRect(0,0,width,height);
150
- ctx.strokeStyle='rgba(40,90,140,0.055)';ctx.lineWidth=1;
151
- for (let index=0;index<20;index++) {
152
- const y=index/20*height;
153
- 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();
154
- }
155
- const points=[];
156
- for (const star of DATA.stars) {
157
- const p=project(star.pos);
158
- if (!p || p.x< -100 || p.x>width+100 || p.y< -100 || p.y>height+100) {star._screen=null;continue;}
159
- points.push({star,p});
160
- }
161
- points.sort((left,right)=>right.p.depth-left.p.depth);
162
- const screen=new Map(points.map(point=>[point.star.id,point]));
163
- ctx.globalCompositeOperation='lighter';
164
- for (const [left,right] of DATA.links) {
165
- const first=screen.get(left),second=screen.get(right);
166
- if (!first||!second) continue;
167
- const act=Math.max(firing.get(left)||0,firing.get(right)||0);
168
- const alpha=Math.min(.85,.1*Math.min(first.star.retention,second.star.retention)+.35*act);
169
- if (alpha<.01) continue;
170
- const mx=(first.p.x+second.p.x)/2+(first.p.y-second.p.y)*.18;
171
- const my=(first.p.y+second.p.y)/2+(second.p.x-first.p.x)*.18;
172
- ctx.strokeStyle='rgba(110,190,255,'+alpha+')';ctx.lineWidth=.7+act*1.6;
173
- ctx.beginPath();ctx.moveTo(first.p.x,first.p.y);ctx.quadraticCurveTo(mx,my,second.p.x,second.p.y);ctx.stroke();
174
- }
175
- for (let index=pulses.length-1;index>=0;index--) {
176
- const pulse=pulses[index],fraction=(now-pulse.born)/pulse.life;
177
- if (fraction>=1) {pulses.splice(index,1);continue;}
178
- const first=screen.get(pulse.from),second=screen.get(pulse.to);
179
- if (!first||!second) continue;
180
- 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;
181
- const inverse=1-fraction;
182
- const x=inverse*inverse*first.p.x+2*inverse*fraction*mx+fraction*fraction*second.p.x;
183
- const y=inverse*inverse*first.p.y+2*inverse*fraction*my+fraction*fraction*second.p.y;
184
- ctx.fillStyle='rgba(205,245,255,'+(.9*inverse)+')';ctx.beginPath();ctx.arc(x,y,2.5+2*inverse,0,Math.PI*2);ctx.fill();
185
- }
186
- for (const {star,p} of points) {
187
- const act=firing.get(star.id)||0,ret=Math.max(.05,Math.min(1.2,star.retention));
188
- const intensity=Math.min(1,.22+ret*.72)*(1+act*1.1);
189
- 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);
190
- const rgb=star._rgb.join(',');
191
- ctx.strokeStyle='rgba('+rgb+','+Math.min(1,.3*intensity)+')';
192
- if (radius>2.5) for (const branch of star._dendrites) {
193
- const length=radius*2.2*branch.length,dx=Math.cos(branch.angle),dy=Math.sin(branch.angle);
194
- const ex=p.x+dx*length,ey=p.y+dy*length;
195
- const cx=p.x+dx*length*.55-dy*length*branch.bend*.5;
196
- const cy=p.y+dy*length*.55+dx*length*branch.bend*.5;
197
- ctx.lineWidth=branch.width;ctx.beginPath();ctx.moveTo(p.x,p.y);ctx.quadraticCurveTo(cx,cy,ex,ey);ctx.stroke();
198
- if (branch.fork!==null) {
199
- const angle=branch.angle+branch.fork;
200
- 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();
201
- }
60
+ document.getElementById('panel').className=node.isCore?'panel core':'panel';
61
+ document.getElementById('kind').textContent=node.kind;
62
+ document.getElementById('title').textContent=node.title;
63
+ document.getElementById('meta').hidden=!!node.isCore;
64
+ document.getElementById('meta').textContent='重要度 '+node.heat+' · 留存 '+node.retention.toFixed(2)+' · 连接数 '+node.degree+(node.emotion.length?' · '+node.emotion.map(item=>item.tag).join('/'):'');
65
+ const text=node.isCore?Array.from(node.text).slice(0,4000).join(''):node.text;
66
+ document.getElementById('body').textContent=text;
67
+ document.getElementById('body').scrollTop=0;
68
+ const copy=document.getElementById('copy');
69
+ copy.hidden=!node.isCore; copy.textContent='复制全文';
70
+ const copyBody=async () => {
71
+ function fallback() {
72
+ const area=document.createElement('textarea');
73
+ area.value=text; area.style.cssText='position:fixed;left:-9999px'; document.body.append(area); area.select();
74
+ try { if (!document.execCommand('copy')) throw new Error('复制失败'); } finally { area.remove(); }
202
75
  }
203
- const glowRadius=radius*3.6;
204
- 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;
205
- ctx.fillStyle='rgba('+rgb+','+Math.min(1,.42*intensity)+')';ctx.beginPath();ctx.arc(p.x,p.y,radius,0,Math.PI*2);ctx.fill();
206
- ctx.strokeStyle='rgba('+rgb+','+Math.min(1,.75*intensity)+')';ctx.lineWidth=1;ctx.stroke();
207
- 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();
208
- if (star.retention<.25) {
209
- ctx.strokeStyle='rgba(255,110,110,'+Math.max(0,.45-star.retention)+')';ctx.setLineDash([3,4]);
210
- ctx.beginPath();ctx.arc(p.x,p.y,radius*3.2,0,Math.PI*2);ctx.stroke();ctx.setLineDash([]);
211
- }
212
- star._screen={x:p.x,y:p.y,r:Math.max(8,radius*2.6)};
213
- }
214
- for (const [id,value] of firing) {
215
- const next=value*Math.pow(.965,delta/16.7);
216
- if (next<.02) firing.delete(id); else firing.set(id,next);
217
- }
218
- ctx.globalCompositeOperation='source-over';
219
- const vignette=ctx.createRadialGradient(width/2,height/2,Math.min(width,height)*.34,width/2,height/2,Math.max(width,height)*.8);
220
- vignette.addColorStop(0,'rgba(0,0,0,0)');vignette.addColorStop(1,'rgba(0,0,0,.78)');
221
- ctx.fillStyle=vignette;ctx.fillRect(0,0,width,height);
222
- requestAnimationFrame(frame);
223
- }
224
- requestAnimationFrame(frame);
76
+ try {
77
+ if (navigator.clipboard?.writeText) {
78
+ try { await navigator.clipboard.writeText(text); } catch { fallback(); }
79
+ } else fallback();
80
+ if (copy.onclick===copyBody) copy.textContent='已复制 ✓';
81
+ } catch { if (copy.onclick===copyBody) copy.textContent='复制失败,请重试'; }
82
+ };
83
+ copy.onclick=copyBody;
84
+ });
85
+ renderer.start();
225
86
  </script>
226
87
  </body>
227
88
  </html>`;
package/lib/routes.js ADDED
@@ -0,0 +1,141 @@
1
+ import path from 'node:path';
2
+ import { computeMapData } from './map.js';
3
+ import { renderHtml } from './render/render.mjs';
4
+ import { ONBOARD_PROMPT, ONBOARD_STEPS } from './onboard.js';
5
+ import { readUiConfig } from './config.js';
6
+ import { chars, retention, titleOf } from './decay.js';
7
+ import { scoreMemories } from './render/score.mjs';
8
+
9
+ const base = '/memory-engine';
10
+ const paths = new Set([base, `${base}/`, `${base}/api/map`, `${base}/api/stats`, `${base}/api/onboard`, `${base}/api/config`,
11
+ `${base}/api/list`, `${base}/api/export`, `${base}/api/clear`]);
12
+ const allowedMethods = new Set(['GET', 'POST', 'PUT', 'DELETE']);
13
+ const escapeBridge = '<script>window.addEventListener("keydown",function(event){if(event.key==="Escape")parent.postMessage({type:"dsh-memory-engine-close"},"*")});</script>';
14
+
15
+ function readBody(request) {
16
+ return new Promise((resolve, reject) => {
17
+ let bytes = 0;
18
+ let chunks = [];
19
+ const finish = (error, value) => {
20
+ request.removeListener('data', onData);
21
+ request.removeListener('end', onEnd);
22
+ request.removeListener('error', onError);
23
+ request.removeListener('aborted', onAborted);
24
+ chunks = [];
25
+ if (error) reject(error); else resolve(value);
26
+ };
27
+ const onError = error => finish(error);
28
+ const onAborted = () => finish(Object.assign(new Error('请求已中断'), { status: 400 }));
29
+ const onData = chunk => {
30
+ bytes += chunk.length;
31
+ if (bytes > 1024 * 1024) {
32
+ finish(Object.assign(new Error('请求体不能超过 1MB'), { status: 413 }));
33
+ request.resume();
34
+ } else chunks.push(chunk);
35
+ };
36
+ const onEnd = () => {
37
+ let value;
38
+ try { value = JSON.parse(Buffer.concat(chunks).toString('utf8')); }
39
+ catch { return finish(Object.assign(new Error('请求体必须是有效 JSON'), { status: 400 })); }
40
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return finish(Object.assign(new Error('请求体必须是 JSON 对象'), { status: 400 }));
41
+ finish(null, value);
42
+ };
43
+ request.on('data', onData);
44
+ request.on('end', onEnd);
45
+ request.on('error', onError);
46
+ request.on('aborted', onAborted);
47
+ });
48
+ }
49
+
50
+ function describeMemories(rows, now) {
51
+ const items = rows.map(row => ({ ...row, title: titleOf(row) }));
52
+ const degrees = new Map(scoreMemories(items, now).nodes.map(node => [node.id, node.degree]));
53
+ return items.map(row => ({ ...row, retention: retention(row, now), degree: degrees.get(row.id) }));
54
+ }
55
+
56
+ function listMemories(store, query) {
57
+ const sort = query.get('sort') || 'recent';
58
+ const invalid = message => { throw Object.assign(new Error(message), { status: 400 }); };
59
+ if (!['recent', 'oldest', 'fading', 'degree'].includes(sort)) invalid('sort 必须是 recent、oldest、fading 或 degree');
60
+ const limit = Number(query.get('limit') ?? 50);
61
+ const offset = Number(query.get('offset') ?? 0);
62
+ if (!Number.isSafeInteger(limit) || limit < 1) invalid('limit 必须是正整数');
63
+ if (!Number.isSafeInteger(offset) || offset < 0) invalid('offset 必须是非负整数');
64
+ const rows = store.read();
65
+ const search = (query.get('q') || '').toLowerCase();
66
+ const kind = query.get('kind') || '';
67
+ const items = describeMemories(rows, store.now()).filter(row => (!kind || row.kind === kind)
68
+ && (!search || row.text.toLowerCase().includes(search) || row.title.toLowerCase().includes(search)));
69
+ const ordering = { recent: (left, right) => right.ts - left.ts, oldest: (left, right) => left.ts - right.ts,
70
+ fading: (left, right) => left.retention - right.retention, degree: (left, right) => right.degree - left.degree };
71
+ items.sort(ordering[sort]);
72
+ const size = Math.min(200, limit);
73
+ return { total: items.length, allTotal: rows.length, limit: size, offset,
74
+ items: items.slice(offset, offset + size).map(row => ({ ...row, preview: chars(row.text, 60) + (Array.from(row.text).length > 60 ? '…' : '') })) };
75
+ }
76
+
77
+ export function createMemoryHandler({ store, warn = () => {} }) {
78
+ return async (request, response) => {
79
+ let methods = ['GET'];
80
+ const reply = (status, payload, html = false) => {
81
+ response.writeHead(status, {
82
+ 'Content-Type': html ? 'text/html; charset=utf-8' : 'application/json; charset=utf-8',
83
+ 'Cache-Control': 'no-store',
84
+ 'X-Content-Type-Options': 'nosniff',
85
+ 'Referrer-Policy': 'no-referrer',
86
+ ...(status === 405 ? { Allow: methods.join(', ') } : {}),
87
+ ...(html ? { 'Content-Security-Policy': "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'self'" } : {}),
88
+ });
89
+ response.end(html ? payload : JSON.stringify(payload));
90
+ };
91
+ const fail = (status, error) => reply(status, { ok: false, error });
92
+ try {
93
+ const host = String(request.headers.host ?? '');
94
+ if (!/^(?:127\.0\.0\.1|localhost)(?::\d+)?$/.test(host)) return fail(403, '只允许本机访问记忆图谱');
95
+ const rawPath = (request.url ?? '/').split('?')[0];
96
+ let pathname;
97
+ try { pathname = decodeURIComponent(rawPath); }
98
+ catch { return fail(404, '找不到这个记忆接口'); }
99
+ const item = /^\/memory-engine\/api\/item\/([^/%\\]+)$/u.exec(pathname);
100
+ 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
+ if (!allowedMethods.has(request.method) || !methods.includes(request.method)) return fail(405, `此接口仅支持 ${methods.join('、')}`);
103
+ if (Number(request.headers['content-length']) > 1024 * 1024) return fail(413, '请求体不能超过 1MB');
104
+ if (request.method === 'PUT' || request.method === 'POST') {
105
+ if (!/^application\/json(?:\s*;|$)/i.test(request.headers['content-type'] ?? '')) return fail(400, '请求体必须使用 application/json');
106
+ const body = await readBody(request);
107
+ return reply(200, { ok: true, data: item ? store.updateItem(item[1], body) : store.clear(body) });
108
+ }
109
+ if (request.headers['transfer-encoding'] || Number(request.headers['content-length']) > 0) return fail(400, request.method === 'GET' ? '此只读接口不接受请求体' : '此接口不接受请求体');
110
+ if (item) {
111
+ if (request.method === 'DELETE') return reply(200, { ok: true, data: store.deleteItem(item[1]) });
112
+ const rows = store.read();
113
+ if (!rows.some(row => row.id === item[1])) return fail(404, '没有这条记忆');
114
+ return reply(200, { ok: true, data: describeMemories(rows, store.now()).find(row => row.id === item[1]) });
115
+ }
116
+ if (pathname === `${base}/api/list`) return reply(200, { ok: true, data: listMemories(store, new URLSearchParams(request.url.includes('?') ? request.url.slice(request.url.indexOf('?') + 1) : '')) });
117
+ if (pathname === `${base}/api/export`) {
118
+ const items = store.read();
119
+ return reply(200, { ok: true, data: { total: items.length, items } });
120
+ }
121
+ if (pathname === `${base}/api/map`) return reply(200, { ok: true, data: computeMapData(store, { warn }) });
122
+ if (pathname === `${base}/api/stats`) return reply(200, { ok: true, data: store.stats() });
123
+ if (pathname === `${base}/api/config`) return reply(200, { ok: true, data: readUiConfig(path.dirname(path.dirname(store.file)), { warn }) });
124
+ if (pathname === `${base}/api/onboard`) return reply(200, { ok: true, data: {
125
+ empty: store.read().length === 0, prompt: ONBOARD_PROMPT, steps: ONBOARD_STEPS, dataDir: path.dirname(store.file),
126
+ } });
127
+ const { nodes, ...data } = computeMapData(store, { warn });
128
+ const html = renderHtml({ ...data, stars: nodes }, '我的记忆神经网络');
129
+ return reply(200, html.replace('</body>', escapeBridge + '</body>'), true);
130
+ } catch (error) {
131
+ if ([400, 404, 413].includes(error.status)) return fail(error.status, error.message);
132
+ warn(`图谱请求失败:${error.message}`);
133
+ return fail(500, '读取记忆失败,请检查本地插件日志');
134
+ }
135
+ };
136
+ }
137
+
138
+ export function registerMemoryRoutes(webServer, options) {
139
+ if (typeof webServer?.register !== 'function') throw new Error('宿主 webServer 服务不可用');
140
+ return webServer.register({ kind: 'prefix', path: base, handler: createMemoryHandler(options) });
141
+ }
package/lib/store.js CHANGED
@@ -83,13 +83,58 @@ export class MemoryStore {
83
83
  backup() {
84
84
  fs.mkdirSync(path.dirname(this.file), { recursive: true });
85
85
  const base = `${this.file}.bak-${Date.now()}`;
86
- let file = base;
87
- for (let suffix = 1; fs.existsSync(file); suffix++) file = `${base}-${suffix}`;
86
+ const directory = path.dirname(this.file);
87
+ const prefix = path.basename(base) + '-';
88
+ const suffixes = fs.readdirSync(directory).filter(name => name.startsWith(prefix))
89
+ .map(name => Number(name.slice(prefix.length))).filter(Number.isSafeInteger);
90
+ let suffix = suffixes.length ? Math.max(...suffixes) + 1 : 0;
91
+ let file = suffix ? `${base}-${suffix}` : base;
92
+ while (fs.existsSync(file)) file = `${base}-${++suffix}`;
88
93
  if (fs.existsSync(this.file)) fs.copyFileSync(this.file, file, fs.constants.COPYFILE_EXCL);
89
94
  else fs.writeFileSync(file, '[]\n', { flag: 'wx', mode: 0o600 });
95
+ const backups = fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
96
+ const match = /^memories\.json\.bak-(\d+)(?:-(\d+))?$/u.exec(entry.name);
97
+ return entry.isFile() && match ? [{ name: entry.name, time: Number(match[1]), suffix: Number(match[2] ?? 0) }] : [];
98
+ }).sort((left, right) => right.time - left.time || right.suffix - left.suffix);
99
+ for (const old of backups.slice(5)) fs.unlinkSync(path.join(directory, old.name));
90
100
  return file;
91
101
  }
92
102
 
103
+ updateItem(id, changes) {
104
+ const invalid = message => { throw Object.assign(new Error(message), { status: 400 }); };
105
+ if (!changes || typeof changes !== 'object' || Array.isArray(changes)) invalid('请求体必须是 JSON 对象');
106
+ for (const key of Object.keys(changes)) {
107
+ if (!['text', 'kind', 'heat', 'who'].includes(key)) invalid(`不支持修改 ${key}`);
108
+ }
109
+ if (Object.hasOwn(changes, 'text') && (!nonempty(changes.text) || Array.from(changes.text).length > 2000)) invalid('text 必须是 1~2000 字符,且不能全为空白');
110
+ if (Object.hasOwn(changes, 'kind') && (!nonempty(changes.kind) || Array.from(changes.kind).length > 20)) invalid('kind 必须是 1~20 字符');
111
+ if (Object.hasOwn(changes, 'heat') && ![1, 2, 3].includes(changes.heat)) invalid('heat 必须是整数 1、2 或 3');
112
+ if (Object.hasOwn(changes, 'who') && (typeof changes.who !== 'string' || Array.from(changes.who).length > 40)) invalid('who 必须是 0~40 字符');
113
+ const rows = this.read();
114
+ const index = rows.findIndex(row => row.id === id);
115
+ if (index === -1) throw Object.assign(new Error('没有这条记忆'), { status: 404 });
116
+ rows[index] = { ...rows[index], ...changes };
117
+ const backup = this.backup();
118
+ this.write(rows);
119
+ return { ...rows[index], backup };
120
+ }
121
+
122
+ deleteItem(id) {
123
+ const rows = this.read();
124
+ if (!rows.some(row => row.id === id)) throw Object.assign(new Error('没有这条记忆'), { status: 404 });
125
+ const backup = this.backup();
126
+ this.write(rows.filter(row => row.id !== id));
127
+ return { deleted: id, backup };
128
+ }
129
+
130
+ clear({ confirm } = {}) {
131
+ if (confirm !== 'DELETE') throw Object.assign(new Error('请输入大写 DELETE 确认清空'), { status: 400 });
132
+ const rows = this.read();
133
+ const backup = this.backup();
134
+ this.write([]);
135
+ return { removed: rows.length, backup };
136
+ }
137
+
93
138
  dedupe({ threshold = 0.7, dry_run = true } = {}) {
94
139
  if (typeof dry_run !== 'boolean') throw new Error('dry_run 必须是布尔值');
95
140
  const started = performance.now();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-memory-engine",
3
- "version": "0.6.1",
3
+ "version": "0.7.0-beta.4",
4
4
  "description": "Local DSH memory engine with neuron network visualization and private file import",
5
5
  "keywords": [
6
6
  "dsh",
@@ -55,6 +55,7 @@
55
55
  }
56
56
  },
57
57
  "scripts": {
58
+ "build": "node scripts/build-client.mjs",
58
59
  "test": "node --test test/*.test.mjs"
59
60
  }
60
61
  }
@@ -0,0 +1,22 @@
1
+ import fs from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { stripRendererExport } from './lib/strip-export.mjs';
4
+
5
+ const template = fs.readFileSync(new URL('../lib/client.src.js', import.meta.url), 'utf8');
6
+ const renderer = fs.readFileSync(new URL('../lib/render/canvas.js', import.meta.url), 'utf8');
7
+ const marker = '/* __CANVAS_RENDERER__ */';
8
+ if (template.split(marker).length !== 2) throw new Error('Expected exactly one renderer marker');
9
+ const bundled = '// 由 scripts/build-client.mjs 生成,别直接改这个文件\n'
10
+ + template.replace(marker, () => stripRendererExport(renderer));
11
+ const target = new URL('../lib/client.js', import.meta.url);
12
+ if (process.argv.includes('--check')) {
13
+ if (fs.readFileSync(target, 'utf8') !== bundled) throw new Error('lib/client.js 已过期,请跑 pnpm build');
14
+ } else {
15
+ const temporary = new URL(`../lib/.client-${randomUUID()}.tmp`, import.meta.url);
16
+ try {
17
+ fs.writeFileSync(temporary, bundled, { encoding: 'utf8', flag: 'wx' });
18
+ fs.renameSync(temporary, target);
19
+ } finally {
20
+ fs.rmSync(temporary, { force: true });
21
+ }
22
+ }
@@ -0,0 +1,7 @@
1
+ export function stripRendererExport(source) {
2
+ const declaration = /^export (?=function createRenderer\()/m;
3
+ if (!declaration.test(source)) throw new Error('Missing createRenderer export');
4
+ const stripped = source.replace(declaration, '');
5
+ if (/^\s*(?:import|export)\b/m.test(stripped)) throw new Error('Renderer must be a standalone browser function');
6
+ return stripped;
7
+ }