@mingxy/cerebro 2.3.4 โ†’ 2.3.5

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,1019 +1,1040 @@
1
- import type { Model, UserMessage, Part } from "@opencode-ai/sdk";
2
- import type { CerebroClient, SearchResult } from "./client.js";
3
- import { type CerebroPluginConfig, DEFAULTS, resolveAgentPolicy } from "./config.js";
4
- import { logDebug, logInfo, logError as logErr } from "./logger.js";
5
- import { readFile } from "node:fs/promises";
6
- import { execSync } from "node:child_process";
7
-
8
- /** Sanitize session ID to prevent path traversal */
9
- function sanitizeSessionId(id: string | undefined): string | undefined {
10
- if (!id) return id;
11
- // Remove any path separators or traversal attempts
12
- return id.replace(/[/\\]/g, "_").replace(/\.\./g, "");
13
- }
14
-
15
- const BOUNDARY_SEARCH_RATIO = 0.6;
16
-
17
- const projectNameCache = new Map<string, string>();
18
-
19
- async function detectProjectName(rootPath: string): Promise<string | undefined> {
20
- const cached = projectNameCache.get(rootPath);
21
- if (cached !== undefined) {
22
- logDebug("detectProjectName cache hit", { rootPath, result: cached });
23
- return cached;
24
- }
25
-
26
- let result: string | undefined;
27
-
28
- try {
29
- const agents = await readFile(`${rootPath}/AGENTS.md`, "utf-8");
30
- const headingMatch = agents.match(/^#\s+(.+)/m);
31
- if (headingMatch) {
32
- result = headingMatch[1].replace(/\s*\(.*?\)/g, "").trim() || undefined;
33
- }
34
- logDebug("detectProjectName step1 AGENTS.md", { rootPath, result });
35
- } catch {}
36
-
37
- if (!result) {
38
- try {
39
- const pkg = await readFile(`${rootPath}/package.json`, "utf-8");
40
- const nameMatch = pkg.match(/"name"\s*:\s*"([^"]+)"/);
41
- if (nameMatch) result = nameMatch[1].trim() || undefined;
42
- logDebug("detectProjectName step2 package.json", { rootPath, result });
43
- } catch {}
44
- }
45
-
46
- if (!result) {
47
- try {
48
- const cargo = await readFile(`${rootPath}/Cargo.toml`, "utf-8");
49
- const inPackage = cargo.replace(/\r\n/g, "\n").split("\n").reduce(
50
- (acc, line) => {
51
- if (/^\[package\]/.test(line.trim())) return { ...acc, inSection: true };
52
- if (/^\[/.test(line.trim())) return { ...acc, inSection: false };
53
- if (acc.inSection) {
54
- const m = line.match(/name\s*=\s*"([^"]+)"/);
55
- if (m) return { ...acc, name: m[1] };
56
- }
57
- return acc;
58
- },
59
- { inSection: false, name: undefined as string | undefined },
60
- );
61
- result = inPackage.name?.trim() || undefined;
62
- logDebug("detectProjectName step3 Cargo.toml", { rootPath, result });
63
- } catch {}
64
- }
65
-
66
- if (!result) {
67
- try {
68
- const gomod = await readFile(`${rootPath}/go.mod`, "utf-8");
69
- const modMatch = gomod.match(/^module\s+(\S+)/m);
70
- if (modMatch) {
71
- const segments = modMatch[1].split("/");
72
- result = segments.pop()?.trim() || undefined;
73
- }
74
- logDebug("detectProjectName step4 go.mod", { rootPath, result });
75
- } catch {}
76
- }
77
-
78
- if (!result) {
79
- try {
80
- const pyproj = await readFile(`${rootPath}/pyproject.toml`, "utf-8");
81
- const inProject = pyproj.replace(/\r\n/g, "\n").split("\n").reduce(
82
- (acc, line) => {
83
- if (/^\[project\]/.test(line.trim())) return { ...acc, inSection: true };
84
- if (/^\[/.test(line.trim())) return { ...acc, inSection: false };
85
- if (acc.inSection) {
86
- const m = line.match(/name\s*=\s*"([^"]+)"/);
87
- if (m) return { ...acc, name: m[1] };
88
- }
89
- return acc;
90
- },
91
- { inSection: false, name: undefined as string | undefined },
92
- );
93
- result = inProject.name?.trim() || undefined;
94
- logDebug("detectProjectName step5 pyproject.toml", { rootPath, result });
95
- } catch {}
96
- }
97
-
98
- if (!result) {
99
- try {
100
- const composer = await readFile(`${rootPath}/composer.json`, "utf-8");
101
- const nameMatch = composer.match(/"name"\s*:\s*"([^"]+)"/);
102
- if (nameMatch) result = nameMatch[1].trim() || undefined;
103
- logDebug("detectProjectName step6 composer.json", { rootPath, result });
104
- } catch {}
105
- }
106
-
107
- if (!result) {
108
- result = rootPath.split("/").pop() || rootPath.split("\\").pop() || undefined;
109
- logDebug("detectProjectName step7 fallback dirname", { rootPath, result });
110
- }
111
-
112
- if (result) {
113
- result = result.trim() || undefined;
114
- }
115
-
116
- if (result) {
117
- projectNameCache.set(rootPath, result);
118
- }
119
- return result;
120
- }
121
-
122
- export function showToast(tui: any, title: string, message: string, variant: string = "info", delayMs?: number) {
123
- const defaultDelay = 1000;
124
- const effectiveDelay = delayMs ?? defaultDelay;
125
- setTimeout(async () => {
126
- if (!tui?.showToast) {
127
- logInfo("showToast: tui.showToast unavailable after delay", { delay: effectiveDelay, title });
128
- return;
129
- }
130
- try {
131
- await tui.showToast({ body: { title, message, variant, duration: 5000 } });
132
- logInfo("showToast: success", { title });
133
- } catch (err) {
134
- logErr("showToast failed", { error: String(err), title });
135
- }
136
- }, effectiveDelay);
137
- }
138
-
139
- export function createToast(config: Partial<CerebroPluginConfig>) {
140
- const defaultDelay = config.ui?.toastDelayMs ?? DEFAULTS.ui.toastDelayMs;
141
- return (tui: any, title: string, message: string, variant: string = "info", delayMs?: number) => {
142
- showToast(tui, title, message, variant, delayMs ?? defaultDelay);
143
- };
144
- }
145
-
146
- const SYSTEM_INJECTION_PATTERNS: RegExp[] = [
147
- /<!--\s*OMO_INTERNAL_INITIATOR\s*-->/,
148
- /^\[SYSTEM DIRECTIVE:/,
149
- /^\[restore checkpointed/,
150
- /^\[session recovered/,
151
- /^<system-reminder>/,
152
- /^<EXTREMELY_IMPORTANT>/,
153
- /^\[CONTEXT\]/,
154
- /^\[GOAL\]/,
155
- /^## ไปปๅŠก[๏ผš:]/,
156
- /^## ๆ”นๅŠจ/,
157
- /^Analyze the attached file/,
158
- /^Provide ONLY the extracted/,
159
- /^Called the Read tool/,
160
- /^MANDATORY delegate_task/,
161
- /^[โ–ฃโ–ช]\s*DCP/,
162
- ];
163
-
164
- const MODE_TAG_PATTERN = /^\[(?:search-mode|analyze-mode)\][\s\S]*?\n---\n?/;
165
- const MODE_TAG_LINE = /^\[(?:search-mode|analyze-mode)\]\s*\n/;
166
-
167
- function extractUserRequest(content: string): string {
168
- const match = content.match(/<user-request>([\s\S]*?)<\/user-request>/);
169
- let text = match ? match[1].trim() : content;
170
-
171
- // [search-mode] / [analyze-mode]: ๅ‰ฅ็ฆปๆ ‡็ญพ+็ณป็ปŸๆŒ‡ไปค+ๅˆ†้š”็บฟ๏ผŒไฟ็•™็”จๆˆทๅฎž้™…ๅ†…ๅฎน
172
- const stripped = text.replace(MODE_TAG_PATTERN, "");
173
- if (stripped !== text && stripped.trim()) {
174
- text = stripped.trim();
175
- } else {
176
- text = text.replace(MODE_TAG_LINE, "").trim();
177
- }
178
-
179
- for (const pattern of SYSTEM_INJECTION_PATTERNS) {
180
- if (pattern.test(text)) return "";
181
- }
182
-
183
- return text;
184
- }
185
-
186
- export const saveKeywordDetectedSessions = new Set<string>();
187
- export const firstMessages = new Map<string, string>();
188
- export const sessionMessages = new Map<string, Array<{ role: string; content: string }>>();
189
- export const profileInjectedSessions = new Map<string, number>();
190
- export const lastProfileBlock = new Map<string, { content: string; count: number }>();
191
- const lastUserMsgCount = new Map<string, number>();
192
- const summarizedSessions = new Set<string>();
193
-
194
- function formatRelativeAge(isoDate: string): string {
195
- const diffMs = Date.now() - new Date(isoDate).getTime();
196
- const minutes = Math.floor(diffMs / 60_000);
197
- if (minutes < 60) return `${minutes}m ago`;
198
- const hours = Math.floor(minutes / 60);
199
- if (hours < 24) return `${hours}h ago`;
200
- const days = Math.floor(hours / 24);
201
- if (days < 30) return `${days}d ago`;
202
- const months = Math.floor(days / 30);
203
- return `${months}mo ago`;
204
- }
205
-
206
- function truncate(text: string, maxLength: number): string {
207
- if (text.length <= maxLength) return text;
208
-
209
- // Sentence boundary characters: period, exclamation, question (Latin + CJK)
210
- // Also treat newline as a boundary
211
- const boundaries = /[.!?ใ€‚๏ผ๏ผŸ\n]/;
212
-
213
- // Search backwards from maxLength for a boundary
214
- const searchEnd = Math.min(maxLength, text.length);
215
- for (let i = searchEnd - 1; i >= Math.floor(searchEnd * BOUNDARY_SEARCH_RATIO); i--) {
216
- if (boundaries.test(text[i])) {
217
- return text.slice(0, i + 1).trimEnd() + "โ€ฆ";
218
- }
219
- }
220
-
221
- let truncated = text.slice(0, maxLength);
222
- const lastCode = truncated.charCodeAt(truncated.length - 1);
223
- if (lastCode >= 0xD800 && lastCode <= 0xDBFF) truncated = truncated.slice(0, -1);
224
- return truncated + "โ€ฆ";
225
- }
226
-
227
- const FETCH_POLICY = [
228
- "<cerebro-fetch-policy>",
229
- "IMPORTANT: Each memory above is a condensed summary. The full version contains critical details that may change your response quality.",
230
- "You MUST use memory_get(\"id\") to retrieve the complete content, or memory_search(\"query\") to find specific memories before making decisions based on any summary.",
231
- "Do NOT rely on condensed summaries alone โ€” depth of recall determines quality of response.",
232
- "</cerebro-fetch-policy>",
233
- ].join("\n");
234
-
235
- const MAX_INJECTION_CHARS_FALLBACK = DEFAULTS.content.maxContentChars;
236
-
237
- interface InjectionResult {
238
- text: string;
239
- profileCount: number;
240
- memoryCount: number;
241
- projectMemoryCount: number;
242
- maxScore: number;
243
- confidence: number;
244
- }
245
-
246
- export async function buildMemoryInjection(
247
- client: CerebroClient,
248
- projectPath: string | undefined,
249
- query: string,
250
- config: Partial<CerebroPluginConfig>,
251
- ): Promise<InjectionResult> {
252
- const maxChars = config.content?.maxContentChars ?? MAX_INJECTION_CHARS_FALLBACK;
253
- const ic = config.injection ?? DEFAULTS.injection;
254
- const recentCount = ic.recentCount || DEFAULTS.injection.recentCount;
255
- const searchCount = ic.searchCount || DEFAULTS.injection.searchCount;
256
- const recentTruncate = ic.recentTruncateChars || 0; // 0 = ไธๆˆชๆ–ญ
257
- const searchTruncate = ic.searchTruncateChars || 0; // 0 = ไธๆˆชๆ–ญ
258
- const profileTimeout = ic.profileTimeoutMs || DEFAULTS.injection.profileTimeoutMs;
259
- const recentTimeout = ic.recentTimeoutMs || DEFAULTS.injection.recentTimeoutMs;
260
- const searchTimeout = ic.searchTimeoutMs || DEFAULTS.injection.searchTimeoutMs;
261
-
262
- const [profile, projectMemories, searchResults] = await Promise.all([
263
- Promise.race([
264
- client.getInjection(),
265
- new Promise<null>((resolve) => setTimeout(() => resolve(null), profileTimeout)),
266
- ]).catch(() => null),
267
- Promise.race([
268
- client.listRecent(recentCount, projectPath),
269
- new Promise<never[]>((resolve) => setTimeout(() => resolve([]), recentTimeout)),
270
- ]).catch(() => []),
271
- query
272
- ? Promise.race([
273
- client.searchMemories(query, searchCount, undefined, undefined, projectPath),
274
- new Promise<never[]>((resolve) => setTimeout(() => resolve([]), searchTimeout)),
275
- ]).catch(() => [])
276
- : Promise.resolve([]),
277
- ]);
278
-
279
- const sections: string[] = ["[CEREBRO-MEMORY]", ""];
280
-
281
- if (profile?.content) {
282
- sections.push(profile.content);
283
- sections.push("");
284
- }
285
-
286
- const seenIds = new Set<string>();
287
-
288
- if (projectMemories.length > 0) {
289
- sections.push("## Recent Project Activity");
290
- for (const m of projectMemories) {
291
- seenIds.add(m.id);
292
- const age = formatRelativeAge(m.updated_at || m.created_at) || "unknown";
293
- const content = recentTruncate > 0 ? truncate(m.content, recentTruncate) : m.content;
294
- sections.push(`- (${age}) ${content}`);
295
- }
296
- sections.push("");
297
- }
298
-
299
- const dedupedResults = (searchResults || []).filter((r) => !seenIds.has(r.memory.id));
300
- if (dedupedResults.length > 0) {
301
- sections.push("## Relevant Memories");
302
- for (const r of dedupedResults) {
303
- const age = formatRelativeAge(r.memory.created_at) || "unknown";
304
- const content = searchTruncate > 0 ? truncate(r.memory.content, searchTruncate) : r.memory.content;
305
- sections.push(`- (${age}) ${content}`);
306
- }
307
- sections.push("");
308
- }
309
-
310
- sections.push("[/CEREBRO-MEMORY]");
311
-
312
- let text = sections.join("\n");
313
- if (text.length > maxChars) {
314
- const cutoff = text.lastIndexOf('\n', maxChars);
315
- text = text.slice(0, cutoff > 0 ? cutoff : maxChars) + "\nโ€ฆ\n[/CEREBRO-MEMORY]";
316
- }
317
-
318
- const maxScore = searchResults.reduce((max, r) => Math.max(max, r.score), 0);
319
- const confidence = Math.min(maxScore, 1.0);
320
-
321
- return {
322
- text,
323
- profileCount: profile?.preference_count ?? 0,
324
- memoryCount: dedupedResults?.length ?? 0,
325
- projectMemoryCount: projectMemories.length,
326
- maxScore,
327
- confidence,
328
- };
329
- }
330
-
331
- const injectedSessions = new Set<string>();
332
-
333
- export function chatMessageRecallHook(
334
- client: CerebroClient,
335
- _containerTags: string[],
336
- tui: any,
337
- config: Partial<CerebroPluginConfig> = {},
338
- getAgentName?: () => string,
339
- directory?: string,
340
- ) {
341
- return async (
342
- input: { sessionID: string; messageID?: string },
343
- output: { message: UserMessage; parts: Part[] },
344
- ) => {
345
- if (!input.sessionID) return;
346
- if (injectedSessions.has(input.sessionID)) return;
347
-
348
- const agentId = getAgentName?.() || process.env.OMEM_AGENT_ID || "opencode";
349
- const policy = resolveAgentPolicy(agentId, config);
350
- if (policy === "none") {
351
- injectedSessions.add(input.sessionID);
352
- return;
353
- }
354
-
355
- const textContent = output.parts
356
- .filter((p: any) => p.type === "text")
357
- .map((p: any) => p.text || (p as any).content || "")
358
- .join(" ")
359
- || (output.message as any).content
360
- || "";
361
-
362
- const query = extractUserRequest(textContent);
363
-
364
- const TRIVIAL_PATTERNS = /^(hi|hello|hey|ไฝ ๅฅฝ|ๅ—จ|ๅ—ฏ|ok|okay|ๅฅฝ็š„|ๆ”ถๅˆฐ|\s*)$/i;
365
- if (!query || TRIVIAL_PATTERNS.test(query.trim())) {
366
- logDebug("chatMessageRecallHook: trivial query, will retry next turn", { sessionId: input.sessionID });
367
- return;
368
- }
369
-
370
- try {
371
- const injection = await buildMemoryInjection(client, directory, query, config);
372
-
373
- const hasContent = (injection.profileCount ?? 0) > 0
374
- || (injection.memoryCount ?? 0) > 0
375
- || (injection.projectMemoryCount ?? 0) > 0;
376
-
377
- if (injection.text && hasContent && injection.text.length > 20) {
378
- injectedSessions.add(input.sessionID);
379
-
380
- output.parts.unshift({
381
- id: `prt_cerebro-inject-${Date.now()}`,
382
- sessionID: input.sessionID,
383
- messageID: output.message?.id,
384
- type: "text",
385
- text: injection.text,
386
- synthetic: true,
387
- } as any);
388
-
389
- showToast(tui, "๐Ÿง  Memory Injected",
390
- `${injection.profileCount} prefs ยท ${injection.projectMemoryCount} project ยท ${injection.memoryCount} relevant`,
391
- "success");
392
-
393
- client.createRecallEvent({
394
- session_id: input.sessionID,
395
- recall_type: "auto",
396
- query_text: query,
397
- max_score: injection.maxScore,
398
- llm_confidence: injection.confidence,
399
- profile_injected: injection.profileCount > 0,
400
- kept_count: injection.projectMemoryCount + injection.memoryCount,
401
- discarded_count: 0,
402
- injected_count: injection.projectMemoryCount + injection.memoryCount,
403
- injected_content: injection.text,
404
- }).catch((e: unknown) => {
405
- logErr("chatMessageRecallHook createRecallEvent failed", { error: String(e) });
406
- });
407
- } else if (!hasContent) {
408
- logDebug("chatMessageRecallHook: no content available, will retry next turn", {
409
- sessionId: input.sessionID,
410
- profileCount: injection.profileCount,
411
- memoryCount: injection.memoryCount,
412
- projectMemoryCount: injection.projectMemoryCount,
413
- });
414
- showToast(tui, "๐Ÿง  Memory Unavailable", "API timeout or no memories yet", "warning");
415
- }
416
- } catch (err) {
417
- logErr("chatMessageRecallHook failed", { error: String(err) });
418
- showToast(tui, "๐Ÿง  Memory Injection Failed", "Check connection", "error");
419
- }
420
- };
421
- }
422
-
423
- /**
424
- * System transform hook โ€” injects current system time + memory_search reminder
425
- * into output.system[] on every LLM request. Forces AI to perceive time and
426
- * recall memories, addressing "time loss" and "forgot to search memory" issues.
427
- *
428
- * Factory pattern matching chatMessageRecallHook. State-less (fires per turn).
429
- */
430
- export function timeMemorySystemHook() {
431
- return async (
432
- _input: unknown,
433
- output: { system?: string[] },
434
- ) => {
435
- if (!output || !Array.isArray(output.system)) return;
436
-
437
- let timeStr = "";
438
- try {
439
- timeStr = execSync("date '+%Y-%m-%d %H:%M:%S %A'", {
440
- encoding: "utf-8",
441
- timeout: 2000,
442
- }).trim();
443
- } catch (err) {
444
- logErr("timeMemorySystemHook: date command failed", { error: String(err) });
445
- timeStr = new Date().toISOString().replace("T", " ").slice(0, 19);
446
- }
447
-
448
- output.system.push(`[CEREBRO-TIME] ${timeStr}`);
449
- output.system.push(
450
- '[CEREBRO-TIME-MEANING] Timestamps above = real moments passed. Feel gaps between turns. Silence โ‰  instant reply. Time shapes conversation. Let it shape yours.',
451
- );
452
- };
453
- }
454
-
455
- export function createCerebroCompactionPrompt(
456
- context: string[],
457
- projectMemories: SearchResult[],
458
- ): string {
459
- const sections: string[] = [
460
- "[Cerebro Compaction Context]",
461
- "",
462
- "## 1. User's Original Request",
463
- "Preserve the user's verbatim original request from the conversation above.",
464
- "",
465
- "## 2. Final Goal",
466
- "What is the ultimate objective the user wants to achieve?",
467
- "",
468
- "## 3. Work Completed",
469
- "List all completed work with file paths and technical decisions made.",
470
- "",
471
- "## 4. Remaining Tasks",
472
- "What is still unfinished or pending?",
473
- "",
474
- "## 5. Prohibited Actions",
475
- "Key constraints and forbidden operations to remember.",
476
- "",
477
- "## 6. Existing Project Knowledge",
478
- ];
479
-
480
- if (projectMemories.length > 0) {
481
- const memBlock = projectMemories
482
- .slice(0, 10)
483
- .map((r) => {
484
- const content = r.memory.content ?? "";
485
- const truncated = content.length > 200 ? content.slice(0, 200) + "..." : content;
486
- return ` - [${r.memory.category ?? "general"}] ${truncated}`;
487
- })
488
- .join("\n");
489
- sections.push(memBlock);
490
- } else {
491
- sections.push(" (No project memories retrieved)");
492
- }
493
-
494
- if (context.length > 0) {
495
- sections.push("");
496
- sections.push("### Additional Context");
497
- sections.push(...context);
498
- }
499
-
500
- sections.push("");
501
- sections.push("IMPORTANT: Output must preserve the user's original language (Chinese/English/etc). Do not translate.");
502
-
503
- return sections.join("\n");
504
- }
505
-
506
- export function compactingHook(client: CerebroClient, containerTags: string[], tui: any, ingestMode: "smart" | "raw" = "smart", isAutoStoreEnabled?: (sessionId: string | undefined) => boolean, getMainSessionId?: () => string | undefined, sdkClient?: any, config: Partial<CerebroPluginConfig> = {}, agentId?: string, directory?: string) {
507
- const effectiveAgentId = agentId || process.env.OMEM_AGENT_ID || "opencode";
508
- return async (
509
- input: { sessionID?: string },
510
- output: { context: string[]; prompt?: string },
511
- ) => {
512
- logInfo("compactingHook triggered", { sessionId: input.sessionID, hasSessionMessages: sessionMessages.has(input.sessionID || "") });
513
-
514
- // Search (read) always runs โ€” even readonly agents need context during compacting
515
- try {
516
- const results = await client.searchMemories("*", 20, undefined, containerTags);
517
- const compactionPrompt = createCerebroCompactionPrompt(output.context, results);
518
- if (output.prompt !== undefined) {
519
- output.prompt = compactionPrompt;
520
- } else if (output.context.length > 0) {
521
- output.context[output.context.length - 1] += "\n\n" + compactionPrompt;
522
- } else {
523
- output.context.push(compactionPrompt);
524
- }
525
- if (output.context.length > 0) {
526
- output.context[output.context.length - 1] += "\n\n" + FETCH_POLICY;
527
- } else {
528
- output.context.push(FETCH_POLICY);
529
- }
530
- } catch {
531
- }
532
-
533
- // Main session gate: sub-agents must not write memories via compacting
534
- if (getMainSessionId) {
535
- const mainId = getMainSessionId();
536
- if (mainId && input.sessionID && input.sessionID !== mainId) {
537
- logInfo("compactingHook: non-main session skipped", { sessionID: input.sessionID, mainSessionId: mainId });
538
- return;
539
- }
540
- }
541
-
542
- // Policy gate: only readwrite agents can write memories
543
- const policy = resolveAgentPolicy(effectiveAgentId, config);
544
- if (policy !== "readwrite") {
545
- logInfo("compactingHook blocked by policy", { agentId: effectiveAgentId, policy });
546
- if (input.sessionID) {
547
- sessionMessages.delete(input.sessionID);
548
- profileInjectedSessions.delete(input.sessionID);
549
- lastUserMsgCount.delete(input.sessionID);
550
- firstMessages.delete(input.sessionID);
551
- }
552
- return;
553
- }
554
-
555
- const effectiveSessionId = sanitizeSessionId(getMainSessionId?.() || input.sessionID);
556
-
557
- // Resolve project name (shared by ingest + poll)
558
- let projectName: string | undefined;
559
- let projectPath: string | undefined;
560
- try {
561
- if (sdkClient && input.sessionID) {
562
- const sessionInfo = await sdkClient.session.get({ path: { id: input.sessionID } });
563
- logDebug("compactingHook project.rootPath", { rootPath: sessionInfo?.data?.directory });
564
- projectPath = directory || sessionInfo?.data?.directory || process.env.OMEM_PROJECT_DIR;
565
- projectName = sessionInfo?.data?.directory
566
- ? await detectProjectName(sessionInfo.data.directory)
567
- : undefined;
568
- }
569
- } catch (e) {
570
- logErr("compactingHook detectProjectName failed", { error: String(e) });
571
- }
572
- if (!projectPath) {
573
- projectPath = directory || process.env.OMEM_PROJECT_DIR;
574
- }
575
-
576
- // --- Phase 1: Ingest tracked messages from sessionMessages (if available) ---
577
- if (input.sessionID && sessionMessages.has(input.sessionID)) {
578
- if (isAutoStoreEnabled && !isAutoStoreEnabled(input.sessionID)) {
579
- sessionMessages.delete(input.sessionID);
580
- profileInjectedSessions.delete(input.sessionID);
581
- lastUserMsgCount.delete(input.sessionID);
582
- firstMessages.delete(input.sessionID);
583
- } else {
584
- const messages = sessionMessages.get(input.sessionID)!;
585
- if (messages.length > 0) {
586
- try {
587
- logInfo("compactingHook ingestMessages called", { msgCount: messages.length, sessionId: effectiveSessionId, agentId: effectiveAgentId });
588
- const result = await client.ingestMessages(messages, {
589
- mode: ingestMode,
590
- tags: [...containerTags, "auto-capture"],
591
- sessionId: effectiveSessionId,
592
- projectName: projectName,
593
- agentId: effectiveAgentId,
594
- projectPath,
595
- });
596
- logInfo("compactingHook ingestMessages result", { result: result === null ? "null(blocked)" : "ok" });
597
- if (result === null) {
598
- showToast(tui, "๐Ÿ”ด Archive Failed", "Session archive blocked ยท check spiritual realm status", "error");
599
- } else {
600
- showToast(tui, "๐Ÿ“ฆ Session Archived", `${messages.length} residual dialogues archived ยท merged into the realm`, "success");
601
- }
602
- } catch (e) {
603
- logErr("compactingHook ingestMessages failed", { error: String(e) });
604
- showToast(tui, "๐Ÿ”ด Archive Failed", "Session archive blocked ยท spiritual pulse anomaly", "error");
605
- }
606
- }
607
- }
608
- // Cleanup tracked messages regardless of ingest result
609
- sessionMessages.delete(input.sessionID);
610
- profileInjectedSessions.delete(input.sessionID);
611
- lastUserMsgCount.delete(input.sessionID);
612
- firstMessages.delete(input.sessionID);
613
- processedMessageIds.delete(input.sessionID);
614
- injectedSessions.delete(input.sessionID);
615
- if (input.sessionID) {
616
- logDebug("compactingHook cleared session state", { sessionID: input.sessionID });
617
- }
618
- }
619
-
620
- // After compacting, clear profile TTL so next autoRecallHook re-injects profile
621
- if (input.sessionID) {
622
- profileInjectedSessions.delete(input.sessionID);
623
- lastUserMsgCount.delete(input.sessionID);
624
- processedMessageIds.delete(input.sessionID);
625
- injectedSessions.delete(input.sessionID);
626
- logDebug("compactingHook cleared profile TTL for re-injection", { sessionID: input.sessionID });
627
- }
628
- };
629
- }
630
-
631
- export function autocontinueHook(
632
- client: CerebroClient,
633
- containerTags: string[],
634
- tui: any,
635
- ingestMode: "smart" | "raw" = "smart",
636
- isAutoStoreEnabled?: (sessionId: string | undefined) => boolean,
637
- getMainSessionId?: () => string | undefined,
638
- sdkClient?: any,
639
- config: Partial<CerebroPluginConfig> = {},
640
- agentId?: string,
641
- directory?: string,
642
- ) {
643
- const effectiveAgentId = agentId || process.env.OMEM_AGENT_ID || "opencode";
644
- return async (
645
- input: {
646
- sessionID: string;
647
- agent: string;
648
- model: Model;
649
- message: UserMessage;
650
- overflow: boolean;
651
- },
652
- _output: { enabled: boolean },
653
- ) => {
654
- try {
655
- const policy = resolveAgentPolicy(effectiveAgentId, config);
656
- if (policy !== "readwrite") {
657
- logInfo("autocontinueHook blocked by policy", { agentId: effectiveAgentId, policy });
658
- return;
659
- }
660
-
661
- if (isAutoStoreEnabled && !isAutoStoreEnabled(input.sessionID)) {
662
- logInfo("autocontinueHook skipped: auto-store disabled", { sessionId: input.sessionID });
663
- return;
664
- }
665
-
666
- const effectiveSessionId = sanitizeSessionId(getMainSessionId?.() || input.sessionID);
667
-
668
- if (!sdkClient) {
669
- logInfo("autocontinueHook skipped: no sdkClient", { sessionId: input.sessionID });
670
- return;
671
- }
672
-
673
- let summaryText: string | undefined;
674
- try {
675
- const response = await sdkClient.session.messages({ path: { id: input.sessionID } });
676
- if (response?.data) {
677
- let targetMsg = response.data.find(
678
- (msg: any) => msg.info?.id === input.message.id,
679
- );
680
-
681
- if (!targetMsg?.parts) {
682
- targetMsg = response.data.find(
683
- (msg: any) => msg.info?.role === "assistant" && msg.info?.summary === true,
684
- );
685
- }
686
-
687
- if (!targetMsg?.parts) {
688
- const assistants = response.data.filter((msg: any) => msg.info?.role === "assistant");
689
- if (assistants.length > 0) targetMsg = assistants[assistants.length - 1];
690
- }
691
-
692
- if (targetMsg?.parts) {
693
- const textParts = (targetMsg.parts as any[])
694
- .filter((p: any) => p.type === "text" && p.text)
695
- .map((p: any) => p.text);
696
- summaryText = textParts.join("\n").trim();
697
- }
698
- }
699
- } catch (e) {
700
- logErr("autocontinueHook failed to fetch message parts", { error: String(e) });
701
- }
702
-
703
- if (!summaryText || summaryText.length < 30) {
704
- logInfo("autocontinueHook skipped: summary too short", { sessionId: input.sessionID, messageId: input.message.id, summaryLen: summaryText?.length ?? 0 });
705
- return;
706
- }
707
-
708
- let projectName: string | undefined;
709
- let projectPath: string | undefined;
710
- try {
711
- const sessionInfo = await sdkClient.session.get({ path: { id: input.sessionID } });
712
- projectPath = directory || sessionInfo?.data?.directory || process.env.OMEM_PROJECT_DIR;
713
- projectName = sessionInfo?.data?.directory
714
- ? await detectProjectName(sessionInfo.data.directory)
715
- : undefined;
716
- } catch (e) {
717
- logErr("autocontinueHook detectProjectName failed", { error: String(e) });
718
- }
719
- if (!projectPath) {
720
- projectPath = directory || process.env.OMEM_PROJECT_DIR;
721
- }
722
-
723
- const messages = [{ role: "user" as const, content: summaryText }];
724
- logInfo("autocontinueHook storing compact summary", {
725
- summaryLen: summaryText.length,
726
- sessionId: effectiveSessionId,
727
- agentId: effectiveAgentId,
728
- overflow: input.overflow,
729
- projectName,
730
- });
731
-
732
- const result = await client.ingestMessages(messages, {
733
- mode: ingestMode,
734
- tags: [...containerTags, "auto-capture", "compact-summary"],
735
- sessionId: effectiveSessionId,
736
- projectName: projectName,
737
- agentId: effectiveAgentId,
738
- projectPath,
739
- });
740
-
741
- logInfo("autocontinueHook store result", { result: result === null ? "null(blocked)" : "ok" });
742
- if (result === null) {
743
- showToast(tui, "๐Ÿ”ด Compact Summary Failed", "Storage blocked ยท check server status", "error");
744
- } else {
745
- showToast(tui, "๐Ÿ“ฆ Compact Summary Stored", "Session summary archived to memory", "success");
746
- }
747
- } catch (e) {
748
- logErr("autocontinueHook failed", { error: String(e) });
749
- }
750
- };
751
- }
752
-
753
- const processedMessageIds = new Map<string, Set<string>>();
754
- const pluginStartTime = Date.now();
755
-
756
- export function sessionIdleHook(
757
- cerebroClient: CerebroClient,
758
- containerTags: string[],
759
- tui: any,
760
- sdkClient: any,
761
- ingestMode: "smart" | "raw" = "smart",
762
- threshold: number = 0,
763
- getMainSessionId?: () => string | undefined,
764
- isAutoStoreEnabled?: (sessionId: string | undefined) => boolean,
765
- agentId?: string,
766
- config: Partial<CerebroPluginConfig> = {},
767
- onAgentResolved?: (name: string) => void,
768
- directory?: string,
769
- ) {
770
- let idleTimeout: ReturnType<typeof setTimeout> | null = null;
771
- let isCapturing = false;
772
-
773
- async function handleSummaryCapture(props: any) {
774
- const info = props?.info;
775
- if (!info) return;
776
- if (info.role !== "assistant") return;
777
- // info.summary may be missing in some SDK versions โ€” handle below
778
- // info.finish check: only process on finish, but allow missing field
779
- if (info.finish === false) return;
780
-
781
- const sessionID = sanitizeSessionId(info.sessionID);
782
- if (!sessionID) return;
783
-
784
- logInfo("handleSummaryCapture checking", {
785
- sessionID,
786
- role: info?.role,
787
- hasSummary: !!info?.summary,
788
- finish: info?.finish,
789
- });
790
-
791
- if (summarizedSessions.has(sessionID)) return;
792
- summarizedSessions.add(sessionID);
793
-
794
- if (!sdkClient) {
795
- logInfo("handleSummaryCapture skipped: no sdkClient", { sessionID });
796
- return;
797
- }
798
-
799
- logInfo("handleSummaryCapture triggered", { sessionID });
800
-
801
- if (getMainSessionId) {
802
- const mainId = getMainSessionId();
803
- if (mainId && sessionID !== mainId) {
804
- logInfo("handleSummaryCapture: non-main session skipped", { sessionID, mainSessionId: mainId });
805
- return;
806
- }
807
- }
808
-
809
- const effectiveAgentId = agentId || process.env.OMEM_AGENT_ID || "opencode";
810
- const policy = resolveAgentPolicy(effectiveAgentId, config);
811
- if (policy !== "readwrite") {
812
- logInfo("handleSummaryCapture blocked by policy", { agentId: effectiveAgentId, policy });
813
- return;
814
- }
815
-
816
- if (isAutoStoreEnabled && !isAutoStoreEnabled(sessionID)) return;
817
-
818
- try {
819
- const resp = await sdkClient.session.messages({ path: { id: sessionID } });
820
- const messages = resp?.data ?? resp;
821
-
822
- let summaryMsg = (messages as Array<{ info: any; parts?: Array<{ type: string; text?: string }> }>).find((m) =>
823
- m.info?.role === "assistant" && m.info?.summary === true
824
- );
825
-
826
- if (!summaryMsg?.parts) {
827
- logInfo("handleSummaryCapture: no summary-flagged message, trying last assistant message", { sessionID });
828
- const assistantMsgs = (messages as Array<{ info: any; parts?: Array<{ type: string; text?: string }> }>)
829
- .filter(m => m.info?.role === "assistant");
830
- summaryMsg = assistantMsgs.length > 0 ? assistantMsgs[assistantMsgs.length - 1] : undefined;
831
- }
832
-
833
- if (!summaryMsg?.parts) {
834
- logInfo("handleSummaryCapture: no assistant message parts found", { sessionID });
835
- return;
836
- }
837
-
838
- const textParts = summaryMsg.parts.filter((p) => p.type === "text" && p.text).map((p) => p.text);
839
- const summaryContent = textParts.join("\n").trim();
840
-
841
- if (!summaryContent || summaryContent.length < 30) {
842
- logInfo("handleSummaryCapture: summary too short", { sessionID, length: summaryContent?.length ?? 0 });
843
- return;
844
- }
845
-
846
- const effectiveSessionId = sanitizeSessionId(getMainSessionId?.() || sessionID);
847
-
848
- let projectName: string | undefined;
849
- let projectPath: string | undefined;
850
- try {
851
- const sessionInfo = await sdkClient.session.get({ path: { id: sessionID } });
852
- projectPath = directory || sessionInfo?.data?.directory || process.env.OMEM_PROJECT_DIR;
853
- projectName = sessionInfo?.data?.directory
854
- ? await detectProjectName(sessionInfo.data.directory)
855
- : undefined;
856
- } catch (e) {
857
- logErr("handleSummaryCapture detectProjectName failed", { error: String(e) });
858
- }
859
- if (!projectPath) {
860
- projectPath = directory || process.env.OMEM_PROJECT_DIR;
861
- }
862
-
863
- const prefixedSummary = `[Session Summary] ${summaryContent}`;
864
- const result = await cerebroClient.ingestMessages(
865
- [{ role: "user" as const, content: prefixedSummary }],
866
- {
867
- mode: ingestMode,
868
- tags: [...containerTags, "auto-capture", "compact-summary"],
869
- sessionId: effectiveSessionId,
870
- projectName,
871
- agentId: effectiveAgentId,
872
- projectPath,
873
- },
874
- );
875
-
876
- logInfo("handleSummaryCapture store result", { result: result === null ? "null(blocked)" : "ok" });
877
- if (result !== null) {
878
- showToast(tui, "๐Ÿ“ฆ Compact Summary Stored", "Session summary archived", "success");
879
- }
880
- } catch (err) {
881
- logErr("handleSummaryCapture failed", { error: String(err) });
882
- }
883
- }
884
-
885
- return async (input: { event: { type: string; properties?: any } }) => {
886
- if (input.event.type === "message.updated") {
887
- await handleSummaryCapture(input.event.properties);
888
- return;
889
- }
890
-
891
- if (input.event.type === "session.deleted") {
892
- const sessionInfo = input.event.properties?.info;
893
- const sid = sessionInfo?.id;
894
- if (sid) {
895
- summarizedSessions.delete(sid);
896
- sessionMessages.delete(sid);
897
- profileInjectedSessions.delete(sid);
898
- lastUserMsgCount.delete(sid);
899
- firstMessages.delete(sid);
900
- logDebug("sessionIdleHook: session.deleted cleanup", { sessionID: sid });
901
- }
902
- return;
903
- }
904
-
905
- if (input.event.type !== "session.idle") return;
906
-
907
- logDebug("sessionIdleHook event.properties dump", { keys: Object.keys(input.event.properties || {}), raw: JSON.stringify(input.event.properties).substring(0, 2000) });
908
-
909
- const sessionID = sanitizeSessionId(input.event.properties?.sessionID);
910
- if (!sessionID) return;
911
-
912
- if (isAutoStoreEnabled && !isAutoStoreEnabled(sessionID)) return;
913
-
914
- if (getMainSessionId) {
915
- const mainId = getMainSessionId();
916
- if (mainId && sessionID !== mainId) {
917
- logInfo("sessionIdleHook: non-main session skipped", { sessionID, mainSessionId: mainId });
918
- return;
919
- }
920
- }
921
-
922
- if (idleTimeout) clearTimeout(idleTimeout);
923
-
924
- idleTimeout = setTimeout(async () => {
925
- if (isCapturing) return;
926
- isCapturing = true;
927
-
928
- try {
929
- const response = await sdkClient.session.messages({ path: { id: sessionID } });
930
- if (!response?.data) return;
931
-
932
- const messages = response.data;
933
- const conversationMessages: Array<{ role: string; content: string }> = [];
934
- const newMessageIds: string[] = [];
935
- let hasNewMessages = false;
936
-
937
- for (const msg of messages) {
938
- const msgId = msg.info?.id;
939
- if (!msgId) continue;
940
- if (!processedMessageIds.has(sessionID)) {
941
- processedMessageIds.set(sessionID, new Set());
942
- }
943
- if (processedMessageIds.get(sessionID)!.has(msgId)) continue;
944
-
945
- const msgTime = msg.info?.createdAt ? new Date(msg.info.createdAt).getTime() : 0;
946
- if (msgTime > 0 && msgTime < pluginStartTime) continue;
947
-
948
- const role = msg.info?.role;
949
- if (role !== "user" && role !== "assistant") continue;
950
-
951
- const textParts = (msg.parts || [])
952
- .filter((p: any) => p.type === "text" && p.text)
953
- .map((p: any) => p.text);
954
- const text = textParts.join("\n").trim();
955
- if (!text) continue;
956
-
957
- hasNewMessages = true;
958
- newMessageIds.push(msgId);
959
- conversationMessages.push({ role, content: text });
960
- }
961
-
962
- if (!hasNewMessages || conversationMessages.length === 0) return;
963
-
964
- if (threshold > 1 && conversationMessages.length < threshold) {
965
- return;
966
- }
967
-
968
- let sessionTitle: string | undefined;
969
- let projectName: string | undefined;
970
- let projectPath: string | undefined;
971
- let effectiveAgentId = agentId || "opencode";
972
- try {
973
- const sessionInfo = await sdkClient.session.get({ path: { id: sessionID } });
974
- if ((sessionInfo?.data as any)?.agent) {
975
- effectiveAgentId = (sessionInfo.data as any).agent;
976
- onAgentResolved?.(effectiveAgentId);
977
- }
978
- sessionTitle = sessionInfo?.data?.title;
979
- projectPath = directory || sessionInfo?.data?.directory || process.env.OMEM_PROJECT_DIR;
980
- projectName = sessionInfo?.data?.directory
981
- ? await detectProjectName(sessionInfo.data.directory)
982
- : undefined;
983
- } catch (e) {
984
- logErr("sessionIdleHook detectProjectName failed", { error: String(e) });
985
- }
986
- if (!projectPath) {
987
- projectPath = directory || process.env.OMEM_PROJECT_DIR;
988
- }
989
-
990
- logDebug("sessionIdleHook resolved agentId", { effectiveAgentId, fallbackAgentId: agentId });
991
-
992
- const policy = resolveAgentPolicy(effectiveAgentId, config);
993
- if (policy !== "readwrite") {
994
- logInfo("sessionIdleHook blocked by policy", { agentId: effectiveAgentId, policy, defaultPolicy: String(config.defaultPolicy ?? "undefined") });
995
- return;
996
- }
997
-
998
- try {
999
- logInfo("sessionIdleHook sessionIngest called", { msgCount: conversationMessages.length, sessionId: sessionID, agentId: effectiveAgentId, title: String(sessionTitle) });
1000
- await cerebroClient.sessionIngest(conversationMessages, sessionID, effectiveAgentId, sessionTitle, projectName, projectPath);
1001
- logInfo("sessionIdleHook sessionIngest ok");
1002
- for (const id of newMessageIds) {
1003
- processedMessageIds.get(sessionID)!.add(id);
1004
- }
1005
- showToast(tui, "๐Ÿง  Memory Sealed", `${conversationMessages.length} dialogues captured ยท entrusted to the heavens for refinement`, "success");
1006
- } catch (err) {
1007
- logErr("sessionIdleHook sessionIngest failed", { error: String(err) });
1008
- showToast(tui, "๐Ÿ”ด Session Capture Failed", String(err).substring(0, 100), "error");
1009
- }
1010
- } catch (err) {
1011
- const errMsg = err instanceof Error ? err.message : String(err);
1012
- showToast(tui, "๐Ÿ”ด Idle Capture Error", errMsg.substring(0, 100), "error");
1013
- } finally {
1014
- isCapturing = false;
1015
- idleTimeout = null;
1016
- }
1017
- }, 10000);
1018
- };
1019
- }
1
+ import type { Model, UserMessage, Part } from "@opencode-ai/sdk";
2
+ import type { CerebroClient, SearchResult } from "./client.js";
3
+ import { type CerebroPluginConfig, DEFAULTS, resolveAgentPolicy } from "./config.js";
4
+ import { logDebug, logInfo, logError as logErr } from "./logger.js";
5
+ import { readFile } from "node:fs/promises";
6
+ import { execSync } from "node:child_process";
7
+
8
+ /** Sanitize session ID to prevent path traversal */
9
+ function sanitizeSessionId(id: string | undefined): string | undefined {
10
+ if (!id) return id;
11
+ // Remove any path separators or traversal attempts
12
+ return id.replace(/[/\\]/g, "_").replace(/\.\./g, "");
13
+ }
14
+
15
+ const BOUNDARY_SEARCH_RATIO = 0.6;
16
+
17
+ const projectNameCache = new Map<string, string>();
18
+
19
+ async function detectProjectName(rootPath: string): Promise<string | undefined> {
20
+ const cached = projectNameCache.get(rootPath);
21
+ if (cached !== undefined) {
22
+ logDebug("detectProjectName cache hit", { rootPath, result: cached });
23
+ return cached;
24
+ }
25
+
26
+ let result: string | undefined;
27
+
28
+ try {
29
+ const agents = await readFile(`${rootPath}/AGENTS.md`, "utf-8");
30
+ const headingMatch = agents.match(/^#\s+(.+)/m);
31
+ if (headingMatch) {
32
+ result = headingMatch[1].replace(/\s*\(.*?\)/g, "").trim() || undefined;
33
+ }
34
+ logDebug("detectProjectName step1 AGENTS.md", { rootPath, result });
35
+ } catch {}
36
+
37
+ if (!result) {
38
+ try {
39
+ const pkg = await readFile(`${rootPath}/package.json`, "utf-8");
40
+ const nameMatch = pkg.match(/"name"\s*:\s*"([^"]+)"/);
41
+ if (nameMatch) result = nameMatch[1].trim() || undefined;
42
+ logDebug("detectProjectName step2 package.json", { rootPath, result });
43
+ } catch {}
44
+ }
45
+
46
+ if (!result) {
47
+ try {
48
+ const cargo = await readFile(`${rootPath}/Cargo.toml`, "utf-8");
49
+ const inPackage = cargo.replace(/\r\n/g, "\n").split("\n").reduce(
50
+ (acc, line) => {
51
+ if (/^\[package\]/.test(line.trim())) return { ...acc, inSection: true };
52
+ if (/^\[/.test(line.trim())) return { ...acc, inSection: false };
53
+ if (acc.inSection) {
54
+ const m = line.match(/name\s*=\s*"([^"]+)"/);
55
+ if (m) return { ...acc, name: m[1] };
56
+ }
57
+ return acc;
58
+ },
59
+ { inSection: false, name: undefined as string | undefined },
60
+ );
61
+ result = inPackage.name?.trim() || undefined;
62
+ logDebug("detectProjectName step3 Cargo.toml", { rootPath, result });
63
+ } catch {}
64
+ }
65
+
66
+ if (!result) {
67
+ try {
68
+ const gomod = await readFile(`${rootPath}/go.mod`, "utf-8");
69
+ const modMatch = gomod.match(/^module\s+(\S+)/m);
70
+ if (modMatch) {
71
+ const segments = modMatch[1].split("/");
72
+ result = segments.pop()?.trim() || undefined;
73
+ }
74
+ logDebug("detectProjectName step4 go.mod", { rootPath, result });
75
+ } catch {}
76
+ }
77
+
78
+ if (!result) {
79
+ try {
80
+ const pyproj = await readFile(`${rootPath}/pyproject.toml`, "utf-8");
81
+ const inProject = pyproj.replace(/\r\n/g, "\n").split("\n").reduce(
82
+ (acc, line) => {
83
+ if (/^\[project\]/.test(line.trim())) return { ...acc, inSection: true };
84
+ if (/^\[/.test(line.trim())) return { ...acc, inSection: false };
85
+ if (acc.inSection) {
86
+ const m = line.match(/name\s*=\s*"([^"]+)"/);
87
+ if (m) return { ...acc, name: m[1] };
88
+ }
89
+ return acc;
90
+ },
91
+ { inSection: false, name: undefined as string | undefined },
92
+ );
93
+ result = inProject.name?.trim() || undefined;
94
+ logDebug("detectProjectName step5 pyproject.toml", { rootPath, result });
95
+ } catch {}
96
+ }
97
+
98
+ if (!result) {
99
+ try {
100
+ const composer = await readFile(`${rootPath}/composer.json`, "utf-8");
101
+ const nameMatch = composer.match(/"name"\s*:\s*"([^"]+)"/);
102
+ if (nameMatch) result = nameMatch[1].trim() || undefined;
103
+ logDebug("detectProjectName step6 composer.json", { rootPath, result });
104
+ } catch {}
105
+ }
106
+
107
+ if (!result) {
108
+ result = rootPath.split("/").pop() || rootPath.split("\\").pop() || undefined;
109
+ logDebug("detectProjectName step7 fallback dirname", { rootPath, result });
110
+ }
111
+
112
+ if (result) {
113
+ result = result.trim() || undefined;
114
+ }
115
+
116
+ if (result) {
117
+ projectNameCache.set(rootPath, result);
118
+ }
119
+ return result;
120
+ }
121
+
122
+ export function showToast(tui: any, title: string, message: string, variant: string = "info", delayMs?: number) {
123
+ const defaultDelay = 1000;
124
+ const effectiveDelay = delayMs ?? defaultDelay;
125
+ setTimeout(async () => {
126
+ if (!tui?.showToast) {
127
+ logInfo("showToast: tui.showToast unavailable after delay", { delay: effectiveDelay, title });
128
+ return;
129
+ }
130
+ try {
131
+ await tui.showToast({ body: { title, message, variant, duration: 5000 } });
132
+ logInfo("showToast: success", { title });
133
+ } catch (err) {
134
+ logErr("showToast failed", { error: String(err), title });
135
+ }
136
+ }, effectiveDelay);
137
+ }
138
+
139
+ export function createToast(config: Partial<CerebroPluginConfig>) {
140
+ const defaultDelay = config.ui?.toastDelayMs ?? DEFAULTS.ui.toastDelayMs;
141
+ return (tui: any, title: string, message: string, variant: string = "info", delayMs?: number) => {
142
+ showToast(tui, title, message, variant, delayMs ?? defaultDelay);
143
+ };
144
+ }
145
+
146
+ const SYSTEM_INJECTION_PATTERNS: RegExp[] = [
147
+ /<!--\s*OMO_INTERNAL_INITIATOR\s*-->/,
148
+ /^\[SYSTEM DIRECTIVE:/,
149
+ /^\[restore checkpointed/,
150
+ /^\[session recovered/,
151
+ /^<system-reminder>/,
152
+ /^<EXTREMELY_IMPORTANT>/,
153
+ /^\[CONTEXT\]/,
154
+ /^\[GOAL\]/,
155
+ /^## ไปปๅŠก[๏ผš:]/,
156
+ /^## ๆ”นๅŠจ/,
157
+ /^Analyze the attached file/,
158
+ /^Provide ONLY the extracted/,
159
+ /^Called the Read tool/,
160
+ /^MANDATORY delegate_task/,
161
+ /^[โ–ฃโ–ช]\s*DCP/,
162
+ ];
163
+
164
+ const MODE_TAG_PATTERN = /^\[(?:search-mode|analyze-mode)\][\s\S]*?\n---\n?/;
165
+ const MODE_TAG_LINE = /^\[(?:search-mode|analyze-mode)\]\s*\n/;
166
+
167
+ function extractUserRequest(content: string): string {
168
+ const match = content.match(/<user-request>([\s\S]*?)<\/user-request>/);
169
+ let text = match ? match[1].trim() : content;
170
+
171
+ // [search-mode] / [analyze-mode]: ๅ‰ฅ็ฆปๆ ‡็ญพ+็ณป็ปŸๆŒ‡ไปค+ๅˆ†้š”็บฟ๏ผŒไฟ็•™็”จๆˆทๅฎž้™…ๅ†…ๅฎน
172
+ const stripped = text.replace(MODE_TAG_PATTERN, "");
173
+ if (stripped !== text && stripped.trim()) {
174
+ text = stripped.trim();
175
+ } else {
176
+ text = text.replace(MODE_TAG_LINE, "").trim();
177
+ }
178
+
179
+ for (const pattern of SYSTEM_INJECTION_PATTERNS) {
180
+ if (pattern.test(text)) return "";
181
+ }
182
+
183
+ return text;
184
+ }
185
+
186
+ export const saveKeywordDetectedSessions = new Set<string>();
187
+ export const firstMessages = new Map<string, string>();
188
+ export const sessionMessages = new Map<string, Array<{ role: string; content: string }>>();
189
+ export const profileInjectedSessions = new Map<string, number>();
190
+ export const lastProfileBlock = new Map<string, { content: string; count: number }>();
191
+ const lastUserMsgCount = new Map<string, number>();
192
+ const summarizedSessions = new Set<string>();
193
+
194
+ function formatRelativeAge(isoDate: string): string {
195
+ const diffMs = Date.now() - new Date(isoDate).getTime();
196
+ const minutes = Math.floor(diffMs / 60_000);
197
+ if (minutes < 60) return `${minutes}m ago`;
198
+ const hours = Math.floor(minutes / 60);
199
+ if (hours < 24) return `${hours}h ago`;
200
+ const days = Math.floor(hours / 24);
201
+ if (days < 30) return `${days}d ago`;
202
+ const months = Math.floor(days / 30);
203
+ return `${months}mo ago`;
204
+ }
205
+
206
+ function truncate(text: string, maxLength: number): string {
207
+ if (text.length <= maxLength) return text;
208
+
209
+ // Sentence boundary characters: period, exclamation, question (Latin + CJK)
210
+ // Also treat newline as a boundary
211
+ const boundaries = /[.!?ใ€‚๏ผ๏ผŸ\n]/;
212
+
213
+ // Search backwards from maxLength for a boundary
214
+ const searchEnd = Math.min(maxLength, text.length);
215
+ for (let i = searchEnd - 1; i >= Math.floor(searchEnd * BOUNDARY_SEARCH_RATIO); i--) {
216
+ if (boundaries.test(text[i])) {
217
+ return text.slice(0, i + 1).trimEnd() + "โ€ฆ";
218
+ }
219
+ }
220
+
221
+ let truncated = text.slice(0, maxLength);
222
+ const lastCode = truncated.charCodeAt(truncated.length - 1);
223
+ if (lastCode >= 0xD800 && lastCode <= 0xDBFF) truncated = truncated.slice(0, -1);
224
+ return truncated + "โ€ฆ";
225
+ }
226
+
227
+ const FETCH_POLICY = [
228
+ "<cerebro-fetch-policy>",
229
+ "IMPORTANT: Each memory above is a condensed summary. The full version contains critical details that may change your response quality.",
230
+ "You MUST use memory_get(\"id\") to retrieve the complete content, or memory_search(\"query\") to find specific memories before making decisions based on any summary.",
231
+ "Do NOT rely on condensed summaries alone โ€” depth of recall determines quality of response.",
232
+ "</cerebro-fetch-policy>",
233
+ ].join("\n");
234
+
235
+ const MAX_INJECTION_CHARS_FALLBACK = DEFAULTS.content.maxContentChars;
236
+
237
+ interface InjectionResult {
238
+ text: string;
239
+ profileCount: number;
240
+ memoryCount: number;
241
+ globalCount: number;
242
+ projectMemoryCount: number;
243
+ maxScore: number;
244
+ confidence: number;
245
+ }
246
+
247
+ export async function buildMemoryInjection(
248
+ client: CerebroClient,
249
+ projectPath: string | undefined,
250
+ query: string,
251
+ config: Partial<CerebroPluginConfig>,
252
+ ): Promise<InjectionResult> {
253
+ const maxChars = config.content?.maxContentChars ?? MAX_INJECTION_CHARS_FALLBACK;
254
+ const ic = config.injection ?? DEFAULTS.injection;
255
+ const recentCount = ic.recentCount || DEFAULTS.injection.recentCount;
256
+ const searchCount = ic.searchCount || DEFAULTS.injection.searchCount;
257
+ const globalCount = ic.globalCount || DEFAULTS.injection.globalCount;
258
+ const recentTruncate = ic.recentTruncateChars || 0; // 0 = ไธๆˆชๆ–ญ
259
+ const searchTruncate = ic.searchTruncateChars || 0; // 0 = ไธๆˆชๆ–ญ
260
+ const profileTimeout = ic.profileTimeoutMs || DEFAULTS.injection.profileTimeoutMs;
261
+ const recentTimeout = ic.recentTimeoutMs || DEFAULTS.injection.recentTimeoutMs;
262
+ const searchTimeout = ic.searchTimeoutMs || DEFAULTS.injection.searchTimeoutMs;
263
+
264
+ // ๅ››่ทฏๅนถๅ‘๏ผšprofile + global๏ผˆไธ“ๅŒบๅ•ๅˆ—๏ผ‰+ recent + searchใ€‚
265
+ // ้กน็›ฎ recent/search ่ทฏๅธฆ exclude_global๏ผˆๆ‹ๆฟ issue #3๏ผšไธ“ๅŒบๅทฒๅ•ๅˆ—๏ผŒ้กน็›ฎ่ทฏไธๆททๅ…จๅฑ€๏ผ‰ใ€‚
266
+ const [profile, globalResults, projectMemories, searchResults] = await Promise.all([
267
+ Promise.race([
268
+ client.getInjection(),
269
+ new Promise<null>((resolve) => setTimeout(() => resolve(null), profileTimeout)),
270
+ ]).catch(() => null),
271
+ Promise.race([
272
+ client.searchMemories(query, globalCount, undefined, undefined, undefined, true),
273
+ new Promise<never[]>((resolve) => setTimeout(() => resolve([]), searchTimeout)),
274
+ ]).catch(() => []),
275
+ Promise.race([
276
+ client.listRecent(recentCount, projectPath, true),
277
+ new Promise<never[]>((resolve) => setTimeout(() => resolve([]), recentTimeout)),
278
+ ]).catch(() => []),
279
+ query
280
+ ? Promise.race([
281
+ client.searchMemories(query, searchCount, undefined, undefined, projectPath, undefined, true),
282
+ new Promise<never[]>((resolve) => setTimeout(() => resolve([]), searchTimeout)),
283
+ ]).catch(() => [])
284
+ : Promise.resolve([]),
285
+ ]);
286
+
287
+ const sections: string[] = ["[CEREBRO-MEMORY]", ""];
288
+
289
+ if (profile?.content) {
290
+ sections.push(profile.content);
291
+ sections.push("");
292
+ }
293
+
294
+ const seenIds = new Set<string>();
295
+
296
+ const dedupedGlobal = (globalResults || []).filter((r) => r.memory?.id && !seenIds.has(r.memory.id));
297
+ if (dedupedGlobal.length > 0) {
298
+ sections.push("## Global Memories");
299
+ for (const r of dedupedGlobal) {
300
+ seenIds.add(r.memory.id);
301
+ const age = formatRelativeAge(r.memory.created_at) || "unknown";
302
+ sections.push(`- (${age}) ${r.memory.content}`);
303
+ }
304
+ sections.push("");
305
+ }
306
+
307
+ if (projectMemories.length > 0) {
308
+ sections.push("## Recent Project Activity");
309
+ for (const m of projectMemories) {
310
+ seenIds.add(m.id);
311
+ const age = formatRelativeAge(m.updated_at || m.created_at) || "unknown";
312
+ const content = recentTruncate > 0 ? truncate(m.content, recentTruncate) : m.content;
313
+ sections.push(`- (${age}) ${content}`);
314
+ }
315
+ sections.push("");
316
+ }
317
+
318
+ const dedupedResults = (searchResults || []).filter((r) => !seenIds.has(r.memory.id));
319
+ if (dedupedResults.length > 0) {
320
+ sections.push("## Relevant Memories");
321
+ for (const r of dedupedResults) {
322
+ const age = formatRelativeAge(r.memory.created_at) || "unknown";
323
+ const content = searchTruncate > 0 ? truncate(r.memory.content, searchTruncate) : r.memory.content;
324
+ sections.push(`- (${age}) ${content}`);
325
+ }
326
+ sections.push("");
327
+ }
328
+
329
+ sections.push("[/CEREBRO-MEMORY]");
330
+
331
+ let text = sections.join("\n");
332
+ if (text.length > maxChars) {
333
+ const cutoff = text.lastIndexOf('\n', maxChars);
334
+ text = text.slice(0, cutoff > 0 ? cutoff : maxChars) + "\nโ€ฆ\n[/CEREBRO-MEMORY]";
335
+ }
336
+
337
+ const maxScore = searchResults.reduce((max, r) => Math.max(max, r.score), 0);
338
+ const confidence = Math.min(maxScore, 1.0);
339
+
340
+ return {
341
+ text,
342
+ profileCount: profile?.preference_count ?? 0,
343
+ memoryCount: dedupedResults?.length ?? 0,
344
+ globalCount: dedupedGlobal.length,
345
+ projectMemoryCount: projectMemories.length,
346
+ maxScore,
347
+ confidence,
348
+ };
349
+ }
350
+
351
+ const injectedSessions = new Set<string>();
352
+
353
+ export function chatMessageRecallHook(
354
+ client: CerebroClient,
355
+ _containerTags: string[],
356
+ tui: any,
357
+ config: Partial<CerebroPluginConfig> = {},
358
+ getAgentName?: () => string,
359
+ directory?: string,
360
+ ) {
361
+ return async (
362
+ input: { sessionID: string; messageID?: string },
363
+ output: { message: UserMessage; parts: Part[] },
364
+ ) => {
365
+ if (!input.sessionID) return;
366
+ if (injectedSessions.has(input.sessionID)) return;
367
+
368
+ const agentId = getAgentName?.() || process.env.OMEM_AGENT_ID || "opencode";
369
+ const policy = resolveAgentPolicy(agentId, config);
370
+ if (policy === "none") {
371
+ injectedSessions.add(input.sessionID);
372
+ return;
373
+ }
374
+
375
+ const textContent = output.parts
376
+ .filter((p: any) => p.type === "text")
377
+ .map((p: any) => p.text || (p as any).content || "")
378
+ .join(" ")
379
+ || (output.message as any).content
380
+ || "";
381
+
382
+ const query = extractUserRequest(textContent);
383
+
384
+ const TRIVIAL_PATTERNS = /^(hi|hello|hey|ไฝ ๅฅฝ|ๅ—จ|ๅ—ฏ|ok|okay|ๅฅฝ็š„|ๆ”ถๅˆฐ|\s*)$/i;
385
+ if (!query || TRIVIAL_PATTERNS.test(query.trim())) {
386
+ logDebug("chatMessageRecallHook: trivial query, will retry next turn", { sessionId: input.sessionID });
387
+ return;
388
+ }
389
+
390
+ try {
391
+ const injection = await buildMemoryInjection(client, directory, query, config);
392
+
393
+ const hasContent = (injection.profileCount ?? 0) > 0
394
+ || (injection.memoryCount ?? 0) > 0
395
+ || (injection.globalCount ?? 0) > 0
396
+ || (injection.projectMemoryCount ?? 0) > 0;
397
+
398
+ if (injection.text && hasContent && injection.text.length > 20) {
399
+ injectedSessions.add(input.sessionID);
400
+
401
+ output.parts.unshift({
402
+ id: `prt_cerebro-inject-${Date.now()}`,
403
+ sessionID: input.sessionID,
404
+ messageID: output.message?.id,
405
+ type: "text",
406
+ text: injection.text,
407
+ synthetic: true,
408
+ } as any);
409
+
410
+ showToast(tui, "๐Ÿง  Memory Injected",
411
+ `${injection.profileCount} prefs ยท ${injection.globalCount} global ยท ${injection.projectMemoryCount} project ยท ${injection.memoryCount} relevant`,
412
+ "success");
413
+
414
+ client.createRecallEvent({
415
+ session_id: input.sessionID,
416
+ recall_type: "auto",
417
+ query_text: query,
418
+ max_score: injection.maxScore,
419
+ llm_confidence: injection.confidence,
420
+ profile_injected: injection.profileCount > 0,
421
+ kept_count: injection.globalCount + injection.projectMemoryCount + injection.memoryCount,
422
+ discarded_count: 0,
423
+ injected_count: injection.globalCount + injection.projectMemoryCount + injection.memoryCount,
424
+ injected_content: injection.text,
425
+ }).catch((e: unknown) => {
426
+ logErr("chatMessageRecallHook createRecallEvent failed", { error: String(e) });
427
+ });
428
+ } else if (!hasContent) {
429
+ logDebug("chatMessageRecallHook: no content available, will retry next turn", {
430
+ sessionId: input.sessionID,
431
+ profileCount: injection.profileCount,
432
+ memoryCount: injection.memoryCount,
433
+ projectMemoryCount: injection.projectMemoryCount,
434
+ });
435
+ showToast(tui, "๐Ÿง  Memory Unavailable", "API timeout or no memories yet", "warning");
436
+ }
437
+ } catch (err) {
438
+ logErr("chatMessageRecallHook failed", { error: String(err) });
439
+ showToast(tui, "๐Ÿง  Memory Injection Failed", "Check connection", "error");
440
+ }
441
+ };
442
+ }
443
+
444
+ /**
445
+ * System transform hook โ€” injects current system time + memory_search reminder
446
+ * into output.system[] on every LLM request. Forces AI to perceive time and
447
+ * recall memories, addressing "time loss" and "forgot to search memory" issues.
448
+ *
449
+ * Factory pattern matching chatMessageRecallHook. State-less (fires per turn).
450
+ */
451
+ export function timeMemorySystemHook() {
452
+ return async (
453
+ _input: unknown,
454
+ output: { system?: string[] },
455
+ ) => {
456
+ if (!output || !Array.isArray(output.system)) return;
457
+
458
+ let timeStr = "";
459
+ try {
460
+ timeStr = execSync("date '+%Y-%m-%d %H:%M:%S %A'", {
461
+ encoding: "utf-8",
462
+ timeout: 2000,
463
+ }).trim();
464
+ } catch (err) {
465
+ logErr("timeMemorySystemHook: date command failed", { error: String(err) });
466
+ timeStr = new Date().toISOString().replace("T", " ").slice(0, 19);
467
+ }
468
+
469
+ output.system.push(`[CEREBRO-TIME] ${timeStr}`);
470
+ output.system.push(
471
+ '[CEREBRO-TIME-MEANING] Timestamps above = real moments passed. Feel gaps between turns. Silence โ‰  instant reply. Time shapes conversation. Let it shape yours.',
472
+ );
473
+ };
474
+ }
475
+
476
+ export function createCerebroCompactionPrompt(
477
+ context: string[],
478
+ projectMemories: SearchResult[],
479
+ ): string {
480
+ const sections: string[] = [
481
+ "[Cerebro Compaction Context]",
482
+ "",
483
+ "## 1. User's Original Request",
484
+ "Preserve the user's verbatim original request from the conversation above.",
485
+ "",
486
+ "## 2. Final Goal",
487
+ "What is the ultimate objective the user wants to achieve?",
488
+ "",
489
+ "## 3. Work Completed",
490
+ "List all completed work with file paths and technical decisions made.",
491
+ "",
492
+ "## 4. Remaining Tasks",
493
+ "What is still unfinished or pending?",
494
+ "",
495
+ "## 5. Prohibited Actions",
496
+ "Key constraints and forbidden operations to remember.",
497
+ "",
498
+ "## 6. Existing Project Knowledge",
499
+ ];
500
+
501
+ if (projectMemories.length > 0) {
502
+ const memBlock = projectMemories
503
+ .slice(0, 10)
504
+ .map((r) => {
505
+ const content = r.memory.content ?? "";
506
+ const truncated = content.length > 200 ? content.slice(0, 200) + "..." : content;
507
+ return ` - [${r.memory.category ?? "general"}] ${truncated}`;
508
+ })
509
+ .join("\n");
510
+ sections.push(memBlock);
511
+ } else {
512
+ sections.push(" (No project memories retrieved)");
513
+ }
514
+
515
+ if (context.length > 0) {
516
+ sections.push("");
517
+ sections.push("### Additional Context");
518
+ sections.push(...context);
519
+ }
520
+
521
+ sections.push("");
522
+ sections.push("IMPORTANT: Output must preserve the user's original language (Chinese/English/etc). Do not translate.");
523
+
524
+ return sections.join("\n");
525
+ }
526
+
527
+ export function compactingHook(client: CerebroClient, containerTags: string[], tui: any, ingestMode: "smart" | "raw" = "smart", isAutoStoreEnabled?: (sessionId: string | undefined) => boolean, getMainSessionId?: () => string | undefined, sdkClient?: any, config: Partial<CerebroPluginConfig> = {}, agentId?: string, directory?: string) {
528
+ const effectiveAgentId = agentId || process.env.OMEM_AGENT_ID || "opencode";
529
+ return async (
530
+ input: { sessionID?: string },
531
+ output: { context: string[]; prompt?: string },
532
+ ) => {
533
+ logInfo("compactingHook triggered", { sessionId: input.sessionID, hasSessionMessages: sessionMessages.has(input.sessionID || "") });
534
+
535
+ // Search (read) always runs โ€” even readonly agents need context during compacting
536
+ try {
537
+ const results = await client.searchMemories("*", 20, undefined, containerTags);
538
+ const compactionPrompt = createCerebroCompactionPrompt(output.context, results);
539
+ if (output.prompt !== undefined) {
540
+ output.prompt = compactionPrompt;
541
+ } else if (output.context.length > 0) {
542
+ output.context[output.context.length - 1] += "\n\n" + compactionPrompt;
543
+ } else {
544
+ output.context.push(compactionPrompt);
545
+ }
546
+ if (output.context.length > 0) {
547
+ output.context[output.context.length - 1] += "\n\n" + FETCH_POLICY;
548
+ } else {
549
+ output.context.push(FETCH_POLICY);
550
+ }
551
+ } catch {
552
+ }
553
+
554
+ // Main session gate: sub-agents must not write memories via compacting
555
+ if (getMainSessionId) {
556
+ const mainId = getMainSessionId();
557
+ if (mainId && input.sessionID && input.sessionID !== mainId) {
558
+ logInfo("compactingHook: non-main session skipped", { sessionID: input.sessionID, mainSessionId: mainId });
559
+ return;
560
+ }
561
+ }
562
+
563
+ // Policy gate: only readwrite agents can write memories
564
+ const policy = resolveAgentPolicy(effectiveAgentId, config);
565
+ if (policy !== "readwrite") {
566
+ logInfo("compactingHook blocked by policy", { agentId: effectiveAgentId, policy });
567
+ if (input.sessionID) {
568
+ sessionMessages.delete(input.sessionID);
569
+ profileInjectedSessions.delete(input.sessionID);
570
+ lastUserMsgCount.delete(input.sessionID);
571
+ firstMessages.delete(input.sessionID);
572
+ }
573
+ return;
574
+ }
575
+
576
+ const effectiveSessionId = sanitizeSessionId(getMainSessionId?.() || input.sessionID);
577
+
578
+ // Resolve project name (shared by ingest + poll)
579
+ let projectName: string | undefined;
580
+ let projectPath: string | undefined;
581
+ try {
582
+ if (sdkClient && input.sessionID) {
583
+ const sessionInfo = await sdkClient.session.get({ path: { id: input.sessionID } });
584
+ logDebug("compactingHook project.rootPath", { rootPath: sessionInfo?.data?.directory });
585
+ projectPath = directory || sessionInfo?.data?.directory || process.env.OMEM_PROJECT_DIR;
586
+ projectName = sessionInfo?.data?.directory
587
+ ? await detectProjectName(sessionInfo.data.directory)
588
+ : undefined;
589
+ }
590
+ } catch (e) {
591
+ logErr("compactingHook detectProjectName failed", { error: String(e) });
592
+ }
593
+ if (!projectPath) {
594
+ projectPath = directory || process.env.OMEM_PROJECT_DIR;
595
+ }
596
+
597
+ // --- Phase 1: Ingest tracked messages from sessionMessages (if available) ---
598
+ if (input.sessionID && sessionMessages.has(input.sessionID)) {
599
+ if (isAutoStoreEnabled && !isAutoStoreEnabled(input.sessionID)) {
600
+ sessionMessages.delete(input.sessionID);
601
+ profileInjectedSessions.delete(input.sessionID);
602
+ lastUserMsgCount.delete(input.sessionID);
603
+ firstMessages.delete(input.sessionID);
604
+ } else {
605
+ const messages = sessionMessages.get(input.sessionID)!;
606
+ if (messages.length > 0) {
607
+ try {
608
+ logInfo("compactingHook ingestMessages called", { msgCount: messages.length, sessionId: effectiveSessionId, agentId: effectiveAgentId });
609
+ const result = await client.ingestMessages(messages, {
610
+ mode: ingestMode,
611
+ tags: [...containerTags, "auto-capture"],
612
+ sessionId: effectiveSessionId,
613
+ projectName: projectName,
614
+ agentId: effectiveAgentId,
615
+ projectPath,
616
+ });
617
+ logInfo("compactingHook ingestMessages result", { result: result === null ? "null(blocked)" : "ok" });
618
+ if (result === null) {
619
+ showToast(tui, "๐Ÿ”ด Archive Failed", "Session archive blocked ยท check spiritual realm status", "error");
620
+ } else {
621
+ showToast(tui, "๐Ÿ“ฆ Session Archived", `${messages.length} residual dialogues archived ยท merged into the realm`, "success");
622
+ }
623
+ } catch (e) {
624
+ logErr("compactingHook ingestMessages failed", { error: String(e) });
625
+ showToast(tui, "๐Ÿ”ด Archive Failed", "Session archive blocked ยท spiritual pulse anomaly", "error");
626
+ }
627
+ }
628
+ }
629
+ // Cleanup tracked messages regardless of ingest result
630
+ sessionMessages.delete(input.sessionID);
631
+ profileInjectedSessions.delete(input.sessionID);
632
+ lastUserMsgCount.delete(input.sessionID);
633
+ firstMessages.delete(input.sessionID);
634
+ processedMessageIds.delete(input.sessionID);
635
+ injectedSessions.delete(input.sessionID);
636
+ if (input.sessionID) {
637
+ logDebug("compactingHook cleared session state", { sessionID: input.sessionID });
638
+ }
639
+ }
640
+
641
+ // After compacting, clear profile TTL so next autoRecallHook re-injects profile
642
+ if (input.sessionID) {
643
+ profileInjectedSessions.delete(input.sessionID);
644
+ lastUserMsgCount.delete(input.sessionID);
645
+ processedMessageIds.delete(input.sessionID);
646
+ injectedSessions.delete(input.sessionID);
647
+ logDebug("compactingHook cleared profile TTL for re-injection", { sessionID: input.sessionID });
648
+ }
649
+ };
650
+ }
651
+
652
+ export function autocontinueHook(
653
+ client: CerebroClient,
654
+ containerTags: string[],
655
+ tui: any,
656
+ ingestMode: "smart" | "raw" = "smart",
657
+ isAutoStoreEnabled?: (sessionId: string | undefined) => boolean,
658
+ getMainSessionId?: () => string | undefined,
659
+ sdkClient?: any,
660
+ config: Partial<CerebroPluginConfig> = {},
661
+ agentId?: string,
662
+ directory?: string,
663
+ ) {
664
+ const effectiveAgentId = agentId || process.env.OMEM_AGENT_ID || "opencode";
665
+ return async (
666
+ input: {
667
+ sessionID: string;
668
+ agent: string;
669
+ model: Model;
670
+ message: UserMessage;
671
+ overflow: boolean;
672
+ },
673
+ _output: { enabled: boolean },
674
+ ) => {
675
+ try {
676
+ const policy = resolveAgentPolicy(effectiveAgentId, config);
677
+ if (policy !== "readwrite") {
678
+ logInfo("autocontinueHook blocked by policy", { agentId: effectiveAgentId, policy });
679
+ return;
680
+ }
681
+
682
+ if (isAutoStoreEnabled && !isAutoStoreEnabled(input.sessionID)) {
683
+ logInfo("autocontinueHook skipped: auto-store disabled", { sessionId: input.sessionID });
684
+ return;
685
+ }
686
+
687
+ const effectiveSessionId = sanitizeSessionId(getMainSessionId?.() || input.sessionID);
688
+
689
+ if (!sdkClient) {
690
+ logInfo("autocontinueHook skipped: no sdkClient", { sessionId: input.sessionID });
691
+ return;
692
+ }
693
+
694
+ let summaryText: string | undefined;
695
+ try {
696
+ const response = await sdkClient.session.messages({ path: { id: input.sessionID } });
697
+ if (response?.data) {
698
+ let targetMsg = response.data.find(
699
+ (msg: any) => msg.info?.id === input.message.id,
700
+ );
701
+
702
+ if (!targetMsg?.parts) {
703
+ targetMsg = response.data.find(
704
+ (msg: any) => msg.info?.role === "assistant" && msg.info?.summary === true,
705
+ );
706
+ }
707
+
708
+ if (!targetMsg?.parts) {
709
+ const assistants = response.data.filter((msg: any) => msg.info?.role === "assistant");
710
+ if (assistants.length > 0) targetMsg = assistants[assistants.length - 1];
711
+ }
712
+
713
+ if (targetMsg?.parts) {
714
+ const textParts = (targetMsg.parts as any[])
715
+ .filter((p: any) => p.type === "text" && p.text)
716
+ .map((p: any) => p.text);
717
+ summaryText = textParts.join("\n").trim();
718
+ }
719
+ }
720
+ } catch (e) {
721
+ logErr("autocontinueHook failed to fetch message parts", { error: String(e) });
722
+ }
723
+
724
+ if (!summaryText || summaryText.length < 30) {
725
+ logInfo("autocontinueHook skipped: summary too short", { sessionId: input.sessionID, messageId: input.message.id, summaryLen: summaryText?.length ?? 0 });
726
+ return;
727
+ }
728
+
729
+ let projectName: string | undefined;
730
+ let projectPath: string | undefined;
731
+ try {
732
+ const sessionInfo = await sdkClient.session.get({ path: { id: input.sessionID } });
733
+ projectPath = directory || sessionInfo?.data?.directory || process.env.OMEM_PROJECT_DIR;
734
+ projectName = sessionInfo?.data?.directory
735
+ ? await detectProjectName(sessionInfo.data.directory)
736
+ : undefined;
737
+ } catch (e) {
738
+ logErr("autocontinueHook detectProjectName failed", { error: String(e) });
739
+ }
740
+ if (!projectPath) {
741
+ projectPath = directory || process.env.OMEM_PROJECT_DIR;
742
+ }
743
+
744
+ const messages = [{ role: "user" as const, content: summaryText }];
745
+ logInfo("autocontinueHook storing compact summary", {
746
+ summaryLen: summaryText.length,
747
+ sessionId: effectiveSessionId,
748
+ agentId: effectiveAgentId,
749
+ overflow: input.overflow,
750
+ projectName,
751
+ });
752
+
753
+ const result = await client.ingestMessages(messages, {
754
+ mode: ingestMode,
755
+ tags: [...containerTags, "auto-capture", "compact-summary"],
756
+ sessionId: effectiveSessionId,
757
+ projectName: projectName,
758
+ agentId: effectiveAgentId,
759
+ projectPath,
760
+ });
761
+
762
+ logInfo("autocontinueHook store result", { result: result === null ? "null(blocked)" : "ok" });
763
+ if (result === null) {
764
+ showToast(tui, "๐Ÿ”ด Compact Summary Failed", "Storage blocked ยท check server status", "error");
765
+ } else {
766
+ showToast(tui, "๐Ÿ“ฆ Compact Summary Stored", "Session summary archived to memory", "success");
767
+ }
768
+ } catch (e) {
769
+ logErr("autocontinueHook failed", { error: String(e) });
770
+ }
771
+ };
772
+ }
773
+
774
+ const processedMessageIds = new Map<string, Set<string>>();
775
+ const pluginStartTime = Date.now();
776
+
777
+ export function sessionIdleHook(
778
+ cerebroClient: CerebroClient,
779
+ containerTags: string[],
780
+ tui: any,
781
+ sdkClient: any,
782
+ ingestMode: "smart" | "raw" = "smart",
783
+ threshold: number = 0,
784
+ getMainSessionId?: () => string | undefined,
785
+ isAutoStoreEnabled?: (sessionId: string | undefined) => boolean,
786
+ agentId?: string,
787
+ config: Partial<CerebroPluginConfig> = {},
788
+ onAgentResolved?: (name: string) => void,
789
+ directory?: string,
790
+ ) {
791
+ let idleTimeout: ReturnType<typeof setTimeout> | null = null;
792
+ let isCapturing = false;
793
+
794
+ async function handleSummaryCapture(props: any) {
795
+ const info = props?.info;
796
+ if (!info) return;
797
+ if (info.role !== "assistant") return;
798
+ // info.summary may be missing in some SDK versions โ€” handle below
799
+ // info.finish check: only process on finish, but allow missing field
800
+ if (info.finish === false) return;
801
+
802
+ const sessionID = sanitizeSessionId(info.sessionID);
803
+ if (!sessionID) return;
804
+
805
+ logInfo("handleSummaryCapture checking", {
806
+ sessionID,
807
+ role: info?.role,
808
+ hasSummary: !!info?.summary,
809
+ finish: info?.finish,
810
+ });
811
+
812
+ if (summarizedSessions.has(sessionID)) return;
813
+ summarizedSessions.add(sessionID);
814
+
815
+ if (!sdkClient) {
816
+ logInfo("handleSummaryCapture skipped: no sdkClient", { sessionID });
817
+ return;
818
+ }
819
+
820
+ logInfo("handleSummaryCapture triggered", { sessionID });
821
+
822
+ if (getMainSessionId) {
823
+ const mainId = getMainSessionId();
824
+ if (mainId && sessionID !== mainId) {
825
+ logInfo("handleSummaryCapture: non-main session skipped", { sessionID, mainSessionId: mainId });
826
+ return;
827
+ }
828
+ }
829
+
830
+ const effectiveAgentId = agentId || process.env.OMEM_AGENT_ID || "opencode";
831
+ const policy = resolveAgentPolicy(effectiveAgentId, config);
832
+ if (policy !== "readwrite") {
833
+ logInfo("handleSummaryCapture blocked by policy", { agentId: effectiveAgentId, policy });
834
+ return;
835
+ }
836
+
837
+ if (isAutoStoreEnabled && !isAutoStoreEnabled(sessionID)) return;
838
+
839
+ try {
840
+ const resp = await sdkClient.session.messages({ path: { id: sessionID } });
841
+ const messages = resp?.data ?? resp;
842
+
843
+ let summaryMsg = (messages as Array<{ info: any; parts?: Array<{ type: string; text?: string }> }>).find((m) =>
844
+ m.info?.role === "assistant" && m.info?.summary === true
845
+ );
846
+
847
+ if (!summaryMsg?.parts) {
848
+ logInfo("handleSummaryCapture: no summary-flagged message, trying last assistant message", { sessionID });
849
+ const assistantMsgs = (messages as Array<{ info: any; parts?: Array<{ type: string; text?: string }> }>)
850
+ .filter(m => m.info?.role === "assistant");
851
+ summaryMsg = assistantMsgs.length > 0 ? assistantMsgs[assistantMsgs.length - 1] : undefined;
852
+ }
853
+
854
+ if (!summaryMsg?.parts) {
855
+ logInfo("handleSummaryCapture: no assistant message parts found", { sessionID });
856
+ return;
857
+ }
858
+
859
+ const textParts = summaryMsg.parts.filter((p) => p.type === "text" && p.text).map((p) => p.text);
860
+ const summaryContent = textParts.join("\n").trim();
861
+
862
+ if (!summaryContent || summaryContent.length < 30) {
863
+ logInfo("handleSummaryCapture: summary too short", { sessionID, length: summaryContent?.length ?? 0 });
864
+ return;
865
+ }
866
+
867
+ const effectiveSessionId = sanitizeSessionId(getMainSessionId?.() || sessionID);
868
+
869
+ let projectName: string | undefined;
870
+ let projectPath: string | undefined;
871
+ try {
872
+ const sessionInfo = await sdkClient.session.get({ path: { id: sessionID } });
873
+ projectPath = directory || sessionInfo?.data?.directory || process.env.OMEM_PROJECT_DIR;
874
+ projectName = sessionInfo?.data?.directory
875
+ ? await detectProjectName(sessionInfo.data.directory)
876
+ : undefined;
877
+ } catch (e) {
878
+ logErr("handleSummaryCapture detectProjectName failed", { error: String(e) });
879
+ }
880
+ if (!projectPath) {
881
+ projectPath = directory || process.env.OMEM_PROJECT_DIR;
882
+ }
883
+
884
+ const prefixedSummary = `[Session Summary] ${summaryContent}`;
885
+ const result = await cerebroClient.ingestMessages(
886
+ [{ role: "user" as const, content: prefixedSummary }],
887
+ {
888
+ mode: ingestMode,
889
+ tags: [...containerTags, "auto-capture", "compact-summary"],
890
+ sessionId: effectiveSessionId,
891
+ projectName,
892
+ agentId: effectiveAgentId,
893
+ projectPath,
894
+ },
895
+ );
896
+
897
+ logInfo("handleSummaryCapture store result", { result: result === null ? "null(blocked)" : "ok" });
898
+ if (result !== null) {
899
+ showToast(tui, "๐Ÿ“ฆ Compact Summary Stored", "Session summary archived", "success");
900
+ }
901
+ } catch (err) {
902
+ logErr("handleSummaryCapture failed", { error: String(err) });
903
+ }
904
+ }
905
+
906
+ return async (input: { event: { type: string; properties?: any } }) => {
907
+ if (input.event.type === "message.updated") {
908
+ await handleSummaryCapture(input.event.properties);
909
+ return;
910
+ }
911
+
912
+ if (input.event.type === "session.deleted") {
913
+ const sessionInfo = input.event.properties?.info;
914
+ const sid = sessionInfo?.id;
915
+ if (sid) {
916
+ summarizedSessions.delete(sid);
917
+ sessionMessages.delete(sid);
918
+ profileInjectedSessions.delete(sid);
919
+ lastUserMsgCount.delete(sid);
920
+ firstMessages.delete(sid);
921
+ logDebug("sessionIdleHook: session.deleted cleanup", { sessionID: sid });
922
+ }
923
+ return;
924
+ }
925
+
926
+ if (input.event.type !== "session.idle") return;
927
+
928
+ logDebug("sessionIdleHook event.properties dump", { keys: Object.keys(input.event.properties || {}), raw: JSON.stringify(input.event.properties).substring(0, 2000) });
929
+
930
+ const sessionID = sanitizeSessionId(input.event.properties?.sessionID);
931
+ if (!sessionID) return;
932
+
933
+ if (isAutoStoreEnabled && !isAutoStoreEnabled(sessionID)) return;
934
+
935
+ if (getMainSessionId) {
936
+ const mainId = getMainSessionId();
937
+ if (mainId && sessionID !== mainId) {
938
+ logInfo("sessionIdleHook: non-main session skipped", { sessionID, mainSessionId: mainId });
939
+ return;
940
+ }
941
+ }
942
+
943
+ if (idleTimeout) clearTimeout(idleTimeout);
944
+
945
+ idleTimeout = setTimeout(async () => {
946
+ if (isCapturing) return;
947
+ isCapturing = true;
948
+
949
+ try {
950
+ const response = await sdkClient.session.messages({ path: { id: sessionID } });
951
+ if (!response?.data) return;
952
+
953
+ const messages = response.data;
954
+ const conversationMessages: Array<{ role: string; content: string }> = [];
955
+ const newMessageIds: string[] = [];
956
+ let hasNewMessages = false;
957
+
958
+ for (const msg of messages) {
959
+ const msgId = msg.info?.id;
960
+ if (!msgId) continue;
961
+ if (!processedMessageIds.has(sessionID)) {
962
+ processedMessageIds.set(sessionID, new Set());
963
+ }
964
+ if (processedMessageIds.get(sessionID)!.has(msgId)) continue;
965
+
966
+ const msgTime = msg.info?.createdAt ? new Date(msg.info.createdAt).getTime() : 0;
967
+ if (msgTime > 0 && msgTime < pluginStartTime) continue;
968
+
969
+ const role = msg.info?.role;
970
+ if (role !== "user" && role !== "assistant") continue;
971
+
972
+ const textParts = (msg.parts || [])
973
+ .filter((p: any) => p.type === "text" && p.text)
974
+ .map((p: any) => p.text);
975
+ const text = textParts.join("\n").trim();
976
+ if (!text) continue;
977
+
978
+ hasNewMessages = true;
979
+ newMessageIds.push(msgId);
980
+ conversationMessages.push({ role, content: text });
981
+ }
982
+
983
+ if (!hasNewMessages || conversationMessages.length === 0) return;
984
+
985
+ if (threshold > 1 && conversationMessages.length < threshold) {
986
+ return;
987
+ }
988
+
989
+ let sessionTitle: string | undefined;
990
+ let projectName: string | undefined;
991
+ let projectPath: string | undefined;
992
+ let effectiveAgentId = agentId || "opencode";
993
+ try {
994
+ const sessionInfo = await sdkClient.session.get({ path: { id: sessionID } });
995
+ if ((sessionInfo?.data as any)?.agent) {
996
+ effectiveAgentId = (sessionInfo.data as any).agent;
997
+ onAgentResolved?.(effectiveAgentId);
998
+ }
999
+ sessionTitle = sessionInfo?.data?.title;
1000
+ projectPath = directory || sessionInfo?.data?.directory || process.env.OMEM_PROJECT_DIR;
1001
+ projectName = sessionInfo?.data?.directory
1002
+ ? await detectProjectName(sessionInfo.data.directory)
1003
+ : undefined;
1004
+ } catch (e) {
1005
+ logErr("sessionIdleHook detectProjectName failed", { error: String(e) });
1006
+ }
1007
+ if (!projectPath) {
1008
+ projectPath = directory || process.env.OMEM_PROJECT_DIR;
1009
+ }
1010
+
1011
+ logDebug("sessionIdleHook resolved agentId", { effectiveAgentId, fallbackAgentId: agentId });
1012
+
1013
+ const policy = resolveAgentPolicy(effectiveAgentId, config);
1014
+ if (policy !== "readwrite") {
1015
+ logInfo("sessionIdleHook blocked by policy", { agentId: effectiveAgentId, policy, defaultPolicy: String(config.defaultPolicy ?? "undefined") });
1016
+ return;
1017
+ }
1018
+
1019
+ try {
1020
+ logInfo("sessionIdleHook sessionIngest called", { msgCount: conversationMessages.length, sessionId: sessionID, agentId: effectiveAgentId, title: String(sessionTitle) });
1021
+ await cerebroClient.sessionIngest(conversationMessages, sessionID, effectiveAgentId, sessionTitle, projectName, projectPath);
1022
+ logInfo("sessionIdleHook sessionIngest ok");
1023
+ for (const id of newMessageIds) {
1024
+ processedMessageIds.get(sessionID)!.add(id);
1025
+ }
1026
+ showToast(tui, "๐Ÿง  Memory Sealed", `${conversationMessages.length} dialogues captured ยท entrusted to the heavens for refinement`, "success");
1027
+ } catch (err) {
1028
+ logErr("sessionIdleHook sessionIngest failed", { error: String(err) });
1029
+ showToast(tui, "๐Ÿ”ด Session Capture Failed", String(err).substring(0, 100), "error");
1030
+ }
1031
+ } catch (err) {
1032
+ const errMsg = err instanceof Error ? err.message : String(err);
1033
+ showToast(tui, "๐Ÿ”ด Idle Capture Error", errMsg.substring(0, 100), "error");
1034
+ } finally {
1035
+ isCapturing = false;
1036
+ idleTimeout = null;
1037
+ }
1038
+ }, 10000);
1039
+ };
1040
+ }