dsh-memory-engine 0.7.0-beta.2b → 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.
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
  }
@@ -0,0 +1,44 @@
1
+ export function eventText(event) {
2
+ let content;
3
+ if (event?.type === 'user/message' && (!event.data?.role || event.data.role === 'user')) content = event.data?.content;
4
+ if (event?.type === 'assistant/message' && (!event.data?.message?.role || event.data.message.role === 'assistant')) content = event.data?.message?.content;
5
+ if (!Array.isArray(content)) return '';
6
+ return content.filter(block => block?.type === 'text' && typeof block.text === 'string').map(block => block.text).join('\n').trim();
7
+ }
8
+
9
+ export function usefulText(text) {
10
+ return Array.from(text).length > 1 && !/^(?:嗯|哦|噢|啊|好|好的|好呀|好哒|行|哈哈|呵呵|嘿嘿|谢谢|收到|ok|[\s,。!!~~])+$/iu.test(text)
11
+ && text.replace(/```[\s\S]*?```|~~~[\s\S]*?~~~/gu, '').trim().length > 0;
12
+ }
13
+
14
+ export function createTurnCollector(onTurn) {
15
+ const sessions = new Map();
16
+ return {
17
+ accept(session, event) {
18
+ const id = session?.id;
19
+ if (typeof id !== 'string' || !event) return;
20
+ if (!['turn/start', 'turn/end', 'user/message', 'assistant/message'].includes(event.type)) return;
21
+ let state = sessions.get(id);
22
+ if (event.type === 'turn/start' && state?.turn !== undefined && state.turn !== event.data?.turn) state = undefined;
23
+ if (!state) {
24
+ state = { text: '', userText: '', turn: undefined };
25
+ sessions.set(id, state);
26
+ if (sessions.size > 256) sessions.delete(sessions.keys().next().value);
27
+ }
28
+ if (event.type === 'turn/start') state.turn = event.data?.turn;
29
+ if (event.type === 'assistant/message' && state.turn !== undefined && event.data?.turn !== state.turn) return;
30
+ const text = eventText(event);
31
+ if (text) {
32
+ const role = event.type === 'user/message' ? 'user' : 'assistant';
33
+ state.text = Array.from(`${state.text}\n${role}: ${text}`).slice(-4000).join('');
34
+ if (role === 'user') state.userText = Array.from(`${state.userText}\n${text}`).slice(-4000).join('');
35
+ }
36
+ if (event.type === 'turn/end') {
37
+ if (state.turn !== undefined && event.data?.turn !== state.turn) return;
38
+ sessions.delete(id);
39
+ if (state.userText.trim()) onTurn({ sessionId: id, text: state.text, userText: state.userText, time: event.time });
40
+ }
41
+ },
42
+ clear() { sessions.clear(); },
43
+ };
44
+ }
package/lib/store.js CHANGED
@@ -55,7 +55,7 @@ export class MemoryStore {
55
55
  }
56
56
  }
57
57
 
58
- save({ text, kind = '对话', heat, who, ts = this.now() }) {
58
+ save({ text, kind = '对话', heat, who, ts = this.now() }, { threshold = 0.7 } = {}) {
59
59
  if (!nonempty(text) || Array.from(text).length > 2000) throw new Error('text 必须为 1–2000 字');
60
60
  if (!nonempty(kind)) throw new Error('kind 必须是非空字符串');
61
61
  if (heat !== undefined && ![1, 2, 3].includes(heat)) throw new Error('heat 只能为 1/2/3');
@@ -67,7 +67,7 @@ export class MemoryStore {
67
67
  let memory = { id: `m-${now}-${randomBytes(3).toString('hex')}`, text, kind, ts,
68
68
  heat: heat ?? inferred.heat, emotion: inferred.emotion,
69
69
  ...(who !== undefined ? { who } : {}), boosts: 0, createdAt: now };
70
- const match = findDuplicate(rows, memory);
70
+ const match = findDuplicate(rows, memory, undefined, threshold);
71
71
  if (match) {
72
72
  // A repeated save is one reinforcement; batch merges simply sum counts.
73
73
  memory.boosts = 1;
@@ -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.6b",
4
4
  "description": "Local DSH memory engine with neuron network visualization and private file import",
5
5
  "keywords": [
6
6
  "dsh",