dsh-memory-fish 0.0.0-stage → 0.7.0
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/CHANGELOG.md +99 -0
- package/DESIGN.md +226 -0
- package/LICENSE +21 -0
- package/README.en.md +121 -0
- package/README.md +169 -2
- package/SECURITY.md +61 -0
- package/client/client.js +201 -0
- package/cordis.patch.yml +5 -0
- package/icon.svg +43 -0
- package/index.mjs +101 -0
- package/lib/config.mjs +44 -0
- package/lib/core.mjs +104 -0
- package/lib/dream.mjs +162 -0
- package/lib/inject.mjs +83 -0
- package/lib/lock.mjs +41 -0
- package/lib/log.mjs +44 -0
- package/lib/panel.mjs +215 -0
- package/lib/store.mjs +264 -0
- package/lib/tools.mjs +398 -0
- package/lib/util.mjs +156 -0
- package/package.json +77 -4
package/lib/store.mjs
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import fsSync from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { nowIso, stamp, dayOf, localDay, estTokens, initialStability, addDays, isDue, redactSecrets } from './util.mjs';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------- 存储
|
|
7
|
+
|
|
8
|
+
export class Store {
|
|
9
|
+
constructor(dir) {
|
|
10
|
+
this.dir = dir;
|
|
11
|
+
this.memDir = path.join(dir, 'memories');
|
|
12
|
+
this.archDir = path.join(dir, 'archive');
|
|
13
|
+
this.journalDir = path.join(dir, 'journal');
|
|
14
|
+
this.dreamDir = path.join(dir, 'dream');
|
|
15
|
+
this.profileFile = path.join(dir, 'profile.md');
|
|
16
|
+
this.indexFile = path.join(dir, 'index.json');
|
|
17
|
+
this.statsFile = path.join(dir, 'stats.json');
|
|
18
|
+
this.entries = [];
|
|
19
|
+
this.stats = { saved: 0, updated: 0, searches: 0, consolidations: 0, lastConsolidate: null, captured: 0, capturedSinceDream: 0, dreams: 0, lastDream: null };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async init() {
|
|
23
|
+
await fs.mkdir(this.memDir, { recursive: true });
|
|
24
|
+
await fs.mkdir(this.archDir, { recursive: true });
|
|
25
|
+
await fs.mkdir(this.journalDir, { recursive: true });
|
|
26
|
+
await fs.mkdir(this.dreamDir, { recursive: true });
|
|
27
|
+
await this.load();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 追加一条对话记录到当天 journal(L3 原始层,永不注入上下文)。落盘前强制脱敏。 */
|
|
31
|
+
async appendJournal(record) {
|
|
32
|
+
const day = localDay();
|
|
33
|
+
const file = path.join(this.journalDir, `${day}.jsonl`);
|
|
34
|
+
const safe = { ...record, text: redactSecrets(record?.text ?? '') };
|
|
35
|
+
await fs.appendFile(file, JSON.stringify(safe) + '\n', 'utf8').catch(() => {});
|
|
36
|
+
this.stats.capturedSinceDream = (this.stats.capturedSinceDream || 0) + 1;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 读取最近 n 天的 journal 条目。 */
|
|
40
|
+
async readJournal(days = 2) {
|
|
41
|
+
const out = [];
|
|
42
|
+
const now = new Date();
|
|
43
|
+
for (let i = 0; i < days; i++) {
|
|
44
|
+
const d = new Date(now.getTime() - i * 86400000);
|
|
45
|
+
const file = path.join(this.journalDir, `${localDay(d)}.jsonl`);
|
|
46
|
+
let text = '';
|
|
47
|
+
try { text = await fs.readFile(file, 'utf8'); } catch { continue; }
|
|
48
|
+
for (const line of text.split('\n')) {
|
|
49
|
+
if (!line.trim()) continue;
|
|
50
|
+
try { out.push(JSON.parse(line)); } catch { /* 跳过坏行 */ }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
out.sort((a, b) => String(a.ts).localeCompare(String(b.ts)));
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async saveDreamReport(day, content) {
|
|
58
|
+
const file = path.join(this.dreamDir, `${day}.md`);
|
|
59
|
+
await atomicWrite(file, content);
|
|
60
|
+
return file;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async readDreamReport(day = localDay(new Date(Date.now() - 86400000))) {
|
|
64
|
+
const file = path.join(this.dreamDir, `${day}.md`);
|
|
65
|
+
try { return { day, content: await fs.readFile(file, 'utf8'), file }; } catch { return { day, content: '', file }; }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async pruneJournal(keepDays) {
|
|
69
|
+
const cutoff = new Date(Date.now() - keepDays * 86400000);
|
|
70
|
+
let removed = 0;
|
|
71
|
+
let files = [];
|
|
72
|
+
try { files = await fs.readdir(this.journalDir); } catch { return 0; }
|
|
73
|
+
for (const f of files) {
|
|
74
|
+
if (!f.endsWith('.jsonl')) continue;
|
|
75
|
+
const dayStr = f.replace(/\.jsonl$/, '');
|
|
76
|
+
const t = new Date(`${dayStr}T00:00:00`).getTime();
|
|
77
|
+
if (Number.isFinite(t) && t < cutoff.getTime()) {
|
|
78
|
+
await fs.unlink(path.join(this.journalDir, f)).catch(() => {});
|
|
79
|
+
removed++;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return removed;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async load() {
|
|
86
|
+
try {
|
|
87
|
+
const raw = await fs.readFile(this.indexFile, 'utf8');
|
|
88
|
+
const data = JSON.parse(raw);
|
|
89
|
+
this.entries = Array.isArray(data.entries) ? data.entries : [];
|
|
90
|
+
} catch {
|
|
91
|
+
this.entries = await this.scan();
|
|
92
|
+
}
|
|
93
|
+
await this.migrate();
|
|
94
|
+
try {
|
|
95
|
+
this.stats = { ...this.stats, ...JSON.parse(await fs.readFile(this.statsFile, 'utf8')) };
|
|
96
|
+
} catch { /* 首次运行 */ }
|
|
97
|
+
if (!fsSync.existsSync(this.profileFile)) {
|
|
98
|
+
await fs.writeFile(this.profileFile, '# 小肥鱼记忆 · L0 画像\n\n(还没有写入画像)\n', 'utf8');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** v0.4 迁移:给旧条目补上 salience / 复习调度 / 链接字段;v0.5 补时间有效性字段。 */
|
|
103
|
+
async migrate() {
|
|
104
|
+
let changed = false;
|
|
105
|
+
const now = nowIso();
|
|
106
|
+
for (const e of this.entries) {
|
|
107
|
+
if (typeof e.salience !== 'number') { e.salience = 5; changed = true; }
|
|
108
|
+
if (!e.stability) {
|
|
109
|
+
e.stability = initialStability(e.importance, e.salience);
|
|
110
|
+
e.lastReview = e.lastReview || e.updated || now;
|
|
111
|
+
e.nextReview = addDays(e.lastReview, Math.round(e.stability));
|
|
112
|
+
e.reviews = e.reviews || 0;
|
|
113
|
+
changed = true;
|
|
114
|
+
}
|
|
115
|
+
if (!Array.isArray(e.links)) { e.links = []; changed = true; }
|
|
116
|
+
if (!e.nextReview) { e.nextReview = addDays(e.updated || now, Math.round(e.stability)); changed = true; }
|
|
117
|
+
// 时间有效性(P3):旧数据默认"从创建时起一直有效"
|
|
118
|
+
if (!e.validFrom) { e.validFrom = e.created || now; changed = true; }
|
|
119
|
+
if (e.validTo === undefined) { e.validTo = null; changed = true; }
|
|
120
|
+
if (!Array.isArray(e.history)) { e.history = []; changed = true; }
|
|
121
|
+
}
|
|
122
|
+
if (changed) {
|
|
123
|
+
try { await this.saveIndex(); } catch { /* 忽略 */ }
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 索引丢失时,从 memories/*.md 的 frontmatter 重建。 */
|
|
128
|
+
async scan() {
|
|
129
|
+
const entries = [];
|
|
130
|
+
let files = [];
|
|
131
|
+
try { files = await fs.readdir(this.memDir); } catch { return entries; }
|
|
132
|
+
for (const f of files) {
|
|
133
|
+
if (!f.endsWith('.md')) continue;
|
|
134
|
+
try {
|
|
135
|
+
const text = await fs.readFile(path.join(this.memDir, f), 'utf8');
|
|
136
|
+
const e = parseFrontmatter(text, f.replace(/\.md$/, ''));
|
|
137
|
+
if (e) entries.push(e);
|
|
138
|
+
} catch { /* 跳过坏文件 */ }
|
|
139
|
+
}
|
|
140
|
+
return entries;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async saveIndex() {
|
|
144
|
+
const data = { version: 1, updated: nowIso(), entries: this.entries };
|
|
145
|
+
await atomicWrite(this.indexFile, JSON.stringify(data, null, 2) + '\n');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async saveStats() {
|
|
149
|
+
await atomicWrite(this.statsFile, JSON.stringify(this.stats, null, 2) + '\n');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** active 的条目:置顶 → 显著性 → 重要度 → 更新时间。 */
|
|
153
|
+
visible() {
|
|
154
|
+
return this.entries
|
|
155
|
+
.filter(e => e.status === 'active')
|
|
156
|
+
.sort((a, b) => {
|
|
157
|
+
if (!!b.pinned !== !!a.pinned) return b.pinned ? 1 : -1;
|
|
158
|
+
if ((b.salience ?? 5) !== (a.salience ?? 5)) return (b.salience ?? 5) - (a.salience ?? 5);
|
|
159
|
+
if ((b.importance ?? 3) !== (a.importance ?? 3)) return (b.importance ?? 3) - (a.importance ?? 3);
|
|
160
|
+
return String(b.updated).localeCompare(String(a.updated));
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 到期该复习的条目。 */
|
|
165
|
+
due(now = Date.now()) {
|
|
166
|
+
return this.entries.filter(e => e.status === 'active' && isDue(e, now));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
byId(id) {
|
|
170
|
+
return this.entries.find(e => e.id === id);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async writeMemory(entry, content) {
|
|
174
|
+
const fm = [
|
|
175
|
+
'---',
|
|
176
|
+
`id: ${entry.id}`,
|
|
177
|
+
`created: ${entry.created}`,
|
|
178
|
+
`updated: ${entry.updated}`,
|
|
179
|
+
`valid_from: ${entry.validFrom ?? entry.created ?? ''}`,
|
|
180
|
+
`valid_to: ${entry.validTo ?? ''}`,
|
|
181
|
+
`importance: ${entry.importance}`,
|
|
182
|
+
`salience: ${entry.salience ?? 5}`,
|
|
183
|
+
`pinned: ${entry.pinned ? 'true' : 'false'}`,
|
|
184
|
+
`status: ${entry.status}`,
|
|
185
|
+
`stability: ${entry.stability ?? ''}`,
|
|
186
|
+
`last_review: ${entry.lastReview ?? ''}`,
|
|
187
|
+
`next_review: ${entry.nextReview ?? ''}`,
|
|
188
|
+
`reviews: ${entry.reviews ?? 0}`,
|
|
189
|
+
`tags: ${(entry.tags || []).join(', ')}`,
|
|
190
|
+
`supersedes: ${(entry.supersedes || []).join(', ')}`,
|
|
191
|
+
`links: ${(entry.links || []).join(', ')}`,
|
|
192
|
+
'---',
|
|
193
|
+
'',
|
|
194
|
+
`# ${entry.title}`,
|
|
195
|
+
'',
|
|
196
|
+
content.trim(),
|
|
197
|
+
'',
|
|
198
|
+
].join('\n');
|
|
199
|
+
entry.file = path.join(this.memDir, `${entry.id}.md`);
|
|
200
|
+
entry.chars = fm.length;
|
|
201
|
+
entry.tokens = estTokens(fm);
|
|
202
|
+
await atomicWrite(entry.file, fm);
|
|
203
|
+
return fm;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async archiveContent(entry, content) {
|
|
207
|
+
const at = stamp();
|
|
208
|
+
const file = path.join(this.archDir, `${entry.id}.${at}.md`);
|
|
209
|
+
const fm = [
|
|
210
|
+
'---',
|
|
211
|
+
`id: ${entry.id}`,
|
|
212
|
+
`title: ${entry.title}`,
|
|
213
|
+
`archived_at: ${nowIso()}`,
|
|
214
|
+
'---',
|
|
215
|
+
'',
|
|
216
|
+
content,
|
|
217
|
+
'',
|
|
218
|
+
].join('\n');
|
|
219
|
+
await atomicWrite(file, fm);
|
|
220
|
+
return file;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function parseFrontmatter(text, fallbackId) {
|
|
225
|
+
const m = /^---\n([\s\S]*?)\n---\n\n?# ([^\n]*)\n\n?([\s\S]*)$/.exec(text);
|
|
226
|
+
if (!m) return null;
|
|
227
|
+
const meta = {};
|
|
228
|
+
for (const line of m[1].split('\n')) {
|
|
229
|
+
const i = line.indexOf(':');
|
|
230
|
+
if (i < 0) continue;
|
|
231
|
+
meta[line.slice(0, i).trim()] = line.slice(i + 1).trim();
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
id: meta.id || fallbackId,
|
|
235
|
+
title: m[2].trim(),
|
|
236
|
+
content: m[3],
|
|
237
|
+
created: meta.created || nowIso(),
|
|
238
|
+
updated: meta.updated || meta.created || nowIso(),
|
|
239
|
+
validFrom: meta.valid_from || meta.created || nowIso(),
|
|
240
|
+
validTo: meta.valid_to || null,
|
|
241
|
+
history: [],
|
|
242
|
+
importance: Number(meta.importance) || 3,
|
|
243
|
+
salience: Number(meta.salience) || 5,
|
|
244
|
+
pinned: meta.pinned === 'true',
|
|
245
|
+
status: (meta.status || 'active').trim(),
|
|
246
|
+
stability: Number(meta.stability) || null,
|
|
247
|
+
lastReview: meta.last_review || null,
|
|
248
|
+
nextReview: meta.next_review || null,
|
|
249
|
+
reviews: Number(meta.reviews) || 0,
|
|
250
|
+
tags: (meta.tags || '').split(',').map(s => s.trim()).filter(Boolean),
|
|
251
|
+
supersedes: (meta.supersedes || '').split(',').map(s => s.trim()).filter(Boolean),
|
|
252
|
+
links: (meta.links || '').split(',').map(s => s.trim()).filter(Boolean),
|
|
253
|
+
hits: 0,
|
|
254
|
+
lastHit: null,
|
|
255
|
+
chars: text.length,
|
|
256
|
+
tokens: estTokens(text),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export async function atomicWrite(file, data) {
|
|
261
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
262
|
+
await fs.writeFile(tmp, data, 'utf8');
|
|
263
|
+
await fs.rename(tmp, file);
|
|
264
|
+
}
|
package/lib/tools.mjs
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
3
|
+
import { out, nowIso, dayOf, similar, reinforce, SECRET_RE, estTokens } from './util.mjs';
|
|
4
|
+
import { buildInjection } from './inject.mjs';
|
|
5
|
+
import { saveMemoryCore } from './core.mjs';
|
|
6
|
+
import { buildDream } from './dream.mjs';
|
|
7
|
+
import { atomicWrite } from './store.mjs';
|
|
8
|
+
import { withLock } from './lock.mjs';
|
|
9
|
+
import { log } from './log.mjs';
|
|
10
|
+
|
|
11
|
+
/** 需要串行化的写操作:跨进程锁 + 写前重读索引。 */
|
|
12
|
+
const MUTATING = new Set(['memory_save', 'memory_pin', 'memory_forget', 'memory_profile_save', 'memory_consolidate', 'memory_dream']);
|
|
13
|
+
|
|
14
|
+
export function registerTools(ctx, store, cfg, toolImpl) {
|
|
15
|
+
const register = (def) => {
|
|
16
|
+
const original = def.execute;
|
|
17
|
+
if (MUTATING.has(def.name) && typeof original === 'function') {
|
|
18
|
+
def.execute = async (args, exec) => withLock(store.dir, async () => {
|
|
19
|
+
await store.load();
|
|
20
|
+
return original.call(def, args, exec);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
toolImpl[def.name] = def;
|
|
24
|
+
try {
|
|
25
|
+
ctx.tools.register(defineTool(def));
|
|
26
|
+
} catch (error) {
|
|
27
|
+
log.warn('tool register failed', def.name, error);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// ---- memory_save ----
|
|
32
|
+
register({
|
|
33
|
+
name: 'memory_save',
|
|
34
|
+
description: '保存一条跨会话记忆。只存"一个月后仍然成立"的结论:偏好、纠正、决策及其理由、排障闭环、关键数字/ID。不要存过程状态、临时错误、仓库里能直接读到的东西、密钥。同主题会原地更新(自动去重),不会重复堆积。内容写成给未来 agent 的祈使句,2-5 句,自包含。',
|
|
35
|
+
parameters: {
|
|
36
|
+
title: { type: 'string', required: true, description: '短标题(≤40 字),会成为热索引的一行。' },
|
|
37
|
+
content: { type: 'string', required: true, description: '记忆正文,2-5 句自包含的祈使句。' },
|
|
38
|
+
tags: { type: 'array', items: { type: 'string' }, description: '至多 3 个标签。' },
|
|
39
|
+
importance: { type: 'number', description: '重要度 1-5(默认 3;用户明确要求记住=5)。' },
|
|
40
|
+
pin: { type: 'boolean', description: '是否置顶(置顶记忆永远在热索引最前,不参与衰减)。' },
|
|
41
|
+
},
|
|
42
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v, null, 1) }] },
|
|
43
|
+
async execute(args) {
|
|
44
|
+
return await saveMemoryCore(store, cfg, args);
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// ---- memory_search ----
|
|
49
|
+
register({
|
|
50
|
+
name: 'memory_search',
|
|
51
|
+
description: '在本地记忆里做关键词检索(标题/标签/正文,大小写不敏感),返回相关条目摘要。当过去的偏好、决策或上下文可能相关时先用它查证,不要直接重问用户。默认只搜"当前有效"的记忆;asOf="2026-09-01" 可按时间点查历史版本(回答"上个月我们用什么方案");includeArchived=true 会连冷档案一起搜。',
|
|
52
|
+
parameters: {
|
|
53
|
+
query: { type: 'string', required: true, description: '关键词或短语。' },
|
|
54
|
+
limit: { type: 'number', description: '最多返回条数(默认 8,最大 20)。' },
|
|
55
|
+
asOf: { type: 'string', description: '时间点(ISO 日期),查那一刻有效的记忆版本;省略则只看当前有效版本。' },
|
|
56
|
+
includeArchived: { type: 'boolean', description: '是否包含已归档的冷记忆(默认 false)。' },
|
|
57
|
+
},
|
|
58
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v, null, 1) }] },
|
|
59
|
+
async execute(args) {
|
|
60
|
+
const q = String(args.query ?? '').trim();
|
|
61
|
+
if (!q) return out({ ok: false, code: 'invalid_input', message: 'query 不能为空' });
|
|
62
|
+
const limit = Math.min(20, Math.max(1, Math.trunc(Number(args.limit) || 8)));
|
|
63
|
+
if (!store.entries.length) await store.load();
|
|
64
|
+
const lower = q.toLowerCase();
|
|
65
|
+
const asOfMs = args.asOf ? new Date(String(args.asOf)).getTime() : NaN;
|
|
66
|
+
const hasAsOf = Number.isFinite(asOfMs);
|
|
67
|
+
const pool = args.includeArchived === true ? store.entries : store.entries.filter(e => e.status === 'active');
|
|
68
|
+
const nowMs = Date.now();
|
|
69
|
+
|
|
70
|
+
// 每条记忆可能有多"代"内容(history),统一展开成带有效期的版本
|
|
71
|
+
const versions = [];
|
|
72
|
+
for (const e of pool) {
|
|
73
|
+
const current = {
|
|
74
|
+
entry: e, title: e.title, content: e.content ?? '',
|
|
75
|
+
validFrom: e.validFrom || e.created || '',
|
|
76
|
+
validTo: e.validTo || null,
|
|
77
|
+
historical: false,
|
|
78
|
+
};
|
|
79
|
+
const hist = (e.history || []).map(h => ({
|
|
80
|
+
entry: e, title: h.title || e.title, content: h.content ?? '',
|
|
81
|
+
validFrom: h.validFrom || '', validTo: h.validTo || null, historical: true,
|
|
82
|
+
}));
|
|
83
|
+
versions.push(...hist, current);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const inRange = (v) => {
|
|
87
|
+
if (!hasAsOf) return v.historical === false; // 默认只看当前有效版本
|
|
88
|
+
const from = new Date(v.validFrom).getTime();
|
|
89
|
+
const to = v.validTo ? new Date(v.validTo).getTime() : nowMs;
|
|
90
|
+
return Number.isFinite(from) && from <= asOfMs && asOfMs <= to;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const hits = [];
|
|
94
|
+
for (const v of versions) {
|
|
95
|
+
if (!inRange(v)) continue;
|
|
96
|
+
const e = v.entry;
|
|
97
|
+
let score = 0;
|
|
98
|
+
if (String(v.title).toLowerCase().includes(lower)) score += 3;
|
|
99
|
+
if ((e.tags || []).some(t => String(t).toLowerCase().includes(lower))) score += 2;
|
|
100
|
+
let body = v.content;
|
|
101
|
+
if (!body) {
|
|
102
|
+
try { body = (await fs.readFile(e.file, 'utf8')).replace(/^---[\s\S]*?---\n\n?/, ''); } catch { body = ''; }
|
|
103
|
+
}
|
|
104
|
+
if (body.toLowerCase().includes(lower)) score += 1;
|
|
105
|
+
if (score > 0) {
|
|
106
|
+
const idx = body.toLowerCase().indexOf(lower);
|
|
107
|
+
const start = Math.max(0, idx - 40);
|
|
108
|
+
const excerpt = (start > 0 ? '…' : '') + body.slice(start, idx + lower.length + 60).replace(/\s+/g, ' ').trim();
|
|
109
|
+
hits.push(out({
|
|
110
|
+
id: e.id, title: v.title, tags: e.tags || [], scope: 'user', status: e.status,
|
|
111
|
+
importance: e.importance ?? 3, updated: dayOf(v.validFrom), score, excerpt,
|
|
112
|
+
validFrom: dayOf(v.validFrom), validTo: v.validTo ? dayOf(v.validTo) : null,
|
|
113
|
+
historical: v.historical,
|
|
114
|
+
}));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
hits.sort((a, b) => b.score - a.score || String(b.validFrom).localeCompare(String(a.validFrom)));
|
|
118
|
+
const top = hits.slice(0, limit);
|
|
119
|
+
for (const h of top) {
|
|
120
|
+
if (h.historical) continue; // 历史版本不参与复习加固
|
|
121
|
+
const e = store.byId(h.id);
|
|
122
|
+
if (e && cfg.reviewEnabled) reinforce(e, 1.3); // 提取练习:命中即加固
|
|
123
|
+
else if (e) { e.hits = (e.hits || 0) + 1; e.lastHit = nowIso(); }
|
|
124
|
+
}
|
|
125
|
+
if (top.some(h => !h.historical)) { await store.saveIndex(); store.stats.searches += 1; await store.saveStats(); }
|
|
126
|
+
return out({
|
|
127
|
+
query: q, asOf: hasAsOf ? dayOf(new Date(asOfMs).toISOString()) : null,
|
|
128
|
+
total: hits.length, returned: top.length, matches: top,
|
|
129
|
+
});
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ---- memory_read ----
|
|
134
|
+
register({
|
|
135
|
+
name: 'memory_read',
|
|
136
|
+
description: '按 id 读取一条记忆的全文(超过 4000 字符会截断并给出本地路径)。id 来自 memory_search 或 memory_list。读取本身也是一次复习,会轻微加固记忆。',
|
|
137
|
+
parameters: { id: { type: 'string', required: true, description: '记忆 id。' } },
|
|
138
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v, null, 1) }] },
|
|
139
|
+
async execute(args) {
|
|
140
|
+
const safe = String(args.id ?? '').trim();
|
|
141
|
+
const e = store.byId(safe);
|
|
142
|
+
if (!e) return out({ ok: false, code: 'not_found', message: `没有 id 为 ${safe} 的记忆,先用 memory_search 找。` });
|
|
143
|
+
let content = '';
|
|
144
|
+
try { content = await fs.readFile(e.file, 'utf8'); } catch {
|
|
145
|
+
content = `# ${e.title}\n\n${e.content ?? ''}`;
|
|
146
|
+
}
|
|
147
|
+
const body = content;
|
|
148
|
+
const truncated = body.length > 4000;
|
|
149
|
+
if (cfg.reviewEnabled) {
|
|
150
|
+
reinforce(e, 1.1);
|
|
151
|
+
await store.saveIndex();
|
|
152
|
+
}
|
|
153
|
+
return out({
|
|
154
|
+
ok: true, id: e.id, title: e.title, status: e.status, tags: e.tags || [],
|
|
155
|
+
importance: e.importance ?? 3, salience: e.salience ?? 5,
|
|
156
|
+
stability: e.stability, nextReview: e.nextReview,
|
|
157
|
+
validFrom: e.validFrom || e.created, validTo: e.validTo || null,
|
|
158
|
+
historyCount: (e.history || []).length,
|
|
159
|
+
created: e.created, updated: e.updated,
|
|
160
|
+
content: truncated ? body.slice(0, 4000) : body,
|
|
161
|
+
truncated, local_file: e.file, size_bytes: Buffer.byteLength(body, 'utf8'),
|
|
162
|
+
});
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ---- memory_list ----
|
|
167
|
+
register({
|
|
168
|
+
name: 'memory_list',
|
|
169
|
+
description: '列出最近的活跃记忆(置顶优先,其次按更新时间倒序)。用于快速了解记住过什么。',
|
|
170
|
+
parameters: { limit: { type: 'number', description: '最多条数(默认 8,最大 50)。' } },
|
|
171
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v, null, 1) }] },
|
|
172
|
+
async execute(args) {
|
|
173
|
+
if (!store.entries.length) await store.load();
|
|
174
|
+
const limit = Math.min(50, Math.max(1, Math.trunc(Number(args.limit) || 8)));
|
|
175
|
+
const visible = store.visible();
|
|
176
|
+
const entries = visible.slice(0, limit).map(e => out({
|
|
177
|
+
id: e.id, title: e.title, pinned: !!e.pinned, tags: e.tags || [],
|
|
178
|
+
importance: e.importance ?? 3, updated: dayOf(e.updated), hits: e.hits || 0,
|
|
179
|
+
}));
|
|
180
|
+
const archived = store.entries.filter(e => e.status !== 'active').length;
|
|
181
|
+
return out({ ok: true, total: visible.length, archived, returned: entries.length, entries });
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ---- memory_pin ----
|
|
186
|
+
register({
|
|
187
|
+
name: 'memory_pin',
|
|
188
|
+
description: '置顶或取消置顶一条记忆。置顶记忆永远出现在热索引最前,不参与衰减归档。',
|
|
189
|
+
parameters: {
|
|
190
|
+
id: { type: 'string', required: true, description: '记忆 id。' },
|
|
191
|
+
pinned: { type: 'boolean', description: 'true 置顶,false 取消(默认 true)。' },
|
|
192
|
+
},
|
|
193
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v, null, 1) }] },
|
|
194
|
+
async execute(args) {
|
|
195
|
+
const e = store.byId(String(args.id ?? '').trim());
|
|
196
|
+
if (!e) return out({ ok: false, code: 'not_found', message: '没有找到该记忆' });
|
|
197
|
+
e.pinned = args.pinned === false ? false : true;
|
|
198
|
+
e.updated = nowIso();
|
|
199
|
+
await store.saveIndex();
|
|
200
|
+
return out({ ok: true, id: e.id, pinned: e.pinned, title: e.title });
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// ---- memory_forget ----
|
|
205
|
+
register({
|
|
206
|
+
name: 'memory_forget',
|
|
207
|
+
description: '遗忘一条记忆(软删除:状态标记 deleted 并移出索引,文件保留在冷档案,不会被压缩复活)。用户明确要求"忘掉"时使用。',
|
|
208
|
+
parameters: { id: { type: 'string', required: true, description: '记忆 id。' } },
|
|
209
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v, null, 1) }] },
|
|
210
|
+
async execute(args) {
|
|
211
|
+
const e = store.byId(String(args.id ?? '').trim());
|
|
212
|
+
if (!e) return out({ ok: false, code: 'not_found', message: '没有找到该记忆' });
|
|
213
|
+
try {
|
|
214
|
+
const text = await fs.readFile(e.file, 'utf8');
|
|
215
|
+
await store.archiveContent(e, text);
|
|
216
|
+
} catch { /* 文件可能已不在 */ }
|
|
217
|
+
e.status = 'deleted';
|
|
218
|
+
e.validTo = e.validTo || nowIso();
|
|
219
|
+
e.pinned = false;
|
|
220
|
+
e.updated = nowIso();
|
|
221
|
+
try { await fs.unlink(e.file); } catch { /* 已删除 */ }
|
|
222
|
+
await store.saveIndex();
|
|
223
|
+
return out({ ok: true, id: e.id, forgotten: true, title: e.title });
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ---- memory_profile_save ----
|
|
228
|
+
register({
|
|
229
|
+
name: 'memory_profile_save',
|
|
230
|
+
description: '写入 L0 画像(每次会话都常驻注入的一小段文字,预算约 200 tokens)。只放"关于铁凝的事实与硬约束":称呼、身份、偏好、雷点。不是记忆条目,别放项目细节。',
|
|
231
|
+
parameters: {
|
|
232
|
+
content: { type: 'string', required: true, description: '画像正文(Markdown,纯文本也行),会按预算截断。' },
|
|
233
|
+
},
|
|
234
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v, null, 1) }] },
|
|
235
|
+
async execute(args) {
|
|
236
|
+
const raw = String(args.content ?? '').trim();
|
|
237
|
+
if (!raw) return out({ ok: false, code: 'invalid_input', message: 'content 不能为空' });
|
|
238
|
+
if (SECRET_RE.test(raw)) return out({ ok: false, code: 'secret_refused', message: '疑似密钥/凭据,拒绝写入画像。' });
|
|
239
|
+
const body = `# 小肥鱼记忆 · L0 画像\n\n${raw}\n`;
|
|
240
|
+
await atomicWrite(store.profileFile, body);
|
|
241
|
+
return out({ ok: true, saved: true, tokens: estTokens(raw), budget: cfg.profileTokens });
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// ---- memory_dream ----
|
|
246
|
+
register({
|
|
247
|
+
name: 'memory_dream',
|
|
248
|
+
description: '睡梦机制(三阶段):SWS 重放+压缩近似记忆、SHY 显著性加权衰减、REM 建立联想链接;同时输出到期复习清单。默认只报告不自动保存(autoSave=true 时只自动保存用户明确说"记住…"的句子)。做完梦后逐条判断候选、memory_save 值得留下的,再 memory_consolidate。',
|
|
249
|
+
parameters: {
|
|
250
|
+
days: { type: 'number', description: '回顾最近几天(默认 2)。' },
|
|
251
|
+
autoSave: { type: 'boolean', description: '是否自动保存"明确要求记住"的候选(默认 false)。' },
|
|
252
|
+
},
|
|
253
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v, null, 1) }] },
|
|
254
|
+
async execute(args) {
|
|
255
|
+
const days = Math.min(30, Math.max(1, Math.trunc(Number(args.days) || cfg.dreamDays)));
|
|
256
|
+
const dream = await buildDream(store, cfg, days);
|
|
257
|
+
const autoSaved = [];
|
|
258
|
+
if (args.autoSave === true) {
|
|
259
|
+
for (const c of dream.cues) {
|
|
260
|
+
if (!c.explicit) continue;
|
|
261
|
+
const raw = c.text.replace(/^.*?(记住|记一下|记下)[::]?\s*/, '').trim();
|
|
262
|
+
if (!raw) continue;
|
|
263
|
+
const title = raw.slice(0, 30);
|
|
264
|
+
const r = await saveMemoryCore(store, cfg, { title, content: raw, tags: ['睡眠提炼'], importance: 4 });
|
|
265
|
+
autoSaved.push({ title, id: r.id, updated: r.updated === true, noop: r.noop === true, ok: r.ok });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return out({
|
|
269
|
+
ok: true, day: dream.day, days, sessions: dream.sessions, messages: dream.messages,
|
|
270
|
+
phases: {
|
|
271
|
+
sws: dream.phases.sws.mergedCount,
|
|
272
|
+
shy: dream.phases.shy.demotedCount,
|
|
273
|
+
rem: dream.phases.rem.linkCount,
|
|
274
|
+
},
|
|
275
|
+
dueReview: dream.due,
|
|
276
|
+
cues: dream.cues.length,
|
|
277
|
+
candidates: dream.cues.map(c => ({ at: c.at, session: c.session, text: c.text, explicit: c.explicit })),
|
|
278
|
+
autoSaved, reportFile: dream.file, report: dream.report,
|
|
279
|
+
});
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// ---- memory_stats ----
|
|
284
|
+
register({
|
|
285
|
+
name: 'memory_stats',
|
|
286
|
+
description: '查看记忆占用统计:活跃/归档条数、L0+L1 注入 token、磁盘字符量、检索次数。用于确认上下文成本是否恒定。',
|
|
287
|
+
parameters: {},
|
|
288
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v, null, 1) }] },
|
|
289
|
+
async execute() {
|
|
290
|
+
if (!store.entries.length) await store.load();
|
|
291
|
+
const hot = buildInjection(store, cfg);
|
|
292
|
+
const active = store.entries.filter(e => e.status === 'active');
|
|
293
|
+
const archived = store.entries.filter(e => e.status !== 'active');
|
|
294
|
+
const totalChars = store.entries.reduce((n, e) => n + (e.chars || 0), 0);
|
|
295
|
+
const due = store.due();
|
|
296
|
+
const salienceAvg = active.length
|
|
297
|
+
? Number((active.reduce((n, e) => n + (e.salience ?? 5), 0) / active.length).toFixed(1))
|
|
298
|
+
: 0;
|
|
299
|
+
return out({
|
|
300
|
+
ok: true,
|
|
301
|
+
active: active.length, archived: archived.length, pinned: active.filter(e => e.pinned).length,
|
|
302
|
+
dueReview: due.length,
|
|
303
|
+
salienceAvg,
|
|
304
|
+
reviews: active.reduce((n, e) => n + (e.reviews || 0), 0),
|
|
305
|
+
l1Lines: hot.l1Lines, coldRemaining: hot.cold,
|
|
306
|
+
injectedL0Tokens: hot.profileTokens, injectedL1Tokens: hot.l1Tokens,
|
|
307
|
+
budgetTokens: cfg.budgetTokens,
|
|
308
|
+
diskChars: totalChars,
|
|
309
|
+
hits: store.stats.searches, saved: store.stats.saved, updated: store.stats.updated,
|
|
310
|
+
captured: store.stats.captured || 0,
|
|
311
|
+
dreams: store.stats.dreams || 0,
|
|
312
|
+
lastDream: store.stats.lastDream || null,
|
|
313
|
+
lastConsolidate: store.stats.lastConsolidate,
|
|
314
|
+
});
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// ---- memory_consolidate ----
|
|
319
|
+
register({
|
|
320
|
+
name: 'memory_consolidate',
|
|
321
|
+
description: '手动整理记忆:合并同主题近似条目(相似度≥0.75,旧的归档、新的保留并记录 supersede)、衰减归档长期未用的低重要度记忆。纯本地规则,不调用 LLM。返回整理报告。',
|
|
322
|
+
parameters: { dryRun: { type: 'boolean', description: '只报告不修改(默认 false)。' } },
|
|
323
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v, null, 1) }] },
|
|
324
|
+
async execute(args) {
|
|
325
|
+
if (!store.entries.length) await store.load();
|
|
326
|
+
const dry = args.dryRun === true;
|
|
327
|
+
const merged = [];
|
|
328
|
+
const archived = [];
|
|
329
|
+
const now = Date.now();
|
|
330
|
+
|
|
331
|
+
// 1) 合并近似条目
|
|
332
|
+
const active = store.entries.filter(e => e.status === 'active').sort((a, b) => String(b.updated).localeCompare(String(a.updated)));
|
|
333
|
+
const removed = new Set();
|
|
334
|
+
for (let i = 0; i < active.length; i++) {
|
|
335
|
+
const a = active[i];
|
|
336
|
+
if (removed.has(a.id)) continue;
|
|
337
|
+
for (let j = i + 1; j < active.length; j++) {
|
|
338
|
+
const b = active[j];
|
|
339
|
+
if (removed.has(b.id)) continue;
|
|
340
|
+
const sim = similar(`${a.title}\n${a.content ?? ''}`, `${b.title}\n${b.content ?? ''}`);
|
|
341
|
+
if (sim < cfg.dedupeThreshold) continue;
|
|
342
|
+
const keep = String(a.updated).localeCompare(String(b.updated)) >= 0 ? a : b;
|
|
343
|
+
const drop = keep === a ? b : a;
|
|
344
|
+
merged.push({ kept: keep.id, dropped: drop.id, similarity: Number(sim.toFixed(2)) });
|
|
345
|
+
if (!dry) {
|
|
346
|
+
keep.supersedes = [...new Set([...(keep.supersedes || []), drop.id])];
|
|
347
|
+
try {
|
|
348
|
+
const text = await fs.readFile(drop.file, 'utf8');
|
|
349
|
+
await store.archiveContent(drop, text);
|
|
350
|
+
} catch { /* 忽略 */ }
|
|
351
|
+
drop.status = 'archived';
|
|
352
|
+
drop.validTo = drop.validTo || nowIso();
|
|
353
|
+
drop.pinned = false;
|
|
354
|
+
drop.updated = nowIso();
|
|
355
|
+
removed.add(drop.id);
|
|
356
|
+
} else {
|
|
357
|
+
removed.add(drop.id);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// 2) 衰减归档:>archiveAfterDays 天未更新、低重要度、未置顶、从未被检索
|
|
363
|
+
for (const e of store.entries) {
|
|
364
|
+
if (e.status !== 'active' || e.pinned) continue;
|
|
365
|
+
if ((e.importance ?? 3) >= 4 || (e.hits || 0) > 0) continue;
|
|
366
|
+
const ageDays = (now - new Date(e.updated || e.created).getTime()) / 86400000;
|
|
367
|
+
if (ageDays > cfg.archiveAfterDays) {
|
|
368
|
+
if (!dry) {
|
|
369
|
+
try {
|
|
370
|
+
const text = await fs.readFile(e.file, 'utf8');
|
|
371
|
+
await store.archiveContent(e, text);
|
|
372
|
+
} catch { /* 忽略 */ }
|
|
373
|
+
e.status = 'archived';
|
|
374
|
+
e.validTo = e.validTo || nowIso();
|
|
375
|
+
e.updated = nowIso();
|
|
376
|
+
}
|
|
377
|
+
archived.push({ id: e.id, ageDays: Math.round(ageDays) });
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (!dry) {
|
|
382
|
+
await store.saveIndex();
|
|
383
|
+
store.stats.consolidations += 1;
|
|
384
|
+
store.stats.lastConsolidate = nowIso();
|
|
385
|
+
await store.saveStats();
|
|
386
|
+
}
|
|
387
|
+
const hot = buildInjection(store, cfg);
|
|
388
|
+
return out({
|
|
389
|
+
ok: true, dryRun: dry,
|
|
390
|
+
mergedCount: merged.length, archivedCount: archived.length,
|
|
391
|
+
merged, archived,
|
|
392
|
+
activeAfter: store.entries.filter(e => e.status === 'active').length,
|
|
393
|
+
injectedL1Tokens: hot.l1Tokens, budgetTokens: cfg.budgetTokens,
|
|
394
|
+
});
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
}
|