dsh-memory-engine 0.7.0-beta.2b → 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.
@@ -6,13 +6,14 @@ 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 = [];
9
+ let nodes = [], links = [], hubs = [], linkTwinkle = [];
10
10
  const byId = new Map(), adjacency = new Map(), sprites = new Map();
11
11
  const pulses = [], firing = new Map(), selections = new Set();
12
12
  if (typeof options.onSelect === 'function') selections.add(options.onSelect);
13
13
  let seed = 987654321;
14
14
  const random = () => ((seed = seed * 16807 % 2147483647) - 1) / 2147483646;
15
15
  let width=0,height=0,dpr=1,rotX=0.3,rotY=0.15,zoom=data.nodes.length<50?1.6:1,autoSpin=true;
16
+ let spinSpeed=data.spinSpeed??0.35,resumeAt=0;
16
17
  let drag=null,nextFire=0,lastTime=0,rafId=null,running=false,destroyed=false,generation=0,observer=null;
17
18
 
18
19
  function glowSprite(rgb) {
@@ -27,8 +28,21 @@ function glowSprite(rgb) {
27
28
  return off;
28
29
  }
29
30
 
31
+ // 字符串 → 32 位无符号哈希。给连线的闪烁参数做种子:
32
+ // 用 id 而不是 Math.random,保证同一条线在每次打开时节奏一致(不会每次刷新都跳),
33
+ // 但不同连线之间互不同步。
34
+ function hashString(text) {
35
+ let hash = 2166136261;
36
+ for (let i = 0; i < text.length; i++) {
37
+ hash ^= text.charCodeAt(i);
38
+ hash = Math.imul(hash, 16777619);
39
+ }
40
+ return hash >>> 0;
41
+ }
42
+
30
43
  function setData(nextData) {
31
44
  if (destroyed) return;
45
+ spinSpeed=nextData.spinSpeed??0.35;
32
46
  nodes = nextData.nodes.map(node => ({ ...node, pos: { ...node.pos } }));
33
47
  links = nextData.links.map(link => [...link]);
34
48
  byId.clear(); adjacency.clear(); sprites.clear(); pulses.length = 0; firing.clear();
@@ -39,6 +53,24 @@ function glowSprite(rgb) {
39
53
  if (adjacency.has(left) && adjacency.has(right)) { adjacency.get(left).push(right); adjacency.get(right).push(left); }
40
54
  }
41
55
  hubs = nodes.filter(star => star.degree > 0).sort((left,right) => right.degree-left.degree).slice(0,6);
56
+ // 每条连线一份自己的闪烁参数:相位和频率都不一样,整体才不会"齐刷刷"地一起亮。
57
+ // 用连线两端 id 的哈希做种子,保证同一条线每次打开图的节奏一致(不会跳变),
58
+ // 但线与线之间互不同步。
59
+ linkTwinkle = links.map(([left, right]) => {
60
+ const h = hashString(left + '|' + right);
61
+ return {
62
+ // 频率调低:周期从 4~18 秒放慢到 18~55 秒,变成"慢慢呼吸"而不是"闪"
63
+ freq: 0.000114 + (h % 1000) / 1000 * 0.000235,
64
+ phase: (Math.floor(h / 1000) % 6283) / 1000, // 0 ~ 6.28,彼此错开
65
+ amp: 0.05 + (Math.floor(h / 7e6) % 100) / 100 * 0.12,
66
+ // 弯曲:一半往左弯一半往右弯,弯度 8%~24%。
67
+ // 之前所有线都朝同一侧弯,视觉上像漩涡,边界区域就显空。
68
+ bow: (h % 2 === 0 ? 1 : -1) * (0.08 + (Math.floor(h / 13) % 100) / 100 * 0.16),
69
+ // 端点偏移:同一个节点被很多线连时,让它们从不同角度进来,散成扇面而不是叠成一条
70
+ jitterStart: ((Math.floor(h / 31) % 100) / 100 - 0.5) * 1.6,
71
+ jitterEnd: ((Math.floor(h / 977) % 100) / 100 - 0.5) * 1.6,
72
+ };
73
+ });
42
74
  for (const star of nodes) {
43
75
  star._rgb = colors[star.kind] || fallback;
44
76
  const count = Math.min(9, 4 + Math.round(Math.sqrt(star.degree) * 1.2));
@@ -74,9 +106,19 @@ function project(pos) {
74
106
  }
75
107
  function fire(id,now) {
76
108
  firing.set(id,1);
77
- for (const other of adjacency.get(id)||[]) {
78
- firing.set(other,Math.max(0.65,firing.get(other)||0));
79
- pulses.push({from:id,to:other,born:now,life:900+Math.random()*700});
109
+ const neighbours=adjacency.get(id)||[];
110
+ // 不再"点亮整个扇面":只随机挑 2~4 条邻居。
111
+ // 一次把几十条边同时点亮,看上去就是全图一起闪。
112
+ const count=Math.min(neighbours.length,2+Math.floor(Math.random()*3));
113
+ const picked=new Set();
114
+ let guard=0;
115
+ while (picked.size<count && neighbours.length && guard++<200) {
116
+ picked.add(neighbours[Math.floor(Math.random()*neighbours.length)]);
117
+ }
118
+ for (const other of picked) {
119
+ // 邻居亮度也压低:原来 0.65 会让整片区域同时亮起来
120
+ firing.set(other,Math.max(0.26,firing.get(other)||0));
121
+ pulses.push({from:id,to:other,born:now,life:700+Math.random()*2400});
80
122
  }
81
123
  }
82
124
 
@@ -96,6 +138,7 @@ function fire(id,now) {
96
138
  }
97
139
 
98
140
  function onPointerDown(event) {
141
+ resumeAt=Infinity;
99
142
  autoSpin=false; drag={x:event.clientX,y:event.clientY,moved:0,pointerId:event.pointerId};
100
143
  canvas.setPointerCapture(event.pointerId);
101
144
  }
@@ -106,6 +149,7 @@ function fire(id,now) {
106
149
  rotY+=dx*0.006; rotX=Math.max(-1.5,Math.min(1.5,rotX+dy*0.006));
107
150
  }
108
151
  function releaseDrag() {
152
+ if (drag && running) resumeAt=performance.now()+2000;
109
153
  if (drag && canvas.hasPointerCapture(drag.pointerId)) canvas.releasePointerCapture(drag.pointerId);
110
154
  drag=null;
111
155
  }
@@ -124,13 +168,21 @@ function fire(id,now) {
124
168
 
125
169
  function frame(now) {
126
170
  const delta=Math.min(50,now-lastTime||16.7); lastTime=now;
127
- if (autoSpin) rotY+=0.0011*delta/16.7;
128
- if (now>nextFire) { nextFire=now+1000+Math.random()*1500; if (hubs.length) fire(hubs[Math.floor(Math.random()*hubs.length)].id,now); }
171
+ if (!autoSpin && !drag && now>=resumeAt) autoSpin=true;
172
+ if (autoSpin) rotY+=0.0011*spinSpeed*3*delta/16.7;
173
+ 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);
178
+ }
179
+ nextFire=now+1400+Math.random()*3200;
180
+ }
129
181
  ctx.globalCompositeOperation='source-over'; ctx.fillStyle='#03060c'; ctx.fillRect(0,0,width,height);
130
182
  ctx.strokeStyle='rgba(40,90,140,0.055)';ctx.lineWidth=1;
131
183
  for (let index=0;index<20;index++) {
132
184
  const y=index/20*height;
133
- 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();
185
+ ctx.beginPath();ctx.moveTo(0,y);ctx.bezierCurveTo(width*.3,y+Math.sin(now*.00007+index)*30,width*.7,y-Math.cos(now*.00006+index)*30,width,y);ctx.stroke();
134
186
  }
135
187
  const points=[];
136
188
  for (const star of nodes) {
@@ -138,19 +190,39 @@ function frame(now) {
138
190
  if (!p || p.x< -100 || p.x>width+100 || p.y< -100 || p.y>height+100) {star._screen=null;continue;}
139
191
  points.push({star,p});
140
192
  }
141
- points.sort((left,right)=>right.p.depth-left.p.depth);
193
+ points.sort((left,right)=>left.star.isCore?1:right.star.isCore?-1:right.p.depth-left.p.depth);
142
194
  const screen=new Map(points.map(point=>[point.star.id,point]));
143
195
  ctx.globalCompositeOperation='lighter';
144
- for (const [left,right] of links) {
196
+ for (let linkIndex=0;linkIndex<links.length;linkIndex++) {
197
+ const [left,right]=links[linkIndex];
145
198
  const first=screen.get(left),second=screen.get(right);
146
199
  if (!first||!second) continue;
147
200
  const act=Math.max(firing.get(left)||0,firing.get(right)||0);
148
- const alpha=Math.min(.85,.1*Math.min(first.star.retention,second.star.retention)+.35*act);
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);
149
209
  if (alpha<.01) continue;
150
- const mx=(first.p.x+second.p.x)/2+(first.p.y-second.p.y)*.18;
151
- const my=(first.p.y+second.p.y)/2+(second.p.x-first.p.x)*.18;
152
- ctx.strokeStyle='rgba(110,190,255,'+alpha+')';ctx.lineWidth=.7+act*1.6;
153
- ctx.beginPath();ctx.moveTo(first.p.x,first.p.y);ctx.quadraticCurveTo(mx,my,second.p.x,second.p.y);ctx.stroke();
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();
154
226
  }
155
227
  for (let index=pulses.length-1;index>=0;index--) {
156
228
  const pulse=pulses[index],fraction=(now-pulse.born)/pulse.life;
@@ -166,8 +238,25 @@ function frame(now) {
166
238
  for (const {star,p} of points) {
167
239
  const act=firing.get(star.id)||0,ret=Math.max(.05,Math.min(1.2,star.retention));
168
240
  const intensity=Math.min(1,.22+ret*.72)*(1+act*1.1);
169
- 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);
241
+ // 每颗星自己的呼吸频率和相位:从 id 哈希来,避免全图同一个节拍
242
+ if (star._pulse===undefined) {
243
+ const h=hashString(star.id);
244
+ star._pulse={freq:0.00018+(h%1000)/1000*0.00045,phase:(Math.floor(h/1000)%6283)/1000,amp:0.035+(Math.floor(h/7e6)%100)/100*0.045};
245
+ }
246
+ const pulse=star._pulse;
247
+ const breathe=Math.sin(now*pulse.freq+pulse.phase)*pulse.amp;
248
+ const radius=Math.max(2,star.radius*p.scale*.55)*(1+breathe)*(1+act*.5);
170
249
  const rgb=star._rgb.join(',');
250
+ if (star.isCore) {
251
+ const halo=ctx.createRadialGradient(p.x,p.y,radius*.75,p.x,p.y,radius*2.2);
252
+ halo.addColorStop(0,'rgba('+rgb+',0.12)');
253
+ halo.addColorStop(.65,'rgba('+rgb+',0.04)');
254
+ halo.addColorStop(1,'rgba('+rgb+',0)');
255
+ ctx.fillStyle=halo;ctx.beginPath();ctx.arc(p.x,p.y,radius*2.2,0,Math.PI*2);ctx.fill();
256
+ ctx.strokeStyle='rgba('+rgb+',0.38)';ctx.lineWidth=.7;ctx.setLineDash([3,5]);ctx.lineDashOffset=-now*.008;
257
+ ctx.beginPath();ctx.arc(p.x,p.y,radius*1.6,0,Math.PI*2);ctx.stroke();
258
+ ctx.setLineDash([]);ctx.lineDashOffset=0;
259
+ }
171
260
  ctx.strokeStyle='rgba('+rgb+','+Math.min(1,.3*intensity)+')';
172
261
  if (radius>2.5) for (const branch of star._dendrites) {
173
262
  const length=radius*2.2*branch.length,dx=Math.cos(branch.angle),dy=Math.sin(branch.angle);
@@ -202,7 +291,7 @@ function frame(now) {
202
291
  }
203
292
  function start() {
204
293
  if (running || destroyed) return;
205
- running=true; lastTime=0; nextFire=0;
294
+ running=true; lastTime=0; nextFire=0; autoSpin=true; resumeAt=0;
206
295
  const currentGeneration = ++generation;
207
296
  canvas.addEventListener('pointerdown',onPointerDown);
208
297
  canvas.addEventListener('pointermove',onPointerMove);
@@ -1,6 +1,49 @@
1
1
  const SEED = 20261030;
2
2
 
3
3
  export function layoutMemories(nodes) {
4
+ if (!nodes.some(node => node.isCore)) return layoutBrain(nodes);
5
+ const golden = Math.PI * (3 - Math.sqrt(5));
6
+ const inner = node => ['印象', '知识', '事件'].includes(node.kind);
7
+ const memories = nodes.filter(node => !node.isCore);
8
+ const shells = [memories.filter(inner), memories.filter(node => !inner(node))];
9
+ const placed = new Map();
10
+ function jitter(index) {
11
+ let hash = (index + 1) ^ SEED;
12
+ hash = Math.imul(hash ^ (hash >>> 16), 0x45d9f3b);
13
+ hash = Math.imul(hash ^ (hash >>> 16), 0x45d9f3b);
14
+ return ((hash ^ (hash >>> 16)) >>> 0) / 4294967295 * 2 - 1;
15
+ }
16
+ shells.forEach((shell, layer) => {
17
+ shell.forEach((node, index) => {
18
+ const vertical = 1 - index / Math.max(1, shell.length - 1) * 2;
19
+ const radial = Math.sqrt(Math.max(0, 1 - vertical * vertical));
20
+ const angle = golden * (index + layer * 0.5);
21
+ const event = node.kind === '事件';
22
+ // 壳做厚,而不是贴在球面上:薄壳会把 236 个节点挤成一团。
23
+ // 内层 26~58(厚度 32),外层 70~108,节点沿半径方向均匀铺开,看起来才有立体感。
24
+ const layerMin = layer === 0 ? 26 : 70;
25
+ const layerMax = layer === 0 ? 58 : 108;
26
+ const age = layer === 1 ? Math.min(1, Math.max(0, node.ageDays / 30)) * 12 : 0;
27
+ // jitter 归一到 0~1,在 [layerMin, layerMax] 之间铺开
28
+ const spread = 0.5 + 0.5 * jitter(index + layer * memories.length);
29
+ // 情绪重的事件往内层靠一点,视觉上更"贴身"
30
+ const pull = event && node.majorEmotion ? 7 * spread : 0;
31
+ const distance = layerMin + (layerMax - layerMin) * spread - pull + age;
32
+ const baseRadius = node.majorEmotion ? 3.2 : layer === 0
33
+ ? (node.heat === 1 ? 2.4 : node.heat === 2 ? 1.8 : 1.3)
34
+ : (node.heat === 1 ? 2.1 : node.heat === 2 ? 1.5 : 1.0);
35
+ placed.set(node, { ...node, pos: {
36
+ x: Math.cos(angle) * radial * distance,
37
+ y: vertical * distance * 0.92,
38
+ z: Math.sin(angle) * radial * distance,
39
+ }, radius: Math.min(layer === 0 ? 4.2 : 3.2, baseRadius + Math.min(1.1, Math.sqrt(node.degree) * 0.15)) });
40
+ });
41
+ });
42
+ relax([...placed.values()], { iterations: 80, gap: 1.9, strength: 0.5 });
43
+ return nodes.map(node => node.isCore ? { ...node, pos: { x: 0, y: 0, z: 0 }, radius: 8 } : placed.get(node));
44
+ }
45
+
46
+ function layoutBrain(nodes) {
4
47
  let seed = SEED;
5
48
  const random = () => ((seed = (seed * 16807) % 2147483647) - 1) / 2147483646;
6
49
  function brainPoint(radiusScale) {
@@ -25,6 +25,7 @@ export function renderHtml(data, title = '记忆神经网络') {
25
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)}
26
26
  .panel{display:none;top:16px;right:18px;width:min(350px,calc(100vw - 36px));max-height:78vh;overflow:auto}
27
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}
28
29
  .close{float:right;border:0;background:transparent;color:#a4bad0;font-size:18px;cursor:pointer}
29
30
  .review{bottom:16px;left:18px;width:min(400px,calc(100vw - 36px));max-height:32vh;overflow:auto;font-size:12px}
30
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}
@@ -36,7 +37,7 @@ export function renderHtml(data, title = '记忆神经网络') {
36
37
  <body>
37
38
  <canvas id="map" aria-label="记忆神经网络"></canvas>
38
39
  <div class="hud"><strong>${safeTitle}</strong><div><span id="total"></span> 个神经元 · <span id="linkCount"></span> 条突触</div><div class="sub">大小=重要度和连接数 · 亮度=留存</div></div>
39
- <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>
40
41
  <section class="review"><h2>复习清单 · 留存最低的记忆</h2><div id="reviewList"></div></section>
41
42
  <div class="tip">拖拽旋转 · 滚轮缩放 · 点击查看记忆</div>
42
43
  <script>
@@ -56,10 +57,30 @@ for (const [index,item] of DATA.review.entries()) {
56
57
  }
57
58
  renderer.onSelect(node => {
58
59
  document.getElementById('panel').style.display='block';
60
+ document.getElementById('panel').className=node.isCore?'panel core':'panel';
59
61
  document.getElementById('kind').textContent=node.kind;
60
62
  document.getElementById('title').textContent=node.title;
63
+ document.getElementById('meta').hidden=!!node.isCore;
61
64
  document.getElementById('meta').textContent='重要度 '+node.heat+' · 留存 '+node.retention.toFixed(2)+' · 连接数 '+node.degree+(node.emotion.length?' · '+node.emotion.map(item=>item.tag).join('/'):'');
62
- document.getElementById('body').textContent=node.text;
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(); }
75
+ }
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;
63
84
  });
64
85
  renderer.start();
65
86
  </script>
package/lib/routes.js CHANGED
@@ -3,21 +3,87 @@ 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 { chars, retention, titleOf } from './decay.js';
7
+ import { scoreMemories } from './render/score.mjs';
6
8
 
7
9
  const base = '/memory-engine';
8
- const paths = new Set([base, `${base}/`, `${base}/api/map`, `${base}/api/stats`, `${base}/api/onboard`, `${base}/api/config`]);
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`]);
9
12
  const allowedMethods = new Set(['GET', 'POST', 'PUT', 'DELETE']);
10
13
  const escapeBridge = '<script>window.addEventListener("keydown",function(event){if(event.key==="Escape")parent.postMessage({type:"dsh-memory-engine-close"},"*")});</script>';
11
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
+
12
77
  export function createMemoryHandler({ store, warn = () => {} }) {
13
78
  return async (request, response) => {
79
+ let methods = ['GET'];
14
80
  const reply = (status, payload, html = false) => {
15
81
  response.writeHead(status, {
16
82
  'Content-Type': html ? 'text/html; charset=utf-8' : 'application/json; charset=utf-8',
17
83
  'Cache-Control': 'no-store',
18
84
  'X-Content-Type-Options': 'nosniff',
19
85
  'Referrer-Policy': 'no-referrer',
20
- ...(status === 405 ? { Allow: 'GET' } : {}),
86
+ ...(status === 405 ? { Allow: methods.join(', ') } : {}),
21
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'" } : {}),
22
88
  });
23
89
  response.end(html ? payload : JSON.stringify(payload));
@@ -30,20 +96,39 @@ export function createMemoryHandler({ store, warn = () => {} }) {
30
96
  let pathname;
31
97
  try { pathname = decodeURIComponent(rawPath); }
32
98
  catch { return fail(404, '找不到这个记忆接口'); }
33
- if (pathname.includes('..') || pathname.includes('\\') || !paths.has(pathname)) return fail(404, '找不到这个记忆接口');
34
- if (!allowedMethods.has(request.method) || request.method !== 'GET') return fail(405, '此接口仅支持 GET');
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('、')}`);
35
103
  if (Number(request.headers['content-length']) > 1024 * 1024) return fail(413, '请求体不能超过 1MB');
36
- if (request.headers['transfer-encoding'] || Number(request.headers['content-length']) > 0) return fail(400, '此只读接口不接受请求体');
37
- if (pathname === `${base}/api/map`) return reply(200, { ok: true, data: computeMapData(store) });
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 }) });
38
122
  if (pathname === `${base}/api/stats`) return reply(200, { ok: true, data: store.stats() });
39
- if (pathname === `${base}/api/config`) return reply(200, { ok: true, data: readUiConfig(path.dirname(path.dirname(store.file))) });
123
+ if (pathname === `${base}/api/config`) return reply(200, { ok: true, data: readUiConfig(path.dirname(path.dirname(store.file)), { warn }) });
40
124
  if (pathname === `${base}/api/onboard`) return reply(200, { ok: true, data: {
41
125
  empty: store.read().length === 0, prompt: ONBOARD_PROMPT, steps: ONBOARD_STEPS, dataDir: path.dirname(store.file),
42
126
  } });
43
- const { nodes, ...data } = computeMapData(store);
127
+ const { nodes, ...data } = computeMapData(store, { warn });
44
128
  const html = renderHtml({ ...data, stars: nodes }, '我的记忆神经网络');
45
129
  return reply(200, html.replace('</body>', escapeBridge + '</body>'), true);
46
130
  } catch (error) {
131
+ if ([400, 404, 413].includes(error.status)) return fail(error.status, error.message);
47
132
  warn(`图谱请求失败:${error.message}`);
48
133
  return fail(500, '读取记忆失败,请检查本地插件日志');
49
134
  }
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.7.0-beta.2b",
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",