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