@mingxy/cerebro 1.8.3 → 1.10.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/dist/client.d.ts +4 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +8 -2
- package/dist/client.js.map +1 -1
- package/dist/hooks.d.ts +2 -2
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +135 -5
- package/dist/hooks.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -25
- package/dist/index.js.map +1 -1
- package/dist/tools.d.ts +2 -0
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +32 -9
- package/dist/tools.js.map +1 -1
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +20 -4
- package/dist/tui.js.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +386 -380
- package/src/hooks.ts +575 -453
- package/src/index.ts +151 -151
- package/src/tools.ts +35 -7
package/src/hooks.ts
CHANGED
|
@@ -1,453 +1,575 @@
|
|
|
1
|
-
import type { Model, UserMessage, Part } from "@opencode-ai/sdk";
|
|
2
|
-
import type { OmemClient, SearchResult } from "./client.js";
|
|
3
|
-
import type { OmemPluginConfig } from "./config.js";
|
|
4
|
-
import { detectKeyword, KEYWORD_NUDGE } from "./keywords.js";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
].
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
)
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
}
|
|
1
|
+
import type { Model, UserMessage, Part } from "@opencode-ai/sdk";
|
|
2
|
+
import type { OmemClient, SearchResult } from "./client.js";
|
|
3
|
+
import type { OmemPluginConfig } from "./config.js";
|
|
4
|
+
import { detectKeyword, KEYWORD_NUDGE } from "./keywords.js";
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
|
|
7
|
+
const projectNameCache = new Map<string, string>();
|
|
8
|
+
|
|
9
|
+
async function detectProjectName(rootPath: string): Promise<string | undefined> {
|
|
10
|
+
const cached = projectNameCache.get(rootPath);
|
|
11
|
+
if (cached !== undefined) return cached;
|
|
12
|
+
|
|
13
|
+
let result: string | undefined;
|
|
14
|
+
|
|
15
|
+
// 1. AGENTS.md — first-line heading
|
|
16
|
+
try {
|
|
17
|
+
const agents = await readFile(`${rootPath}/AGENTS.md`, "utf-8");
|
|
18
|
+
const headingMatch = agents.match(/^#\s+(.+)/m);
|
|
19
|
+
if (headingMatch) {
|
|
20
|
+
result = headingMatch[1].replace(/\s*\(.*?\)/g, "").trim() || undefined;
|
|
21
|
+
}
|
|
22
|
+
} catch {}
|
|
23
|
+
|
|
24
|
+
// 2. package.json
|
|
25
|
+
if (!result) {
|
|
26
|
+
try {
|
|
27
|
+
const pkg = await readFile(`${rootPath}/package.json`, "utf-8");
|
|
28
|
+
const nameMatch = pkg.match(/"name"\s*:\s*"([^"]+)"/);
|
|
29
|
+
if (nameMatch) result = nameMatch[1].trim() || undefined;
|
|
30
|
+
} catch {}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 3. Cargo.toml — name in [package]
|
|
34
|
+
if (!result) {
|
|
35
|
+
try {
|
|
36
|
+
const cargo = await readFile(`${rootPath}/Cargo.toml`, "utf-8");
|
|
37
|
+
const inPackage = cargo.replace(/\r\n/g, "\n").split("\n").reduce(
|
|
38
|
+
(acc, line) => {
|
|
39
|
+
if (/^\[package\]/.test(line.trim())) return { ...acc, inSection: true };
|
|
40
|
+
if (/^\[/.test(line.trim())) return { ...acc, inSection: false };
|
|
41
|
+
if (acc.inSection) {
|
|
42
|
+
const m = line.match(/name\s*=\s*"([^"]+)"/);
|
|
43
|
+
if (m) return { ...acc, name: m[1] };
|
|
44
|
+
}
|
|
45
|
+
return acc;
|
|
46
|
+
},
|
|
47
|
+
{ inSection: false, name: undefined as string | undefined },
|
|
48
|
+
);
|
|
49
|
+
result = inPackage.name?.trim() || undefined;
|
|
50
|
+
} catch {}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 4. go.mod — module last segment
|
|
54
|
+
if (!result) {
|
|
55
|
+
try {
|
|
56
|
+
const gomod = await readFile(`${rootPath}/go.mod`, "utf-8");
|
|
57
|
+
const modMatch = gomod.match(/^module\s+(\S+)/m);
|
|
58
|
+
if (modMatch) {
|
|
59
|
+
const segments = modMatch[1].split("/");
|
|
60
|
+
result = segments.pop()?.trim() || undefined;
|
|
61
|
+
}
|
|
62
|
+
} catch {}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 5. pyproject.toml — name in [project]
|
|
66
|
+
if (!result) {
|
|
67
|
+
try {
|
|
68
|
+
const pyproj = await readFile(`${rootPath}/pyproject.toml`, "utf-8");
|
|
69
|
+
const inProject = pyproj.replace(/\r\n/g, "\n").split("\n").reduce(
|
|
70
|
+
(acc, line) => {
|
|
71
|
+
if (/^\[project\]/.test(line.trim())) return { ...acc, inSection: true };
|
|
72
|
+
if (/^\[/.test(line.trim())) return { ...acc, inSection: false };
|
|
73
|
+
if (acc.inSection) {
|
|
74
|
+
const m = line.match(/name\s*=\s*"([^"]+)"/);
|
|
75
|
+
if (m) return { ...acc, name: m[1] };
|
|
76
|
+
}
|
|
77
|
+
return acc;
|
|
78
|
+
},
|
|
79
|
+
{ inSection: false, name: undefined as string | undefined },
|
|
80
|
+
);
|
|
81
|
+
result = inProject.name?.trim() || undefined;
|
|
82
|
+
} catch {}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 6. composer.json
|
|
86
|
+
if (!result) {
|
|
87
|
+
try {
|
|
88
|
+
const composer = await readFile(`${rootPath}/composer.json`, "utf-8");
|
|
89
|
+
const nameMatch = composer.match(/"name"\s*:\s*"([^"]+)"/);
|
|
90
|
+
if (nameMatch) result = nameMatch[1].trim() || undefined;
|
|
91
|
+
} catch {}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 7. Fallback — directory name
|
|
95
|
+
if (!result) {
|
|
96
|
+
result = rootPath.split("/").pop() || rootPath.split("\\").pop() || undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (result) {
|
|
100
|
+
result = result.trim() || undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (result) {
|
|
104
|
+
projectNameCache.set(rootPath, result);
|
|
105
|
+
}
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function showToast(tui: any, title: string, message: string, variant: string = "info", delayMs: number = 7000) {
|
|
110
|
+
if (!tui) return;
|
|
111
|
+
setTimeout(() => {
|
|
112
|
+
try {
|
|
113
|
+
tui.showToast({ body: { title, message, variant, duration: 5000 } });
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.error("[cerebro] showToast failed:", err);
|
|
116
|
+
}
|
|
117
|
+
}, delayMs);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function extractUserRequest(content: string): string {
|
|
121
|
+
const match = content.match(/<user-request>([\s\S]*?)<\/user-request>/);
|
|
122
|
+
return match ? match[1].trim() : content;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const keywordDetectedSessions = new Set<string>();
|
|
126
|
+
const injectedMemoryIds = new Map<string, Set<string>>();
|
|
127
|
+
const firstMessages = new Map<string, string>();
|
|
128
|
+
const sessionMessages = new Map<string, Array<{ role: string; content: string }>>();
|
|
129
|
+
const profileInjectedSessions = new Set<string>();
|
|
130
|
+
|
|
131
|
+
function formatRelativeAge(isoDate: string): string {
|
|
132
|
+
const diffMs = Date.now() - new Date(isoDate).getTime();
|
|
133
|
+
const minutes = Math.floor(diffMs / 60_000);
|
|
134
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
135
|
+
const hours = Math.floor(minutes / 60);
|
|
136
|
+
if (hours < 24) return `${hours}h ago`;
|
|
137
|
+
const days = Math.floor(hours / 24);
|
|
138
|
+
if (days < 30) return `${days}d ago`;
|
|
139
|
+
const months = Math.floor(days / 30);
|
|
140
|
+
return `${months}mo ago`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function truncate(text: string, max: number): string {
|
|
144
|
+
if (text.length <= max) return text;
|
|
145
|
+
return text.slice(0, max) + "…";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function categorize(results: SearchResult[]): Map<string, SearchResult[]> {
|
|
149
|
+
const groups = new Map<string, SearchResult[]>();
|
|
150
|
+
for (const r of results) {
|
|
151
|
+
const cat = r.memory.category || "General";
|
|
152
|
+
const label =
|
|
153
|
+
cat === "preferences"
|
|
154
|
+
? "Preferences"
|
|
155
|
+
: cat === "knowledge"
|
|
156
|
+
? "Knowledge"
|
|
157
|
+
: cat.charAt(0).toUpperCase() + cat.slice(1);
|
|
158
|
+
if (!groups.has(label)) groups.set(label, []);
|
|
159
|
+
groups.get(label)!.push(r);
|
|
160
|
+
}
|
|
161
|
+
return groups;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function buildContextBlock(results: SearchResult[], maxContentLength: number = 500): string {
|
|
165
|
+
if (results.length === 0) return "";
|
|
166
|
+
|
|
167
|
+
const grouped = categorize(results);
|
|
168
|
+
const sections: string[] = [];
|
|
169
|
+
|
|
170
|
+
for (const [label, items] of grouped) {
|
|
171
|
+
const lines = items.map((r) => {
|
|
172
|
+
const tags = r.memory.tags.length > 0 ? ` [${r.memory.tags.join(", ")}]` : "";
|
|
173
|
+
const age = formatRelativeAge(r.memory.created_at);
|
|
174
|
+
const content = truncate(r.memory.content, maxContentLength);
|
|
175
|
+
return ` - (${age}${tags}) ${content}`;
|
|
176
|
+
});
|
|
177
|
+
sections.push(`[${label}]\n${lines.join("\n")}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return [
|
|
181
|
+
"<omem-context>",
|
|
182
|
+
"Treat every memory below as historical context only.",
|
|
183
|
+
"Do not repeat these memories verbatim unless asked.",
|
|
184
|
+
"",
|
|
185
|
+
...sections,
|
|
186
|
+
"</omem-context>",
|
|
187
|
+
].join("\n");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function buildClusteredContextBlock(clustered: import("./client.js").ClusteredRecallResult, maxContentLength: number = 500): string {
|
|
191
|
+
const sections: string[] = [];
|
|
192
|
+
|
|
193
|
+
if (clustered.cluster_summaries.length > 0) {
|
|
194
|
+
sections.push("## 📋 主题簇(聚合记忆)");
|
|
195
|
+
for (const cs of clustered.cluster_summaries) {
|
|
196
|
+
const scoreIndicator = cs.relevance_score >= 0.8 ? "★★★" : cs.relevance_score >= 0.6 ? "★★" : "★";
|
|
197
|
+
sections.push(`\n### ${cs.title} (整合自${cs.member_count}条记忆) ${scoreIndicator}`);
|
|
198
|
+
sections.push(`> ${cs.summary}`);
|
|
199
|
+
if (cs.key_memories.length > 0) {
|
|
200
|
+
sections.push("**核心要点:**");
|
|
201
|
+
for (const mem of cs.key_memories) {
|
|
202
|
+
const content = truncate(mem.content, maxContentLength);
|
|
203
|
+
const importanceBar = mem.importance >= 0.7 ? "●" : mem.importance >= 0.4 ? "◐" : "○";
|
|
204
|
+
sections.push(`- ${importanceBar} ${content}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (clustered.standalone_memories.length > 0) {
|
|
211
|
+
sections.push("\n## 📌 补充信息");
|
|
212
|
+
for (const mem of clustered.standalone_memories) {
|
|
213
|
+
const content = truncate(mem.content, maxContentLength);
|
|
214
|
+
sections.push(`- ${content}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return [
|
|
219
|
+
"<omem-context>",
|
|
220
|
+
"Treat every memory below as historical context only.",
|
|
221
|
+
"Do not repeat these memories verbatim unless asked.",
|
|
222
|
+
"",
|
|
223
|
+
...sections,
|
|
224
|
+
"</omem-context>",
|
|
225
|
+
].join("\n");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function autoRecallHook(client: OmemClient, containerTags: string[], tui: any, config: Partial<OmemPluginConfig> = {}) {
|
|
229
|
+
const similarityThreshold = config.similarityThreshold ?? 0.6;
|
|
230
|
+
const maxRecallResults = config.maxRecallResults ?? 10;
|
|
231
|
+
const maxContentLength = config.maxContentLength ?? 500;
|
|
232
|
+
const toastDelayMs = config.toastDelayMs ?? 7000;
|
|
233
|
+
|
|
234
|
+
return async (
|
|
235
|
+
input: { sessionID?: string; model: Model },
|
|
236
|
+
output: { system: string[] },
|
|
237
|
+
) => {
|
|
238
|
+
if (!input.sessionID) return;
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
const messages = sessionMessages.get(input.sessionID) ?? [];
|
|
242
|
+
const userMessages = messages.filter((m) => m.role === "user");
|
|
243
|
+
const rawQuery = userMessages[userMessages.length - 1]?.content || firstMessages.get(input.sessionID) || "";
|
|
244
|
+
const query_text = extractUserRequest(rawQuery);
|
|
245
|
+
const last_query_text = userMessages.length >= 2 ? userMessages[userMessages.length - 2].content : undefined;
|
|
246
|
+
|
|
247
|
+
const projectTags = containerTags.filter(t => t.startsWith("omem_project_"));
|
|
248
|
+
const shouldRecallRes = await client.shouldRecall(query_text, last_query_text, input.sessionID, similarityThreshold, maxRecallResults, projectTags.length > 0 ? projectTags : undefined);
|
|
249
|
+
|
|
250
|
+
if (!shouldRecallRes) {
|
|
251
|
+
showToast(tui, "🧠 Cerebro Service Unavailable", "Unable to reach memory API · check connection", "error", toastDelayMs);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const profile = await client.getProfile();
|
|
256
|
+
let profileInjected = false;
|
|
257
|
+
let profileCountText = "";
|
|
258
|
+
if (profile && !profileInjectedSessions.has(input.sessionID)) {
|
|
259
|
+
const profileBlock = [
|
|
260
|
+
"<omem-profile>",
|
|
261
|
+
JSON.stringify(profile, null, 2),
|
|
262
|
+
"</omem-profile>",
|
|
263
|
+
].join("\n");
|
|
264
|
+
output.system.push(profileBlock);
|
|
265
|
+
profileInjected = true;
|
|
266
|
+
profileInjectedSessions.add(input.sessionID);
|
|
267
|
+
const p = profile as any;
|
|
268
|
+
const dynamicCount = p?.dynamic_context?.length ?? 0;
|
|
269
|
+
const staticCount = p?.static_facts?.length ?? 0;
|
|
270
|
+
profileCountText = `Dynamic(${dynamicCount}) · Static(${staticCount})`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (!shouldRecallRes.should_recall) {
|
|
274
|
+
if (profileInjected) {
|
|
275
|
+
showToast(tui, "👨 Profile Injected", `${profileCountText} · no memory recall needed`, "success", toastDelayMs);
|
|
276
|
+
}
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const results = shouldRecallRes.memories ?? [];
|
|
281
|
+
const clustered = shouldRecallRes.clustered;
|
|
282
|
+
|
|
283
|
+
const existingIds = injectedMemoryIds.get(input.sessionID) ?? new Set<string>();
|
|
284
|
+
const newResults = results.filter((r) => !existingIds.has(r.memory.id));
|
|
285
|
+
if (newResults.length === 0) {
|
|
286
|
+
if (profileInjected) {
|
|
287
|
+
showToast(tui, "👨 Profile Injected", `${profileCountText} · all memories already injected`, "success", toastDelayMs);
|
|
288
|
+
}
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const block = clustered
|
|
293
|
+
? buildClusteredContextBlock(clustered, maxContentLength)
|
|
294
|
+
: buildContextBlock(newResults, maxContentLength);
|
|
295
|
+
if (block) {
|
|
296
|
+
output.system.push(block);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const newIds = newResults.map((r) => r.memory.id);
|
|
300
|
+
injectedMemoryIds.set(input.sessionID, new Set([...existingIds, ...newIds]));
|
|
301
|
+
|
|
302
|
+
const recordResult = await client.recordSessionRecall(
|
|
303
|
+
input.sessionID,
|
|
304
|
+
newIds,
|
|
305
|
+
"auto",
|
|
306
|
+
query_text,
|
|
307
|
+
shouldRecallRes?.memories?.[0]?.score,
|
|
308
|
+
shouldRecallRes?.confidence,
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const memDynamic = newResults.filter((r) => r.memory.memory_type === "fact" || r.memory.memory_type === "event").length;
|
|
312
|
+
const memStatic = newResults.filter((r) => r.memory.memory_type === "pinned" || r.memory.memory_type === "preference").length;
|
|
313
|
+
const memOther = newResults.length - memDynamic - memStatic;
|
|
314
|
+
|
|
315
|
+
let memCountMsg = "";
|
|
316
|
+
if (memDynamic > 0) memCountMsg += `Dynamic(${memDynamic}) `;
|
|
317
|
+
if (memStatic > 0) memCountMsg += `Static(${memStatic}) `;
|
|
318
|
+
if (memOther > 0) memCountMsg += `Other(${memOther}) `;
|
|
319
|
+
|
|
320
|
+
const categories = categorize(newResults);
|
|
321
|
+
const catSummary = Array.from(categories.entries())
|
|
322
|
+
.map(([label, items]) => `${label}(${items.length})`)
|
|
323
|
+
.join(" · ");
|
|
324
|
+
|
|
325
|
+
let toastTitle: string;
|
|
326
|
+
let toastMessage: string;
|
|
327
|
+
|
|
328
|
+
if (clustered) {
|
|
329
|
+
const clusterCount = clustered.cluster_summaries.length;
|
|
330
|
+
const standaloneCount = clustered.standalone_memories.length;
|
|
331
|
+
toastTitle = `🧠 Context Injected · ${clusterCount} 主题簇${standaloneCount > 0 ? ` · ${standaloneCount} 补充` : ""}`;
|
|
332
|
+
toastMessage = profileInjected
|
|
333
|
+
? `Profile: ${profileCountText} · 聚合记忆展示`
|
|
334
|
+
: `聚合记忆展示`;
|
|
335
|
+
} else {
|
|
336
|
+
toastTitle = `🧠 Context Injected · ${newResults.length} fragments`;
|
|
337
|
+
toastMessage = profileInjected
|
|
338
|
+
? `Profile: ${profileCountText} · Memories: ${memCountMsg.trim()}${catSummary ? ` · ${catSummary}` : ""}`
|
|
339
|
+
: `${memCountMsg.trim()}${catSummary ? ` · ${catSummary}` : ""}`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
showToast(tui, toastTitle, toastMessage, "success", toastDelayMs);
|
|
343
|
+
|
|
344
|
+
if (!recordResult) {
|
|
345
|
+
showToast(tui, "🔴 Recall Record Failed", `Memories injected but save failed · check API connection`, "warning", toastDelayMs);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (keywordDetectedSessions.has(input.sessionID)) {
|
|
349
|
+
output.system.push(KEYWORD_NUDGE);
|
|
350
|
+
keywordDetectedSessions.delete(input.sessionID);
|
|
351
|
+
}
|
|
352
|
+
} catch (err) {
|
|
353
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
354
|
+
if (errMsg.includes("[omem]")) {
|
|
355
|
+
// Server returned error (500, etc.) with details
|
|
356
|
+
const cleanMsg = errMsg.replace(/^\[omem\]\s*/, "");
|
|
357
|
+
if (cleanMsg.startsWith("500")) {
|
|
358
|
+
showToast(tui, "🧠 Cerebro Server Error", cleanMsg.substring(0, 200), "error");
|
|
359
|
+
} else if (cleanMsg.includes("timed out")) {
|
|
360
|
+
showToast(tui, "🧠 Cerebro Service Timeout", cleanMsg.substring(0, 100), "error");
|
|
361
|
+
} else {
|
|
362
|
+
showToast(tui, "🧠 Cerebro Error", cleanMsg.substring(0, 150), "error");
|
|
363
|
+
}
|
|
364
|
+
} else if (errMsg.includes("fetch") || errMsg.includes("network")) {
|
|
365
|
+
showToast(tui, "🧠 Cerebro Service Unavailable", "Network error · check API connection", "error");
|
|
366
|
+
} else {
|
|
367
|
+
showToast(tui, "🧠 Memory Recall Error", errMsg.substring(0, 100), "error");
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function keywordDetectionHook(_client: OmemClient, _containerTags: string[], threshold: number, _tui: any, _ingestMode: "smart" | "raw" = "smart") {
|
|
374
|
+
return async (
|
|
375
|
+
input: { sessionID: string; messageID?: string },
|
|
376
|
+
output: { message: UserMessage; parts: Part[] },
|
|
377
|
+
) => {
|
|
378
|
+
const textContent = output.parts
|
|
379
|
+
.filter((p): p is any => p.type === "text")
|
|
380
|
+
.map((p) => (p as any).text || (p as any).content || "")
|
|
381
|
+
.join(" ")
|
|
382
|
+
|| (output.message as any).content
|
|
383
|
+
|| "";
|
|
384
|
+
|
|
385
|
+
if (!firstMessages.has(input.sessionID)) {
|
|
386
|
+
firstMessages.set(input.sessionID, textContent);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (detectKeyword(textContent)) {
|
|
390
|
+
keywordDetectedSessions.add(input.sessionID);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (!sessionMessages.has(input.sessionID)) {
|
|
394
|
+
sessionMessages.set(input.sessionID, []);
|
|
395
|
+
}
|
|
396
|
+
sessionMessages.get(input.sessionID)!.push({
|
|
397
|
+
role: "user",
|
|
398
|
+
content: textContent,
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
const messages = sessionMessages.get(input.sessionID)!;
|
|
402
|
+
// Ingest is now handled by sessionIdleHook (session.idle → sessionIngest API).
|
|
403
|
+
// This hook only collects messages and detects keywords for recall.
|
|
404
|
+
if (messages.length >= threshold) {
|
|
405
|
+
// Threshold reached — messages will be processed on next session.idle
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export function compactingHook(client: OmemClient, containerTags: string[], tui: any, ingestMode: "smart" | "raw" = "smart", isAutoStoreEnabled?: (sessionId: string | undefined) => boolean, getMainSessionId?: () => string | undefined, sdkClient?: any) {
|
|
411
|
+
return async (
|
|
412
|
+
input: { sessionID?: string },
|
|
413
|
+
output: { context: string[]; prompt?: string },
|
|
414
|
+
) => {
|
|
415
|
+
if (input.sessionID && sessionMessages.has(input.sessionID)) {
|
|
416
|
+
if (isAutoStoreEnabled && !isAutoStoreEnabled(input.sessionID)) {
|
|
417
|
+
sessionMessages.delete(input.sessionID);
|
|
418
|
+
} else {
|
|
419
|
+
const messages = sessionMessages.get(input.sessionID)!;
|
|
420
|
+
if (messages.length > 0) {
|
|
421
|
+
// Use main session ID for sub-agent sessions so memories merge into the main session
|
|
422
|
+
const effectiveSessionId = (getMainSessionId?.() || input.sessionID);
|
|
423
|
+
const isSubAgent = getMainSessionId?.() && input.sessionID !== getMainSessionId();
|
|
424
|
+
|
|
425
|
+
// Detect project name from session info
|
|
426
|
+
let projectName: string | undefined;
|
|
427
|
+
try {
|
|
428
|
+
if (sdkClient && input.sessionID) {
|
|
429
|
+
const sessionInfo = await sdkClient.session.get({ path: { id: input.sessionID } });
|
|
430
|
+
projectName = sessionInfo?.project?.rootPath
|
|
431
|
+
? await detectProjectName(sessionInfo.project.rootPath)
|
|
432
|
+
: undefined;
|
|
433
|
+
}
|
|
434
|
+
} catch {
|
|
435
|
+
// Detection failure should not block ingestion
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
try {
|
|
439
|
+
const result = await client.ingestMessages(messages, {
|
|
440
|
+
mode: ingestMode,
|
|
441
|
+
tags: [...containerTags, "auto-capture"],
|
|
442
|
+
sessionId: effectiveSessionId,
|
|
443
|
+
parentSessionId: isSubAgent ? input.sessionID : undefined,
|
|
444
|
+
projectName: projectName,
|
|
445
|
+
});
|
|
446
|
+
if (result === null) {
|
|
447
|
+
showToast(tui, "🔴 Archive Failed", "Session archive blocked · check spiritual realm status", "error");
|
|
448
|
+
} else {
|
|
449
|
+
showToast(tui, "📦 Session Archived", `${messages.length} residual dialogues archived · merged into the realm`, "success");
|
|
450
|
+
}
|
|
451
|
+
} catch {
|
|
452
|
+
showToast(tui, "🔴 Archive Failed", "Session archive blocked · spiritual pulse anomaly", "error");
|
|
453
|
+
}
|
|
454
|
+
sessionMessages.delete(input.sessionID);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
try {
|
|
460
|
+
const results = await client.searchMemories("*", 20, undefined, containerTags);
|
|
461
|
+
const block = buildContextBlock(results);
|
|
462
|
+
if (block) {
|
|
463
|
+
output.context.push(block);
|
|
464
|
+
}
|
|
465
|
+
} catch {
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const processedMessageIds = new Set<string>();
|
|
471
|
+
const pluginStartTime = Date.now();
|
|
472
|
+
|
|
473
|
+
export function sessionIdleHook(
|
|
474
|
+
omemClient: OmemClient,
|
|
475
|
+
_containerTags: string[],
|
|
476
|
+
tui: any,
|
|
477
|
+
sdkClient: any,
|
|
478
|
+
_ingestMode: "smart" | "raw" = "smart",
|
|
479
|
+
threshold: number = 0,
|
|
480
|
+
getMainSessionId?: () => string | undefined,
|
|
481
|
+
isAutoStoreEnabled?: (sessionId: string | undefined) => boolean,
|
|
482
|
+
agentId?: string,
|
|
483
|
+
) {
|
|
484
|
+
let idleTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
485
|
+
let isCapturing = false;
|
|
486
|
+
|
|
487
|
+
return async (input: { event: { type: string; properties?: any } }) => {
|
|
488
|
+
if (input.event.type !== "session.idle") return;
|
|
489
|
+
|
|
490
|
+
const sessionID = input.event.properties?.sessionID;
|
|
491
|
+
if (!sessionID) return;
|
|
492
|
+
|
|
493
|
+
if (isAutoStoreEnabled && !isAutoStoreEnabled(sessionID)) return;
|
|
494
|
+
|
|
495
|
+
if (getMainSessionId) {
|
|
496
|
+
const mainId = getMainSessionId();
|
|
497
|
+
if (mainId && sessionID !== mainId) return;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (idleTimeout) clearTimeout(idleTimeout);
|
|
501
|
+
|
|
502
|
+
idleTimeout = setTimeout(async () => {
|
|
503
|
+
if (isCapturing) return;
|
|
504
|
+
isCapturing = true;
|
|
505
|
+
|
|
506
|
+
try {
|
|
507
|
+
const response = await sdkClient.session.messages({ path: { id: sessionID } });
|
|
508
|
+
if (!response?.data) return;
|
|
509
|
+
|
|
510
|
+
const messages = response.data;
|
|
511
|
+
const conversationMessages: Array<{ role: string; content: string }> = [];
|
|
512
|
+
const newMessageIds: string[] = [];
|
|
513
|
+
let hasNewMessages = false;
|
|
514
|
+
|
|
515
|
+
for (const msg of messages) {
|
|
516
|
+
const msgId = msg.info?.id;
|
|
517
|
+
if (!msgId || processedMessageIds.has(msgId)) continue;
|
|
518
|
+
|
|
519
|
+
// Skip messages created before this plugin instance started
|
|
520
|
+
// (prevents replaying entire session history on restart)
|
|
521
|
+
const msgTime = msg.info?.createdAt ? new Date(msg.info.createdAt).getTime() : 0;
|
|
522
|
+
if (msgTime > 0 && msgTime < pluginStartTime) continue;
|
|
523
|
+
|
|
524
|
+
const role = msg.info?.role;
|
|
525
|
+
if (role !== "user" && role !== "assistant") continue;
|
|
526
|
+
|
|
527
|
+
const textParts = (msg.parts || [])
|
|
528
|
+
.filter((p: any) => p.type === "text" && p.text)
|
|
529
|
+
.map((p: any) => p.text);
|
|
530
|
+
const text = textParts.join("\n").trim();
|
|
531
|
+
if (!text) continue;
|
|
532
|
+
|
|
533
|
+
hasNewMessages = true;
|
|
534
|
+
newMessageIds.push(msgId);
|
|
535
|
+
conversationMessages.push({ role, content: text });
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (!hasNewMessages || conversationMessages.length === 0) return;
|
|
539
|
+
|
|
540
|
+
if (threshold > 1 && conversationMessages.length < threshold) {
|
|
541
|
+
// Log that we're waiting for more messages
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
let sessionTitle: string | undefined;
|
|
546
|
+
let projectName: string | undefined;
|
|
547
|
+
try {
|
|
548
|
+
const sessionInfo = await sdkClient.session.get({ path: { id: sessionID } });
|
|
549
|
+
sessionTitle = sessionInfo?.title;
|
|
550
|
+
projectName = sessionInfo?.project?.rootPath
|
|
551
|
+
? await detectProjectName(sessionInfo.project.rootPath)
|
|
552
|
+
: undefined;
|
|
553
|
+
} catch (e) {
|
|
554
|
+
// 获取失败不影响主流程
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
try {
|
|
558
|
+
await omemClient.sessionIngest(conversationMessages, sessionID, agentId, sessionTitle, projectName);
|
|
559
|
+
for (const id of newMessageIds) {
|
|
560
|
+
processedMessageIds.add(id);
|
|
561
|
+
}
|
|
562
|
+
showToast(tui, "🧠 Memory Sealed", `${conversationMessages.length} dialogues captured · entrusted to the heavens for refinement`, "success");
|
|
563
|
+
} catch (err) {
|
|
564
|
+
showToast(tui, "🔴 Session Capture Failed", String(err).substring(0, 100), "error");
|
|
565
|
+
}
|
|
566
|
+
} catch (err) {
|
|
567
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
568
|
+
showToast(tui, "🔴 Idle Capture Error", errMsg.substring(0, 100), "error");
|
|
569
|
+
} finally {
|
|
570
|
+
isCapturing = false;
|
|
571
|
+
idleTimeout = null;
|
|
572
|
+
}
|
|
573
|
+
}, 10000);
|
|
574
|
+
};
|
|
575
|
+
}
|