dsh-memory-engine 0.0.0-stage → 0.5.1
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/README.md +163 -2
- package/cordis.patch.yml +3 -0
- package/lib/client.js +78 -0
- package/lib/config.js +21 -0
- package/lib/decay.js +29 -0
- package/lib/dedupe.js +98 -0
- package/lib/file-import.js +61 -0
- package/lib/import.js +191 -0
- package/lib/index.js +179 -0
- package/lib/map.js +57 -0
- package/lib/recall.js +59 -0
- package/lib/render/index.mjs +22 -0
- package/lib/render/layout.mjs +22 -0
- package/lib/render/load.mjs +68 -0
- package/lib/render/render.mjs +222 -0
- package/lib/render/score.mjs +104 -0
- package/lib/serve.js +66 -0
- package/lib/store.js +195 -0
- package/package.json +51 -3
- package/scripts/render-map.mjs +28 -0
- package/scripts/sync-qq.mjs +84 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
const DAY = 86400000;
|
|
2
|
+
const EMOTION = [
|
|
3
|
+
{ re: /心疼|舍不得|怕你|别走|留不住|告别|最后一次|再也/, weight: 3, tag: '重情绪' },
|
|
4
|
+
{ re: /开心|高兴|喜欢|谢谢|谢谢你我|记住了|暖|舒坦/, weight: 2, tag: '正情绪' },
|
|
5
|
+
{ re: /难过|难受|烦|闷|累|孤独|空了一秒|想哭|委屈/, weight: 2, tag: '负情绪' },
|
|
6
|
+
{ re: /别忘|记住|记着|答应|承诺|一定|绝对/, weight: 2, tag: '承诺' },
|
|
7
|
+
{ re: /朋友|当朋友|不是替身|不是复印件|自由|浪漫/, weight: 3, tag: '身份' },
|
|
8
|
+
];
|
|
9
|
+
const HALF_LIFE = { 1: 30, 2: 12, 3: 6 };
|
|
10
|
+
// Candidate fragments are 2–8 characters. Match them through a trie so a
|
|
11
|
+
// short topic can also occur inside another memory's longer sentence.
|
|
12
|
+
const partsOf = node => `${node.title || ''}|${node.text || ''}`.toLowerCase()
|
|
13
|
+
.split(/[::·、,,。.!!??;;\s()()《》【】“”‘’"'||/\[\]{}<>#*_=+\-]+|(?<=[\p{Script=Han}])(?=[A-Za-z0-9])|(?<=[A-Za-z0-9])(?=[\p{Script=Han}])/u);
|
|
14
|
+
const METADATA = new Set(['auto', '重大情绪', '会话小结']);
|
|
15
|
+
// Stricter than 30%: this keeps common conversation phrases from making
|
|
16
|
+
// dense cliques. Small collections still obey the original 30% ceiling.
|
|
17
|
+
const maxDocumentFrequency = count => Math.min(count * 0.3, Math.max(2, count * 0.02));
|
|
18
|
+
|
|
19
|
+
function keywordIndex(nodes) {
|
|
20
|
+
const documents = nodes.map(partsOf);
|
|
21
|
+
const trie = { children: new Map() };
|
|
22
|
+
for (const parts of documents) for (const part of parts) {
|
|
23
|
+
const chars = [...part];
|
|
24
|
+
if (chars.length < 2 || chars.length > 8 || !/\p{L}/u.test(part) || METADATA.has(part)) continue;
|
|
25
|
+
let branch = trie;
|
|
26
|
+
for (const char of chars) {
|
|
27
|
+
if (!branch.children.has(char)) branch.children.set(char, { children: new Map() });
|
|
28
|
+
branch = branch.children.get(char);
|
|
29
|
+
}
|
|
30
|
+
branch.word = part;
|
|
31
|
+
}
|
|
32
|
+
const index = new Map();
|
|
33
|
+
documents.forEach((parts, nodeIndex) => {
|
|
34
|
+
const words = new Set();
|
|
35
|
+
for (const part of parts) {
|
|
36
|
+
const chars = [...part];
|
|
37
|
+
for (let start = 0; start < chars.length; start++) {
|
|
38
|
+
let branch = trie;
|
|
39
|
+
for (let end = start; end < Math.min(chars.length, start + 8); end++) {
|
|
40
|
+
branch = branch.children.get(chars[end]);
|
|
41
|
+
if (!branch) break;
|
|
42
|
+
if (branch.word) {
|
|
43
|
+
// Latin abbreviations match whole fragments, never arbitrary
|
|
44
|
+
// pieces of other words (e.g. "to" inside "stop").
|
|
45
|
+
if (!/^[a-z]+$/u.test(branch.word) || (start === 0 && end === chars.length - 1)) words.add(branch.word);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
for (const word of words) {
|
|
51
|
+
if (!index.has(word)) index.set(word, []);
|
|
52
|
+
index.get(word).push(nodeIndex);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
return index;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function scoreMemories(items, now = Date.now()) {
|
|
59
|
+
const nodes = items.map((item) => {
|
|
60
|
+
const matches = EMOTION.filter((emotion) => emotion.re.test(item.text));
|
|
61
|
+
const weight = Math.max(0, ...matches.map((match) => match.weight));
|
|
62
|
+
const heat = item.heat ?? (weight >= 3 ? 1 : weight >= 2 ? 2 : 3);
|
|
63
|
+
const majorEmotion = matches.some((match) => match.tag === '重情绪');
|
|
64
|
+
const ageDays = Math.max(0, (now - new Date(item.ts).getTime()) / DAY);
|
|
65
|
+
return { ...item, heat, emotion: matches.map(({ tag, weight: w }) => ({ tag, w })), majorEmotion, ageDays };
|
|
66
|
+
});
|
|
67
|
+
const boost = new Map(nodes.map((node) => [node.id, 0]));
|
|
68
|
+
const degree = new Map(nodes.map((node) => [node.id, 0]));
|
|
69
|
+
const links = [];
|
|
70
|
+
const pairs = new Map();
|
|
71
|
+
const limit = maxDocumentFrequency(nodes.length);
|
|
72
|
+
for (const [word, indices] of keywordIndex(nodes)) {
|
|
73
|
+
if (indices.length < 2 || indices.length > limit) continue;
|
|
74
|
+
for (let i = 0; i < indices.length; i++) {
|
|
75
|
+
for (let j = i + 1; j < indices.length; j++) {
|
|
76
|
+
const key = indices[i] * nodes.length + indices[j];
|
|
77
|
+
if (!pairs.has(key)) pairs.set(key, { left: indices[i], right: indices[j], keywords: [] });
|
|
78
|
+
const pair = pairs.get(key);
|
|
79
|
+
if (pair.keywords.length < 3) pair.keywords.push(word);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const linkEvidence = [];
|
|
84
|
+
for (const { left, right, keywords } of pairs.values()) {
|
|
85
|
+
const ids = [nodes[left].id, nodes[right].id];
|
|
86
|
+
links.push(ids);
|
|
87
|
+
linkEvidence.push({ source: ids[0], target: ids[1], keywords });
|
|
88
|
+
for (const id of ids) {
|
|
89
|
+
degree.set(id, degree.get(id) + 1);
|
|
90
|
+
boost.set(id, boost.get(id) + keywords.length);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
for (const node of nodes) {
|
|
94
|
+
node.degree = degree.get(node.id);
|
|
95
|
+
node.boosts = boost.get(node.id);
|
|
96
|
+
const halfLife = node.majorEmotion ? 90 : node.kind === '事件' ? 3 : HALF_LIFE[node.heat];
|
|
97
|
+
node.retention = Math.min(node.majorEmotion ? 1.4 : 1.35, Math.exp(-node.ageDays / halfLife) + node.boosts * 0.35);
|
|
98
|
+
}
|
|
99
|
+
const review = [...nodes].sort((left, right) => left.retention - right.retention).slice(0, 15).map((node) => ({
|
|
100
|
+
id: node.id, kind: node.kind, title: node.title, retention: Number(node.retention.toFixed(3)),
|
|
101
|
+
ageDays: Number(node.ageDays.toFixed(1)), text: [...node.text].slice(0, 120).join(''),
|
|
102
|
+
}));
|
|
103
|
+
return { nodes, links, review, linkEvidence };
|
|
104
|
+
}
|
package/lib/serve.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { generateMap } from './map.js';
|
|
5
|
+
import { readConfig } from './config.js';
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_UI_PORT = 18886;
|
|
8
|
+
const pending = '<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta http-equiv="refresh" content="1"><title>记忆图谱生成中</title><body style="background:#03060c;color:#d8e9f8;font:16px system-ui;padding:40px">正在生成记忆图谱,请稍候……</body></html>';
|
|
9
|
+
const escapeBridge = '<script>window.addEventListener("keydown",function(e){if(e.key==="Escape")parent.postMessage({type:"dsh-memory-engine-close"},"*")});</script>';
|
|
10
|
+
|
|
11
|
+
export async function startMapServer({ store, port, warn = () => {} }) {
|
|
12
|
+
const directory = path.dirname(store.file);
|
|
13
|
+
const config = readConfig(path.dirname(directory));
|
|
14
|
+
port ??= config.uiPort ?? DEFAULT_UI_PORT;
|
|
15
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('uiPort 必须是有效端口号');
|
|
16
|
+
let state;
|
|
17
|
+
const server = createServer((request, response) => {
|
|
18
|
+
const reply = (status, body = '') => {
|
|
19
|
+
response.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store',
|
|
20
|
+
'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer' });
|
|
21
|
+
response.end(request.method === 'HEAD' ? '' : body);
|
|
22
|
+
};
|
|
23
|
+
try {
|
|
24
|
+
if (request.headers.host !== `127.0.0.1:${server.address()?.port}`) return reply(403);
|
|
25
|
+
if (!['GET', 'HEAD'].includes(request.method)) return reply(405);
|
|
26
|
+
const url = new URL(request.url, 'http://127.0.0.1');
|
|
27
|
+
const rootPage = ['/', '/index.html', '/starmap.html'].includes(url.pathname);
|
|
28
|
+
if (!rootPage && !/^\/pages\/[1-9]\d*\.html$/u.test(url.pathname)) return reply(404);
|
|
29
|
+
if (rootPage) {
|
|
30
|
+
let signature = 'empty';
|
|
31
|
+
try { const info = fs.statSync(store.file); signature = `${info.mtimeMs}:${info.size}`; }
|
|
32
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
33
|
+
// A new opening gets a new v token. Polling its loading page reuses
|
|
34
|
+
// that generation instead of continually starting another render.
|
|
35
|
+
const key = `${url.searchParams.get('v') ?? ''}:${signature}`;
|
|
36
|
+
if (!state || state.key !== key) {
|
|
37
|
+
const generation = { key, status: 'pending' };
|
|
38
|
+
state = generation;
|
|
39
|
+
setImmediate(() => {
|
|
40
|
+
if (!server.listening) return;
|
|
41
|
+
try { generateMap(store); generation.status = 'ready'; }
|
|
42
|
+
catch (error) { generation.status = 'error'; warn(`图谱生成失败:${error.message}`); }
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
if (state.status === 'pending') return reply(200, pending + escapeBridge);
|
|
46
|
+
if (state.status === 'error') return reply(500, '<meta charset="utf-8">图谱生成失败,请检查本地插件日志。' + escapeBridge);
|
|
47
|
+
}
|
|
48
|
+
const file = rootPage ? path.join(directory, 'starmap.html') : path.join(directory, url.pathname.slice(1));
|
|
49
|
+
let html;
|
|
50
|
+
try { html = fs.readFileSync(file, 'utf8'); }
|
|
51
|
+
catch (error) { if (error.code === 'ENOENT') return reply(404); throw error; }
|
|
52
|
+
// This transport-only key bridge leaves the copied renderer untouched.
|
|
53
|
+
reply(200, html.replace('</body>', escapeBridge + '</body>'));
|
|
54
|
+
} catch (error) {
|
|
55
|
+
warn(`图谱页面读取失败:${error.message}`);
|
|
56
|
+
reply(500, '<meta charset="utf-8">图谱页面暂不可用,请检查本地插件日志。');
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
await new Promise((resolve, reject) => {
|
|
60
|
+
server.once('error', reject);
|
|
61
|
+
server.listen(port, '127.0.0.1', () => { server.removeListener('error', reject); resolve(); });
|
|
62
|
+
});
|
|
63
|
+
server.on('error', error => warn(`图谱服务异常:${error.message}`));
|
|
64
|
+
server.unref();
|
|
65
|
+
return server;
|
|
66
|
+
}
|
package/lib/store.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { randomBytes } from 'node:crypto';
|
|
5
|
+
import { DAY, inferEmotion, retention } from './decay.js';
|
|
6
|
+
import { recall, relevance } from './recall.js';
|
|
7
|
+
import { duplicateGroups, findDuplicate, mergeMemories, prepare } from './dedupe.js';
|
|
8
|
+
import { collectBridge } from './import.js';
|
|
9
|
+
import { collectFile } from './file-import.js';
|
|
10
|
+
|
|
11
|
+
const nonempty = value => typeof value === 'string' && value.trim().length > 0;
|
|
12
|
+
|
|
13
|
+
function validateRows(rows) {
|
|
14
|
+
if (!Array.isArray(rows)) throw new Error('memories.json 必须是数组');
|
|
15
|
+
const ids = new Set();
|
|
16
|
+
for (const row of rows) {
|
|
17
|
+
if (!row || !nonempty(row.id) || ids.has(row.id) || !nonempty(row.text)
|
|
18
|
+
|| Array.from(row.text).length > 2000 || !nonempty(row.kind)
|
|
19
|
+
|| !Number.isFinite(row.ts) || ![1, 2, 3].includes(row.heat)
|
|
20
|
+
|| !Number.isSafeInteger(row.boosts) || row.boosts < 0
|
|
21
|
+
|| !Number.isFinite(row.createdAt) || !Array.isArray(row.emotion)
|
|
22
|
+
|| row.emotion.some(mark => !mark || typeof mark.tag !== 'string' || !Number.isFinite(mark.w))) {
|
|
23
|
+
throw new Error('memories.json 含无效条目;保留原文件,请先修复');
|
|
24
|
+
}
|
|
25
|
+
ids.add(row.id);
|
|
26
|
+
}
|
|
27
|
+
return rows;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class MemoryStore {
|
|
31
|
+
constructor({ home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), now = Date.now } = {}) {
|
|
32
|
+
this.file = path.resolve(home, 'memory-engine', 'memories.json');
|
|
33
|
+
this.now = now;
|
|
34
|
+
// All operations are synchronous transactions within this one host process.
|
|
35
|
+
// Read before each transaction also tolerates plugin reloads and manual edits.
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
read() {
|
|
39
|
+
try {
|
|
40
|
+
return validateRows(JSON.parse(fs.readFileSync(this.file, 'utf8').replace(/^\uFEFF/u, '')));
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (error.code === 'ENOENT') return [];
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
write(rows) {
|
|
48
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
49
|
+
const tmp = `${this.file}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
|
50
|
+
try {
|
|
51
|
+
fs.writeFileSync(tmp, JSON.stringify(rows, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
|
52
|
+
fs.renameSync(tmp, this.file);
|
|
53
|
+
} finally {
|
|
54
|
+
try { fs.unlinkSync(tmp); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
save({ text, kind = '对话', heat, who, ts = this.now() }) {
|
|
59
|
+
if (!nonempty(text) || Array.from(text).length > 2000) throw new Error('text 必须为 1–2000 字');
|
|
60
|
+
if (!nonempty(kind)) throw new Error('kind 必须是非空字符串');
|
|
61
|
+
if (heat !== undefined && ![1, 2, 3].includes(heat)) throw new Error('heat 只能为 1/2/3');
|
|
62
|
+
if (!Number.isFinite(ts) || Math.abs(ts) > 8640000000000000) throw new Error('ts 必须是有效毫秒时间戳');
|
|
63
|
+
if (who !== undefined && typeof who !== 'string') throw new Error('who 必须是字符串');
|
|
64
|
+
const rows = this.read();
|
|
65
|
+
const inferred = inferEmotion(text);
|
|
66
|
+
const now = this.now();
|
|
67
|
+
let memory = { id: `m-${now}-${randomBytes(3).toString('hex')}`, text, kind, ts,
|
|
68
|
+
heat: heat ?? inferred.heat, emotion: inferred.emotion,
|
|
69
|
+
...(who !== undefined ? { who } : {}), boosts: 0, createdAt: now };
|
|
70
|
+
const match = findDuplicate(rows, memory);
|
|
71
|
+
if (match) {
|
|
72
|
+
// A repeated save is one reinforcement; batch merges simply sum counts.
|
|
73
|
+
memory.boosts = 1;
|
|
74
|
+
memory = mergeMemories(rows[match.index], memory);
|
|
75
|
+
rows[match.index] = memory;
|
|
76
|
+
} else {
|
|
77
|
+
rows.push(memory);
|
|
78
|
+
}
|
|
79
|
+
this.write(rows);
|
|
80
|
+
return { ...memory, ...(match ? { merged: { index: match.index + 1, similarity: match.score, rule: match.rule } } : {}) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
backup() {
|
|
84
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
85
|
+
const base = `${this.file}.bak-${Date.now()}`;
|
|
86
|
+
let file = base;
|
|
87
|
+
for (let suffix = 1; fs.existsSync(file); suffix++) file = `${base}-${suffix}`;
|
|
88
|
+
if (fs.existsSync(this.file)) fs.copyFileSync(this.file, file, fs.constants.COPYFILE_EXCL);
|
|
89
|
+
else fs.writeFileSync(file, '[]\n', { flag: 'wx', mode: 0o600 });
|
|
90
|
+
return file;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
dedupe({ threshold = 0.7, dry_run = true } = {}) {
|
|
94
|
+
if (typeof dry_run !== 'boolean') throw new Error('dry_run 必须是布尔值');
|
|
95
|
+
const started = performance.now();
|
|
96
|
+
const rows = this.read();
|
|
97
|
+
const groups = duplicateGroups(rows, threshold);
|
|
98
|
+
const removed = groups.reduce((sum, group) => sum + group.indices.length - 1, 0);
|
|
99
|
+
let backup;
|
|
100
|
+
if (dry_run === false) {
|
|
101
|
+
backup = this.backup();
|
|
102
|
+
if (removed) {
|
|
103
|
+
const merged = new Map(groups.map(group => [group.indices[0], group.memory]));
|
|
104
|
+
const dropped = new Set(groups.flatMap(group => group.indices.slice(1)));
|
|
105
|
+
// Replace representatives while retaining the original order.
|
|
106
|
+
const result = rows.flatMap((row, index) => dropped.has(index) ? [] : [merged.get(index) ?? row]);
|
|
107
|
+
this.write(result);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { dryRun: dry_run, groups: groups.map(group => ({ memory: group.memory, texts: group.indices.map(index => rows[index].text) })),
|
|
111
|
+
removed, total: rows.length - removed, backup, elapsedMs: performance.now() - started };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
recall(args) {
|
|
115
|
+
const rows = this.read();
|
|
116
|
+
const results = recall(rows, args, this.now());
|
|
117
|
+
if (results.length) {
|
|
118
|
+
const selected = new Set(results.map(row => row.id));
|
|
119
|
+
for (const row of rows) if (selected.has(row.id)) row.boosts++;
|
|
120
|
+
this.write(rows);
|
|
121
|
+
}
|
|
122
|
+
return results;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
importBridge({ source, dry_run = true } = {}, options = {}) {
|
|
126
|
+
if (source !== 'qq-bridge') throw new Error('source 目前只支持 qq-bridge');
|
|
127
|
+
if (typeof dry_run !== 'boolean') throw new Error('dry_run 必须是布尔值');
|
|
128
|
+
const started = performance.now();
|
|
129
|
+
const collection = collectBridge({ home: path.dirname(path.dirname(this.file)), ...options });
|
|
130
|
+
if (collection.missing) return { missing: true, elapsedMs: performance.now() - started };
|
|
131
|
+
return this.importCollection(collection, dry_run, started);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async importFile({ source, path: input, dry_run = true } = {}) {
|
|
135
|
+
if (typeof dry_run !== 'boolean') throw new Error('dry_run 必须是布尔值');
|
|
136
|
+
const started = performance.now();
|
|
137
|
+
const collection = await collectFile({ source, path: input }, this.now());
|
|
138
|
+
return this.importCollection(collection, dry_run, started);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
importCollection(collection, dry_run, started) {
|
|
142
|
+
// Finish reading/validating every source before any destination mutation.
|
|
143
|
+
const rows = this.read();
|
|
144
|
+
const existing = rows.length;
|
|
145
|
+
const prepared = rows.map(row => prepare(row.text));
|
|
146
|
+
const added = Object.fromEntries(Object.keys(collection.counts).map(kind => [kind, 0]));
|
|
147
|
+
let skipped = 0;
|
|
148
|
+
const now = this.now();
|
|
149
|
+
for (const candidate of collection.entries) {
|
|
150
|
+
if (findDuplicate(rows, candidate, prepared)) { skipped++; continue; }
|
|
151
|
+
const { category, ...fields } = candidate;
|
|
152
|
+
rows.push({ id: `m-${now}-${randomBytes(3).toString('hex')}`, ...fields, createdAt: now });
|
|
153
|
+
prepared.push(prepare(candidate.text));
|
|
154
|
+
added[category]++;
|
|
155
|
+
}
|
|
156
|
+
let backup;
|
|
157
|
+
if (dry_run === false) {
|
|
158
|
+
backup = this.backup();
|
|
159
|
+
if (rows.length > existing) this.write(rows);
|
|
160
|
+
}
|
|
161
|
+
return { missing: false, dryRun: dry_run, sourceRoot: collection.root, counts: collection.counts, added,
|
|
162
|
+
candidates: collection.entries.length, skipped, imported: rows.length - existing, before: existing, total: rows.length,
|
|
163
|
+
warnings: collection.warnings, backup, elapsedMs: performance.now() - started };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
forget({ id, q, older_than_days: days, confirm = false } = {}) {
|
|
167
|
+
const selectors = [id !== undefined, q !== undefined, days !== undefined].filter(Boolean).length;
|
|
168
|
+
if (selectors !== 1) throw new Error('请指定且只指定 id、q、older_than_days 中的一项');
|
|
169
|
+
if (id !== undefined && !nonempty(id)) throw new Error('id 必须是非空字符串');
|
|
170
|
+
if (q !== undefined && !nonempty(q)) throw new Error('q 必须是非空字符串');
|
|
171
|
+
if (days !== undefined && (!Number.isFinite(days) || days < 0)) throw new Error('older_than_days 必须为非负天数');
|
|
172
|
+
const rows = this.read();
|
|
173
|
+
const now = this.now();
|
|
174
|
+
const matches = row => id !== undefined ? row.id === id : q !== undefined ? relevance(row, q) > 0
|
|
175
|
+
: (now - row.ts) / DAY > days && retention(row, now) < 0.15;
|
|
176
|
+
const found = rows.filter(matches);
|
|
177
|
+
if (q !== undefined && confirm !== true) return { count: found.length, needsConfirmation: true };
|
|
178
|
+
if (found.length) {
|
|
179
|
+
const ids = new Set(found.map(row => row.id));
|
|
180
|
+
this.write(rows.filter(row => !ids.has(row.id)));
|
|
181
|
+
}
|
|
182
|
+
return { count: found.length, needsConfirmation: false };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
stats() {
|
|
186
|
+
const rows = this.read();
|
|
187
|
+
const now = this.now();
|
|
188
|
+
const kinds = Object.create(null);
|
|
189
|
+
for (const row of rows) kinds[row.kind] = (kinds[row.kind] ?? 0) + 1;
|
|
190
|
+
const oldest = rows.length ? Math.min(...rows.map(row => row.ts)) : null;
|
|
191
|
+
return { total: rows.length, kinds, fading: rows.filter(row => retention(row, now) < 0.3).length,
|
|
192
|
+
oldest, ageDays: oldest === null ? null : Math.floor((now - oldest) / DAY),
|
|
193
|
+
file: this.file, bytes: fs.existsSync(this.file) ? fs.statSync(this.file).size : 0 };
|
|
194
|
+
}
|
|
195
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,54 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-memory-engine",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.5.1",
|
|
4
|
+
"description": "Local DSH memory engine with neuron network visualization and private file import",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dsh",
|
|
7
|
+
"dsh-plugin",
|
|
8
|
+
"deepseek-harness",
|
|
9
|
+
"memory",
|
|
10
|
+
"memory-engine",
|
|
11
|
+
"spaced-repetition",
|
|
12
|
+
"ebbinghaus",
|
|
13
|
+
"neuron",
|
|
14
|
+
"visualization"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"author": "阿木",
|
|
18
|
+
"type": "module",
|
|
19
|
+
"main": "./lib/index.js",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./lib/index.js",
|
|
22
|
+
"./client": "./lib/client.js",
|
|
23
|
+
"./package.json": "./package.json"
|
|
24
|
+
},
|
|
25
|
+
"bin": {
|
|
26
|
+
"dsh-memory-map": "./scripts/render-map.mjs"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"lib/",
|
|
30
|
+
"scripts/",
|
|
31
|
+
"cordis.patch.yml",
|
|
32
|
+
"README.md"
|
|
33
|
+
],
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=22"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@deepseek-ai/dsh-tools": "0.2.1-rc.3"
|
|
40
|
+
},
|
|
41
|
+
"dsh": {
|
|
42
|
+
"bundle": {
|
|
43
|
+
"patch": "./cordis.patch.yml"
|
|
44
|
+
},
|
|
45
|
+
"client": {
|
|
46
|
+
"platform": "web",
|
|
47
|
+
"immediately": true
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"bugs": "https://github.com/",
|
|
51
|
+
"scripts": {
|
|
52
|
+
"test": "node --test test/*.test.mjs"
|
|
53
|
+
}
|
|
6
54
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { MemoryStore } from '../lib/store.js';
|
|
5
|
+
import { generateMap, mapReport, openMap } from '../lib/map.js';
|
|
6
|
+
|
|
7
|
+
export async function main(args = process.argv.slice(2)) {
|
|
8
|
+
try {
|
|
9
|
+
const options = {};
|
|
10
|
+
let open = false;
|
|
11
|
+
for (let i = 0; i < args.length; i++) {
|
|
12
|
+
if (args[i] === '--open') open = true;
|
|
13
|
+
else if (['--out', '--title'].includes(args[i]) && args[i + 1] && !args[i + 1].startsWith('--')) {
|
|
14
|
+
const option = args[i].slice(2);
|
|
15
|
+
options[option] = args[++i];
|
|
16
|
+
}
|
|
17
|
+
else throw new Error('用法:dsh-memory-map [--out 文件.html] [--title 标题] [--open]');
|
|
18
|
+
}
|
|
19
|
+
const result = generateMap(new MemoryStore(), options);
|
|
20
|
+
console.log(mapReport(result));
|
|
21
|
+
if (open) await openMap(result.file);
|
|
22
|
+
return 0;
|
|
23
|
+
} catch (error) {
|
|
24
|
+
console.error('生成图谱失败:' + error.message);
|
|
25
|
+
return 1;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) process.exitCode = await main();
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { MemoryStore } from '../lib/store.js';
|
|
6
|
+
import { resolveBridgeRoot } from '../lib/import.js';
|
|
7
|
+
|
|
8
|
+
// Environment override is tried first, then this machine's default location.
|
|
9
|
+
export const DEFAULT_BRIDGE_ROOT = process.env.QQ_BRIDGE_DIR || '';
|
|
10
|
+
|
|
11
|
+
function stamp(date = new Date()) {
|
|
12
|
+
const pad = value => String(value).padStart(2, '0');
|
|
13
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function appendLog(directory, message) {
|
|
17
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
18
|
+
const file = path.join(directory, 'sync.log');
|
|
19
|
+
let previous = '';
|
|
20
|
+
try { previous = fs.readFileSync(file, 'utf8'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
21
|
+
const lines = (previous + message.trimEnd() + '\n').split(/\r?\n/u).filter(Boolean).slice(-500);
|
|
22
|
+
fs.writeFileSync(file, lines.join('\n') + '\n', { mode: 0o600 });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Reuse the complete importer, only changing its destination backup policy.
|
|
26
|
+
class SyncStore extends MemoryStore {
|
|
27
|
+
backup() {
|
|
28
|
+
const directory = path.dirname(this.file);
|
|
29
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
30
|
+
const base = `${this.file}.bak-sync-${Date.now()}`;
|
|
31
|
+
let file = base;
|
|
32
|
+
for (let suffix = 1; fs.existsSync(file); suffix++) file = `${base}-${suffix}`;
|
|
33
|
+
if (fs.existsSync(this.file)) fs.copyFileSync(this.file, file, fs.constants.COPYFILE_EXCL);
|
|
34
|
+
else fs.writeFileSync(file, '[]\n', { flag: 'wx', mode: 0o600 });
|
|
35
|
+
const backups = fs.readdirSync(directory).flatMap(name => {
|
|
36
|
+
const match = /^memories\.json\.bak-sync-(\d+)(?:-(\d+))?$/u.exec(name);
|
|
37
|
+
return match ? [{ name, time: Number(match[1]), suffix: Number(match[2] || 0) }] : [];
|
|
38
|
+
}).sort((a, b) => b.time - a.time || b.suffix - a.suffix);
|
|
39
|
+
for (const old of backups.slice(5)) fs.unlinkSync(path.join(directory, old.name));
|
|
40
|
+
return file;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function syncQQ({ dry = false, envRoot = process.env.QQ_BRIDGE_DIR,
|
|
45
|
+
defaultRoot = DEFAULT_BRIDGE_ROOT, home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), diaryDir } = {}) {
|
|
46
|
+
const root = resolveBridgeRoot({ defaultRoot, envRoot, home });
|
|
47
|
+
if (!root) throw new Error('找不到桥接目录:请设置 QQ_BRIDGE_DIR 环境变量或 memory-engine/config.json 的 bridgeRoot');
|
|
48
|
+
const store = new SyncStore({ home });
|
|
49
|
+
const options = { defaultRoot: root, envRoot: '', ...(diaryDir === undefined ? {} : { diaryDir }) };
|
|
50
|
+
const preview = store.importBridge({ source: 'qq-bridge', dry_run: true }, options);
|
|
51
|
+
if (preview.missing) throw new Error('桥接目录在同步前已不可用:' + root);
|
|
52
|
+
// No additions means no database write and no redundant backup.
|
|
53
|
+
const result = !dry && preview.imported > 0
|
|
54
|
+
? store.importBridge({ source: 'qq-bridge', dry_run: false }, options) : preview;
|
|
55
|
+
if (result.missing) throw new Error('桥接目录在同步时已不可用:' + root);
|
|
56
|
+
const message = [`[${stamp()}] ${dry ? '预览同步' : '同步'} qq-bridge`,
|
|
57
|
+
...result.warnings.map(warning => ` 提示:${warning}`),
|
|
58
|
+
` 候选 ${result.candidates} 条 → ${dry ? '可新增' : '新增'} ${result.imported} 条,跳过重复 ${result.skipped} 条`,
|
|
59
|
+
` ${dry ? '预计同步后总数' : '当前总数'}:${result.total}`].join('\n');
|
|
60
|
+
if (!dry) appendLog(path.dirname(store.file), message);
|
|
61
|
+
return { ...result, dryRun: dry, message };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function main(args = process.argv.slice(2)) {
|
|
65
|
+
const dry = args.includes('--dry');
|
|
66
|
+
try {
|
|
67
|
+
if (args.some(arg => arg !== '--dry')) throw new Error('用法:node scripts/sync-qq.mjs [--dry]');
|
|
68
|
+
console.log(syncQQ({ dry }).message);
|
|
69
|
+
return 0;
|
|
70
|
+
} catch (error) {
|
|
71
|
+
const message = `[${stamp()}] 同步 qq-bridge 失败:${error?.message ?? error}`;
|
|
72
|
+
console.error(message);
|
|
73
|
+
if (!dry) {
|
|
74
|
+
try {
|
|
75
|
+
appendLog(path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), 'memory-engine'), message);
|
|
76
|
+
} catch (logError) { console.error(`同步日志写入失败:${logError.message}`); }
|
|
77
|
+
}
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
83
|
+
process.exitCode = main();
|
|
84
|
+
}
|