@mingxy/cerebro 1.15.13 → 1.16.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,5 +1,5 @@
1
1
  import type { Model, UserMessage, Part } from "@opencode-ai/sdk";
2
- import type { CerebroClient, SearchResult, ShouldRecallResponse } from "./client.js";
2
+ import type { CerebroClient, SearchResult } from "./client.js";
3
3
  import { type OmemPluginConfig, resolveAgentPolicy } from "./config.js";
4
4
  import { detectSaveKeyword, KEYWORD_NUDGE } from "./keywords.js";
5
5
  import { logDebug, logInfo, logError as logErr } from "./logger.js";
@@ -124,7 +124,7 @@ async function detectProjectName(rootPath: string): Promise<string | undefined>
124
124
  return result;
125
125
  }
126
126
 
127
- function showToast(tui: any, title: string, message: string, variant: string = "info", delayMs: number = 7000) {
127
+ export function showToast(tui: any, title: string, message: string, variant: string = "info", delayMs: number = 7000) {
128
128
  if (!tui) return;
129
129
  setTimeout(() => {
130
130
  try {
@@ -172,26 +172,7 @@ const injectedMemoryIds = new Map<string, Set<string>>();
172
172
  const firstMessages = new Map<string, string>();
173
173
  const sessionMessages = new Map<string, Array<{ role: string; content: string }>>();
174
174
  export const profileInjectedSessions = new Map<string, number>();
175
- const injectedSessions = new Set<string>();
176
- const compactingSummaryCooldown = new Map<string, number>();
177
-
178
- // Per-session async cache for fire-and-forget recall results
179
- export const recallCache = new Map<string, {
180
- profileBlock: string;
181
- recallResult: ShouldRecallResponse;
182
- profileData: { countText: string };
183
- timestamp: number;
184
- }>();
185
-
186
- function hashString(str: string): string {
187
- let hash = 0;
188
- for (let i = 0; i < str.length; i++) {
189
- const char = str.charCodeAt(i);
190
- hash = ((hash << 5) - hash) + char;
191
- hash |= 0;
192
- }
193
- return hash.toString(36);
194
- }
175
+ const summarizedSessions = new Set<string>();
195
176
 
196
177
  function formatRelativeAge(isoDate: string): string {
197
178
  const diffMs = Date.now() - new Date(isoDate).getTime();
@@ -262,14 +243,30 @@ const FETCH_POLICY = [
262
243
  "</cerebro-fetch-policy>",
263
244
  ].join("\n");
264
245
 
265
- function buildContextBlock(results: SearchResult[], maxContentLength: number = 500): string {
246
+ /**
247
+ * Score-weighted budget allocation: high-score memories get more chars.
248
+ * Falls back to uniform distribution when totalScore === 0 or all scores equal.
249
+ */
250
+ function buildContextBlock(
251
+ results: SearchResult[],
252
+ budget: number,
253
+ maxContentLength: number = 500,
254
+ minItemChars: number = MIN_ITEM_CONTENT_CHARS,
255
+ ): string {
266
256
  if (results.length === 0) return "";
267
257
 
258
+ const totalScore = results.reduce((sum, r) => sum + r.score, 0);
259
+
268
260
  const grouped = categorize(results);
269
261
  const sections: string[] = [];
270
262
 
271
263
  for (const [label, items] of grouped) {
272
- const lines = items.map((r) => formatMemoryLine(r, maxContentLength));
264
+ const lines = items.map((r) => {
265
+ const itemMaxLen = totalScore > 0
266
+ ? Math.min(maxContentLength, Math.max(minItemChars, Math.floor((r.score / totalScore) * budget)))
267
+ : Math.min(maxContentLength, Math.max(minItemChars, Math.floor(budget / results.length)));
268
+ return formatMemoryLine(r, itemMaxLen);
269
+ });
273
270
  sections.push(`[${label}]\n${lines.join("\n")}`);
274
271
  }
275
272
 
@@ -281,13 +278,23 @@ function buildContextBlock(results: SearchResult[], maxContentLength: number = 5
281
278
  ].join("\n");
282
279
  }
283
280
 
284
- function buildClusteredContextBlock(clustered: import("./client.js").ClusteredRecallResult, maxContentLength: number = 500): string {
281
+ function buildClusteredContextBlock(
282
+ clustered: import("./client.js").ClusteredRecallResult,
283
+ budget: number,
284
+ maxContentLength: number = 500,
285
+ minItemChars: number = MIN_ITEM_CONTENT_CHARS,
286
+ ): string {
285
287
  const sections: string[] = [];
286
288
 
287
289
  if (clustered.cluster_summaries.length > 0) {
290
+ const totalClusterScore = clustered.cluster_summaries.reduce((sum, cs) => sum + cs.relevance_score, 0);
291
+
288
292
  sections.push("## 📋 主题簇(聚合记忆)");
289
293
  for (const cs of clustered.cluster_summaries) {
290
294
  const scoreIndicator = cs.relevance_score >= 0.8 ? "★★★" : cs.relevance_score >= 0.6 ? "★★" : "★";
295
+ const clusterMaxLen = totalClusterScore > 0
296
+ ? Math.min(maxContentLength, Math.max(minItemChars, Math.floor((cs.relevance_score / totalClusterScore) * budget)))
297
+ : Math.min(maxContentLength, Math.max(minItemChars, Math.floor(budget / clustered.cluster_summaries.length)));
291
298
  sections.push(`\n### ${cs.title} (整合自${cs.member_count}条记忆) ${scoreIndicator}`);
292
299
  sections.push(`> ${cs.summary}`);
293
300
  if (cs.key_memories.length > 0) {
@@ -298,7 +305,7 @@ function buildClusteredContextBlock(clustered: import("./client.js").ClusteredRe
298
305
  ? ` [rel:${mem.relations.map((rel) => rel.target_id).join(",")}]`
299
306
  : "";
300
307
  const importanceBar = mem.importance >= 0.7 ? "●" : mem.importance >= 0.4 ? "◐" : "○";
301
- const content = truncate(mem.content, maxContentLength);
308
+ const content = truncate(mem.content, clusterMaxLen);
302
309
  sections.push(`- ${importanceBar}${idTag}${relTag} ${content}`);
303
310
  }
304
311
  }
@@ -306,13 +313,18 @@ function buildClusteredContextBlock(clustered: import("./client.js").ClusteredRe
306
313
  }
307
314
 
308
315
  if (clustered.standalone_memories.length > 0) {
316
+ const standaloneBudget = clustered.cluster_summaries.length === 0
317
+ ? budget
318
+ : Math.floor(budget * 0.3);
319
+ const standaloneMaxLen = Math.min(maxContentLength, Math.max(minItemChars, Math.floor(standaloneBudget / clustered.standalone_memories.length)));
320
+
309
321
  sections.push("\n## 📌 补充信息");
310
322
  for (const mem of clustered.standalone_memories) {
311
323
  const idTag = mem.id ? ` [id:${mem.id}]` : "";
312
324
  const relTag = mem.relations && mem.relations.length > 0
313
325
  ? ` [rel:${mem.relations.map((rel) => rel.target_id).join(",")}]`
314
326
  : "";
315
- const content = truncate(mem.content, maxContentLength);
327
+ const content = truncate(mem.content, standaloneMaxLen);
316
328
  sections.push(`-${idTag}${relTag} ${content}`);
317
329
  }
318
330
  }
@@ -335,7 +347,6 @@ export function autoRecallHook(client: CerebroClient, containerTags: string[], t
335
347
  const phase2Multiplier = config.recall?.phase2Multiplier ?? 2;
336
348
  const llmMaxEval = config.recall?.llmMaxEval ?? 15;
337
349
  const refineStrategy = config.recall?.refineStrategy ?? "balanced";
338
- const refineMediumChars = config.recall?.refineMediumChars ?? 200;
339
350
  const maxContentLength = Math.max(MIN_CONTENT_LENGTH, config.content?.maxContentLength ?? 500);
340
351
  const maxContentChars = Math.max(MIN_CONTENT_CHARS, config.content?.maxContentChars ?? 30000);
341
352
  const toastDelayMs = config.ui?.toastDelayMs ?? 7000;
@@ -352,44 +363,32 @@ export function autoRecallHook(client: CerebroClient, containerTags: string[], t
352
363
  if (policy === "none") return;
353
364
 
354
365
  try {
355
- logDebug("autoRecallHook start", { sessionId: input.sessionID, agentId, policy, similarityThreshold, maxRecallResults, fetchMultiplier, topkCapMultiplier, mmrJaccardThreshold, mmrPenaltyFactor, phase2Multiplier, llmMaxEval, refineStrategy, refineMediumChars });
366
+ logDebug("autoRecallHook start", { sessionId: input.sessionID, agentId, policy, similarityThreshold, maxRecallResults, fetchMultiplier, topkCapMultiplier, mmrJaccardThreshold, mmrPenaltyFactor, phase2Multiplier, llmMaxEval, refineStrategy });
356
367
  const messages = sessionMessages.get(input.sessionID) ?? [];
357
368
  const userMessages = messages.filter((m) => m.role === "user");
358
369
 
359
- // --- Profile Fetch (before query_text check, but injection deferred to after context) ---
360
- const profile = await client.getProfile();
370
+ // --- Profile Fetch (V2 inject API with TTL gate) ---
371
+ const profileTtlMs = config.profile?.ttlMs ?? 300000; // default 5 minutes
372
+ const lastInjected = profileInjectedSessions.get(input.sessionID);
373
+ const profileTtlExpired = !lastInjected || (Date.now() - lastInjected > profileTtlMs);
374
+
375
+ let profileBlock = "";
361
376
  let profileInjected = false;
362
377
  let profileCountText = "";
363
- let profileBlock = "";
364
- const lastInjected = profileInjectedSessions.get(input.sessionID);
365
- const ttlExpired = !lastInjected || (Date.now() - lastInjected > 10 * 60 * 1000);
366
- const isFirstInjection = !lastInjected;
367
- if (profile && ttlExpired) {
368
- const prefs = ((profile as any)?.static_facts ?? [])
369
- .filter((sf: any) => {
370
- const t: string[] = sf.tags ?? [];
371
- return t.includes("preferences");
372
- })
373
- .map((sf: any) => sf.l2_content ?? sf.content ?? "")
374
- .filter(Boolean);
375
- const profileLines = prefs.length > 0
376
- ? prefs.map((c: string) => ` · ${c}`).join("\n")
377
- : " · (preferences queuing, will populate on next refresh)";
378
- profileBlock = [
379
- "<cerebro-profile>",
380
- profileLines,
381
- "</cerebro-profile>",
382
- ].join("\n");
383
- profileInjected = true;
384
- profileInjectedSessions.set(input.sessionID, Date.now());
385
- const p = profile as any;
386
- const dynamicCount = p?.dynamic_context?.length ?? 0;
387
- const staticCount = p?.static_facts?.length ?? 0;
388
- profileCountText = `Dynamic(${dynamicCount}) · Static(${staticCount})`;
389
- if (isFirstInjection) {
390
- logDebug("autoRecallHook profile ready (first)", { dynamicCount, staticCount });
391
- } else {
392
- logDebug("autoRecallHook profile ready (TTL)", { dynamicCount, staticCount });
378
+
379
+ if (profileTtlExpired) {
380
+ try {
381
+ const injection = await client.getInjection(directory || process.env.OMEM_PROJECT_DIR);
382
+ if (injection?.content) {
383
+ profileBlock = injection.content;
384
+ profileCountText = `${injection.preference_count} preferences`;
385
+ profileInjected = true;
386
+ profileInjectedSessions.set(input.sessionID, Date.now());
387
+ logDebug("autoRecallHook profile ready (V2 injection)", { preferenceCount: injection.preference_count, estimatedTokens: injection.estimated_tokens });
388
+ }
389
+ } catch (e) {
390
+ logErr("autoRecallHook getInjection failed, skipping profile", { error: String(e) });
391
+ // profile failure does not block shouldRecall
393
392
  }
394
393
  }
395
394
 
@@ -430,7 +429,6 @@ export function autoRecallHook(client: CerebroClient, containerTags: string[], t
430
429
  phase2_multiplier: phase2Multiplier,
431
430
  llm_max_eval: llmMaxEval,
432
431
  refine_strategy: refineStrategy,
433
- refine_medium_chars: refineMediumChars,
434
432
  },
435
433
  directory || process.env.OMEM_PROJECT_DIR,
436
434
  );
@@ -496,8 +494,7 @@ export function autoRecallHook(client: CerebroClient, containerTags: string[], t
496
494
  appendToSystem(output.system, profileBlock);
497
495
  logDebug("autoRecallHook profile injected (no-recall path)", { sessionId: input.sessionID, outputSystemLength: output.system.length });
498
496
  }
499
- if (profileInjected && isFirstInjection) {
500
- await createEventAndReturn(0, 0, 0);
497
+ if (profileInjected && !lastInjected) {
501
498
  showToast(tui, "👨 Profile Injected", `${profileCountText} · no memory recall needed`, "success", toastDelayMs);
502
499
  }
503
500
  return;
@@ -514,7 +511,7 @@ export function autoRecallHook(client: CerebroClient, containerTags: string[], t
514
511
  appendToSystem(output.system, profileBlock);
515
512
  logDebug("autoRecallHook profile injected (dedup path)", { sessionId: input.sessionID, outputSystemLength: output.system.length });
516
513
  }
517
- if (profileInjected && isFirstInjection) {
514
+ if (profileInjected && !lastInjected) {
518
515
  showToast(tui, "👨 Profile Injected", `${profileCountText} · all memories already injected`, "success", toastDelayMs);
519
516
  }
520
517
  return;
@@ -526,20 +523,14 @@ export function autoRecallHook(client: CerebroClient, containerTags: string[], t
526
523
  if (budgetRemaining < 0) {
527
524
  logDebug("autoRecallHook budget overflow", { profileChars, maxContentChars, deficit: -budgetRemaining });
528
525
  }
529
- const itemCount = clustered
530
- ? (clustered.cluster_summaries.length + clustered.standalone_memories.length)
531
- : newResults.length;
532
- const dynamicMaxContentLength = itemCount > 0
533
- ? Math.min(maxContentLength, Math.max(MIN_ITEM_CONTENT_CHARS, Math.floor(budgetRemaining / itemCount)))
534
- : maxContentLength;
535
526
  logDebug("autoRecallHook budget", {
536
- maxContentChars, profileChars, budgetRemaining, itemCount,
537
- configuredMax: maxContentLength, dynamicMax: dynamicMaxContentLength
527
+ maxContentChars, profileChars, budgetRemaining,
528
+ configuredMax: maxContentLength,
538
529
  });
539
530
 
540
531
  const block = clustered
541
- ? buildClusteredContextBlock(clustered, dynamicMaxContentLength)
542
- : buildContextBlock(newResults, dynamicMaxContentLength);
532
+ ? buildClusteredContextBlock(clustered, budgetRemaining, maxContentLength, MIN_ITEM_CONTENT_CHARS)
533
+ : buildContextBlock(newResults, budgetRemaining, maxContentLength, MIN_ITEM_CONTENT_CHARS);
543
534
  if (block) {
544
535
  appendToSystem(output.system, block);
545
536
  appendToSystem(output.system, FETCH_POLICY);
@@ -623,537 +614,6 @@ export function autoRecallHook(client: CerebroClient, containerTags: string[], t
623
614
  };
624
615
  }
625
616
 
626
- export function buildProfileBlock(profile: any): { block: string; countText: string } | null {
627
- const prefs = ((profile as any)?.static_facts ?? [])
628
- .filter((sf: any) => {
629
- const t: string[] = sf.tags ?? [];
630
- return t.includes("preferences");
631
- })
632
- .map((sf: any) => sf.l2_content ?? sf.content ?? "")
633
- .filter(Boolean);
634
- const profileLines = prefs.length > 0
635
- ? prefs.map((c: string) => ` · ${c}`).join("\n")
636
- : " · (preferences queuing, will populate on next refresh)";
637
- const block = [
638
- "<cerebro-profile>",
639
- profileLines,
640
- "</cerebro-profile>",
641
- ].join("\n");
642
- const p = profile as any;
643
- const dynamicCount = p?.dynamic_context?.length ?? 0;
644
- const staticCount = p?.static_facts?.length ?? 0;
645
- const countText = `Dynamic(${dynamicCount}) · Static(${staticCount})`;
646
- return { block, countText };
647
- }
648
-
649
- export function memoryInjectionHook(
650
- client: CerebroClient,
651
- containerTags: string[],
652
- tui: any,
653
- config: Partial<OmemPluginConfig> = {},
654
- getAgentName?: () => string,
655
- directory?: string,
656
- ) {
657
- const similarityThreshold = config.recall?.similarityThreshold ?? 0.4;
658
- const maxRecallResults = config.recall?.maxRecallResults ?? 10;
659
- const fetchMultiplier = config.recall?.fetchMultiplier ?? 3;
660
- const topkCapMultiplier = config.recall?.topkCapMultiplier ?? 2;
661
- const mmrJaccardThreshold = config.recall?.mmrJaccardThreshold ?? 0.85;
662
- const mmrPenaltyFactor = config.recall?.mmrPenaltyFactor ?? 0.5;
663
- const phase2Multiplier = config.recall?.phase2Multiplier ?? 2;
664
- const llmMaxEval = config.recall?.llmMaxEval ?? 15;
665
- const refineStrategy = config.recall?.refineStrategy ?? "balanced";
666
- const refineMediumChars = config.recall?.refineMediumChars ?? 200;
667
- const maxContentLength = Math.max(MIN_CONTENT_LENGTH, config.content?.maxContentLength ?? 500);
668
- const maxContentChars = Math.max(MIN_CONTENT_CHARS, config.content?.maxContentChars ?? 30000);
669
- const toastDelayMs = config.ui?.toastDelayMs ?? 7000;
670
-
671
- return async (
672
- input: { sessionID?: string; messageID?: string; model: Model },
673
- output: { message: UserMessage; parts: Part[] },
674
- ) => {
675
- if (!input.sessionID) return;
676
-
677
- const agentId = getAgentName?.() || process.env.OMEM_AGENT_ID || "opencode";
678
- const policy = resolveAgentPolicy(agentId, config);
679
- if (policy === "none") return;
680
-
681
- const isSaveKeyword = saveKeywordDetectedSessions.has(input.sessionID);
682
-
683
- try {
684
- logDebug("memoryInjectionHook start", { sessionId: input.sessionID, agentId, policy, isSaveKeyword, similarityThreshold, maxRecallResults });
685
- const messages = sessionMessages.get(input.sessionID) ?? [];
686
- const userMessages = messages.filter((m) => m.role === "user");
687
-
688
- if (userMessages.length === 0) {
689
- logDebug("memoryInjectionHook skipped: no user messages in session (post-compacting?)", { sessionId: input.sessionID });
690
- return;
691
- }
692
-
693
- const rawQuery = userMessages[userMessages.length - 1]?.content || firstMessages.get(input.sessionID) || "";
694
- const query_text = extractUserRequest(rawQuery);
695
- if (!query_text) {
696
- logDebug("memoryInjectionHook filtered system injection", { rawQueryPrefix: rawQuery.slice(0, 60) });
697
- return;
698
- }
699
- const last_query_text = userMessages.length >= 2 ? userMessages[userMessages.length - 2].content : undefined;
700
-
701
- const projectTags = containerTags.filter(t => t.startsWith("omem_project_"));
702
-
703
- const conversationContext = userMessages.length >= 2
704
- ? userMessages.slice(-4, -1).map((m) => {
705
- const stripped = stripPrivateContent(m.content);
706
- return stripped.length > 200 ? stripped.slice(0, 200) : stripped;
707
- })
708
- : undefined;
709
-
710
- // ========== Phase A: unified data fetch + injection ==========
711
- let shouldRecallRes: ShouldRecallResponse;
712
- let profileBlock = "";
713
- let profileInjected = false;
714
- let profileCountText = "";
715
- let isCacheHit = false;
716
-
717
- const cached = recallCache.get(input.sessionID);
718
-
719
- if (cached && cached.recallResult) {
720
- isCacheHit = true;
721
- shouldRecallRes = cached.recallResult;
722
- if (cached.profileBlock) {
723
- profileBlock = cached.profileBlock;
724
- profileInjected = true;
725
- profileCountText = cached.profileData?.countText ?? "";
726
- }
727
- } else {
728
- // cache miss: synchronous await (first message takes 5-8s, but gets injection)
729
- const [profile, recallRes] = await Promise.all([
730
- client.getProfile(),
731
- client.shouldRecall(
732
- query_text, last_query_text, input.sessionID,
733
- similarityThreshold, maxRecallResults,
734
- projectTags.length > 0 ? projectTags : undefined,
735
- conversationContext && conversationContext.length > 0 ? conversationContext : undefined,
736
- {
737
- fetch_multiplier: fetchMultiplier,
738
- topk_cap_multiplier: topkCapMultiplier,
739
- mmr_jaccard_threshold: mmrJaccardThreshold,
740
- mmr_penalty_factor: mmrPenaltyFactor,
741
- phase2_multiplier: phase2Multiplier,
742
- llm_max_eval: llmMaxEval,
743
- refine_strategy: "loose" as any,
744
- refine_medium_chars: refineMediumChars,
745
- skip_llm_gate: true,
746
- },
747
- directory || process.env.OMEM_PROJECT_DIR,
748
- ),
749
- ]);
750
- if (!recallRes) {
751
- showToast(tui, "🧠 Cerebro Service Unavailable", "Unable to reach memory API", "error", toastDelayMs);
752
- return;
753
- }
754
- shouldRecallRes = recallRes;
755
-
756
- // build profile block (with TTL check)
757
- if (profile) {
758
- const lastInjected = profileInjectedSessions.get(input.sessionID);
759
- const ttlExpired = !lastInjected || (Date.now() - lastInjected > 10 * 60 * 1000);
760
- if (ttlExpired) {
761
- const built = buildProfileBlock(profile);
762
- if (built) {
763
- profileBlock = built.block;
764
- profileCountText = built.countText;
765
- profileInjected = true;
766
- profileInjectedSessions.set(input.sessionID, Date.now());
767
- }
768
- }
769
- }
770
-
771
- // write cache for next round
772
- recallCache.set(input.sessionID, {
773
- profileBlock,
774
- recallResult: shouldRecallRes,
775
- profileData: { countText: profileCountText },
776
- timestamp: Date.now(),
777
- });
778
-
779
- // LRU eviction
780
- if (recallCache.size > 50) {
781
- let oldestKey: string | null = null;
782
- let oldestTime = Infinity;
783
- for (const [k, v] of recallCache) {
784
- if (v.timestamp < oldestTime) { oldestTime = v.timestamp; oldestKey = k; }
785
- }
786
- if (oldestKey) recallCache.delete(oldestKey);
787
- }
788
-
789
- // defensive check
790
- if (shouldRecallRes.should_recall && !Array.isArray(shouldRecallRes.memories)) {
791
- logErr("memoryInjectionHook shouldRecall returned incomplete data", { shouldRecall: shouldRecallRes.should_recall, hasMemories: !!shouldRecallRes.memories });
792
- return;
793
- }
794
-
795
- logDebug("memoryInjectionHook cache miss, fetched synchronously", { sessionId: input.sessionID, shouldRecall: shouldRecallRes.should_recall, memCount: shouldRecallRes.memories?.length ?? 0 });
796
- }
797
-
798
- // ========== unified injection logic (cache hit + cache miss share this) ==========
799
- if (!shouldRecallRes.should_recall) {
800
- // no-recall path: inject profile only
801
- const partsToInject: string[] = [];
802
- if (profileBlock) partsToInject.push(profileBlock);
803
- if (partsToInject.length > 0) {
804
- const injectText = partsToInject.join("\n\n");
805
- const contextPart: Part = {
806
- id: `prt_cerebro-context-${Date.now()}`,
807
- sessionID: input.sessionID,
808
- messageID: output.message.id,
809
- type: "text",
810
- text: injectText,
811
- synthetic: true,
812
- };
813
- output.parts.unshift(contextPart);
814
- logDebug("memoryInjectionHook profile injected (no-recall)", { sessionId: input.sessionID });
815
- }
816
- injectedSessions.add(input.sessionID);
817
- const cacheTag = isCacheHit ? " (cached)" : "";
818
- showToast(tui, `🧠 Profile Injected${cacheTag}`, profileCountText ? `Profile: ${profileCountText} · no recall needed` : "No memory recall needed", "success", toastDelayMs);
819
- } else {
820
- const results = shouldRecallRes.memories ?? [];
821
- const clustered = shouldRecallRes.clustered;
822
- const existingIds = injectedMemoryIds.get(input.sessionID) ?? new Set<string>();
823
- const newResults = results.filter((r) => !existingIds.has(r.memory.id));
824
- logDebug("memoryInjectionHook dedup", { totalResults: results.length, existingCount: existingIds.size, newCount: newResults.length });
825
-
826
- if (newResults.length === 0) {
827
- const partsToInject: string[] = [];
828
- if (profileBlock) partsToInject.push(profileBlock);
829
- if (partsToInject.length > 0) {
830
- const injectText = partsToInject.join("\n\n");
831
- const contextPart: Part = {
832
- id: `prt_cerebro-context-${Date.now()}`,
833
- sessionID: input.sessionID,
834
- messageID: output.message.id,
835
- type: "text",
836
- text: injectText,
837
- synthetic: true,
838
- };
839
- output.parts.unshift(contextPart);
840
- logDebug("memoryInjectionHook profile injected (dedup)", { sessionId: input.sessionID });
841
- }
842
- injectedSessions.add(input.sessionID);
843
- } else {
844
- const profileChars = profileInjected ? profileBlock.length : 0;
845
- const budgetRemaining = maxContentChars - profileChars;
846
- const itemCount = clustered
847
- ? (clustered.cluster_summaries.length + clustered.standalone_memories.length)
848
- : newResults.length;
849
- const dynamicMaxContentLength = itemCount > 0
850
- ? Math.min(maxContentLength, Math.max(MIN_ITEM_CONTENT_CHARS, Math.floor(budgetRemaining / itemCount)))
851
- : maxContentLength;
852
-
853
- const block = clustered
854
- ? buildClusteredContextBlock(clustered, dynamicMaxContentLength)
855
- : buildContextBlock(newResults, dynamicMaxContentLength);
856
-
857
- const partsToInject: string[] = [];
858
- if (block) partsToInject.push(block);
859
- if (block) partsToInject.push(FETCH_POLICY);
860
- if (profileBlock) partsToInject.push(profileBlock);
861
- if (isSaveKeyword) partsToInject.push(KEYWORD_NUDGE);
862
-
863
- if (partsToInject.length > 0) {
864
- const injectText = partsToInject.join("\n\n");
865
- const contextPart: Part = {
866
- id: `prt_cerebro-context-${Date.now()}`,
867
- sessionID: input.sessionID,
868
- messageID: output.message.id,
869
- type: "text",
870
- text: injectText,
871
- synthetic: true,
872
- };
873
- output.parts.unshift(contextPart);
874
- logDebug("memoryInjectionHook block injected", {
875
- sessionId: input.sessionID,
876
- injectTextLen: injectText.length,
877
- blockPreview: block?.slice(0, 200),
878
- });
879
- }
880
-
881
- injectedSessions.add(input.sessionID);
882
-
883
- if (isSaveKeyword) {
884
- saveKeywordDetectedSessions.delete(input.sessionID);
885
- }
886
-
887
- const newIds = newResults.map((r) => r.memory.id);
888
- injectedMemoryIds.set(input.sessionID, new Set([...existingIds, ...newIds]));
889
-
890
- const memDynamic = newResults.filter((r) => r.memory.memory_type === "fact" || r.memory.memory_type === "event").length;
891
- const memStatic = newResults.filter((r) => r.memory.memory_type === "pinned" || r.memory.memory_type === "preference").length;
892
- const memOther = newResults.length - memDynamic - memStatic;
893
-
894
- let memCountMsg = "";
895
- if (memDynamic > 0) memCountMsg += `Dynamic(${memDynamic}) `;
896
- if (memStatic > 0) memCountMsg += `Static(${memStatic}) `;
897
- if (memOther > 0) memCountMsg += `Other(${memOther}) `;
898
-
899
- const categories = categorize(newResults);
900
- const catSummary = Array.from(categories.entries())
901
- .map(([label, items]) => `${label}(${items.length})`)
902
- .join(" · ");
903
-
904
- let toastTitle: string;
905
- let toastMessage: string;
906
-
907
- if (clustered) {
908
- const clusterCount = clustered.cluster_summaries.length;
909
- const standaloneCount = clustered.standalone_memories.length;
910
- toastTitle = `🧠 Context Injected${isCacheHit ? " (cached)" : ""} · ${clusterCount} clusters${standaloneCount > 0 ? ` · ${standaloneCount} standalone` : ""}`;
911
- toastMessage = profileInjected
912
- ? `Profile: ${profileCountText} · Clustered memory display`
913
- : `Clustered memory display`;
914
- } else {
915
- toastTitle = `🧠 Context Injected${isCacheHit ? " (cached)" : ""} · ${newResults.length} fragments`;
916
- toastMessage = profileInjected
917
- ? `Profile: ${profileCountText} · Memories: ${memCountMsg.trim()}${catSummary ? ` · ${catSummary}` : ""}`
918
- : `${memCountMsg.trim()}${catSummary ? ` · ${catSummary}` : ""}`;
919
- }
920
-
921
- showToast(tui, toastTitle, toastMessage, "success", toastDelayMs);
922
- }
923
- }
924
-
925
- // cache miss: fire-and-forget createRecallEvent so web UI shows the record
926
- if (!isCacheHit) {
927
- if (shouldRecallRes.should_recall) {
928
- const results = shouldRecallRes.memories ?? [];
929
- const existingIds = injectedMemoryIds.get(input.sessionID) ?? new Set<string>();
930
- const newResults = results.filter((r) => !existingIds.has(r.memory.id));
931
- const storedMemoryIds = results.map((r) => r.memory.id);
932
- const storedDiscardedIds = shouldRecallRes.discarded?.map((d) => d.memory_id) ?? [];
933
- const maxScore = storedMemoryIds.length > 0
934
- ? Math.max(...(results.map((r) => r.score) ?? [0]))
935
- : 0;
936
- const bgBlock = shouldRecallRes.clustered
937
- ? buildClusteredContextBlock(shouldRecallRes.clustered, maxContentLength)
938
- : buildContextBlock(newResults, maxContentLength);
939
- const items = [
940
- ...(results.map((r) => ({
941
- memory_id: r.memory.id, score: r.score,
942
- refine_relevance: r.refine_relevance, refine_reasoning: r.refine_reasoning, is_kept: true,
943
- }))),
944
- ...(shouldRecallRes.discarded?.map((d) => ({
945
- memory_id: d.memory_id, score: d.score,
946
- refine_relevance: d.refine_relevance, refine_reasoning: d.refine_reasoning, is_kept: false,
947
- })) ?? []),
948
- ];
949
- client.createRecallEvent({
950
- session_id: input.sessionID!, recall_type: "auto", query_text,
951
- max_score: maxScore, llm_confidence: shouldRecallRes.confidence ?? 0,
952
- profile_injected: profileInjected,
953
- kept_count: storedMemoryIds.length, discarded_count: storedDiscardedIds.length,
954
- injected_count: newResults.length,
955
- profile_content: profileInjected && profileBlock ? profileBlock : undefined,
956
- injected_content: bgBlock ?? undefined,
957
- items: items.length > 0 ? items : undefined,
958
- }).catch((e: unknown) => {
959
- logErr("memoryInjectionHook cache-miss createRecallEvent failed", { error: String(e) });
960
- });
961
- } else if (profileInjected) {
962
- client.createRecallEvent({
963
- session_id: input.sessionID!, recall_type: "auto", query_text,
964
- max_score: 0, llm_confidence: shouldRecallRes.confidence ?? 0,
965
- profile_injected: true,
966
- kept_count: 0, discarded_count: 0, injected_count: 0,
967
- profile_content: profileBlock || undefined,
968
- }).catch((e: unknown) => {
969
- logErr("memoryInjectionHook cache-miss profile-only createRecallEvent failed", { error: String(e) });
970
- });
971
- }
972
- }
973
-
974
- logDebug("memoryInjectionHook injection complete", { sessionId: input.sessionID, isCacheHit });
975
-
976
- // ========== Phase B: fire-and-forget async fetch for NEXT round (cache hit only) ==========
977
- if (isCacheHit) {
978
- const bgSessionId = input.sessionID;
979
- const bgQueryText = query_text;
980
- const bgLastQueryText = last_query_text;
981
- const bgConversationContext = conversationContext;
982
- const bgProjectTags = projectTags.length > 0 ? projectTags : undefined;
983
- const bgDirectory = directory || process.env.OMEM_PROJECT_DIR;
984
-
985
- Promise.allSettled([
986
- client.getProfile(),
987
- client.shouldRecall(
988
- bgQueryText, bgLastQueryText, bgSessionId,
989
- similarityThreshold, maxRecallResults,
990
- bgProjectTags,
991
- bgConversationContext && bgConversationContext.length > 0 ? bgConversationContext : undefined,
992
- {
993
- fetch_multiplier: fetchMultiplier,
994
- topk_cap_multiplier: topkCapMultiplier,
995
- mmr_jaccard_threshold: mmrJaccardThreshold,
996
- mmr_penalty_factor: mmrPenaltyFactor,
997
- phase2_multiplier: phase2Multiplier,
998
- llm_max_eval: llmMaxEval,
999
- refine_strategy: refineStrategy,
1000
- refine_medium_chars: refineMediumChars,
1001
- },
1002
- bgDirectory,
1003
- ),
1004
- ])
1005
- .then(([profileRes, recallRes]) => {
1006
- if (recallRes.status === 'rejected') {
1007
- logErr("memoryInjectionHook shouldRecall failed", { error: String(recallRes.reason) });
1008
- return;
1009
- }
1010
- const profile = profileRes.status === 'fulfilled' ? profileRes.value : null;
1011
- const shouldRecallRes = recallRes.value;
1012
- if (!shouldRecallRes) {
1013
- showToast(tui, "🧠 Cerebro Service Unavailable", "Unable to reach memory API · check connection", "error", toastDelayMs);
1014
- return;
1015
- }
1016
- logDebug("memoryInjectionHook background fetch complete", {
1017
- sessionId: bgSessionId,
1018
- shouldRecall: shouldRecallRes.should_recall,
1019
- confidence: shouldRecallRes.confidence,
1020
- memCount: shouldRecallRes.memories?.length ?? 0,
1021
- });
1022
-
1023
- if (shouldRecallRes.should_recall && !Array.isArray(shouldRecallRes.memories)) {
1024
- logErr("memoryInjectionHook shouldRecall returned incomplete data", {
1025
- shouldRecall: shouldRecallRes.should_recall,
1026
- hasMemories: !!shouldRecallRes.memories,
1027
- });
1028
- return;
1029
- }
1030
-
1031
- let bgProfileBlock = "";
1032
- let bgProfileCountText = "";
1033
- let bgProfileInjected = false;
1034
-
1035
- if (profile) {
1036
- const lastInjected = profileInjectedSessions.get(bgSessionId);
1037
- const ttlExpired = !lastInjected || (Date.now() - lastInjected > 10 * 60 * 1000);
1038
- if (ttlExpired) {
1039
- const built = buildProfileBlock(profile);
1040
- if (built) {
1041
- bgProfileBlock = built.block;
1042
- bgProfileCountText = built.countText;
1043
- bgProfileInjected = true;
1044
- }
1045
- }
1046
- }
1047
-
1048
- recallCache.set(bgSessionId, {
1049
- profileBlock: bgProfileBlock,
1050
- recallResult: shouldRecallRes,
1051
- profileData: { countText: bgProfileCountText },
1052
- timestamp: Date.now(),
1053
- });
1054
-
1055
- if (recallCache.size > 50) {
1056
- let oldestKey: string | null = null;
1057
- let oldestTime = Infinity;
1058
- for (const [k, v] of recallCache) {
1059
- if (v.timestamp < oldestTime) {
1060
- oldestTime = v.timestamp;
1061
- oldestKey = k;
1062
- }
1063
- }
1064
- if (oldestKey) recallCache.delete(oldestKey);
1065
- }
1066
-
1067
- if (shouldRecallRes.should_recall) {
1068
- const results = shouldRecallRes.memories ?? [];
1069
- const existingIds = injectedMemoryIds.get(bgSessionId) ?? new Set<string>();
1070
- const newResults = results.filter((r) => !existingIds.has(r.memory.id));
1071
- if (newResults.length > 0) {
1072
- const newIds = newResults.map((r) => r.memory.id);
1073
- injectedMemoryIds.set(bgSessionId, new Set([...existingIds, ...newIds]));
1074
- }
1075
-
1076
- const storedMemoryIds = shouldRecallRes.memories?.map((r) => r.memory.id) ?? [];
1077
- const storedDiscardedIds = shouldRecallRes.discarded?.map((d) => d.memory_id) ?? [];
1078
- const maxScore = storedMemoryIds.length > 0
1079
- ? Math.max(...(shouldRecallRes.memories?.map((r) => r.score) ?? [0]))
1080
- : 0;
1081
-
1082
- const bgBlock = shouldRecallRes.clustered
1083
- ? buildClusteredContextBlock(shouldRecallRes.clustered, maxContentLength)
1084
- : buildContextBlock(newResults, maxContentLength);
1085
- const bgInjectedContent = bgBlock ?? undefined;
1086
-
1087
- const items = [
1088
- ...(shouldRecallRes.memories?.map((r) => ({
1089
- memory_id: r.memory.id,
1090
- score: r.score,
1091
- refine_relevance: r.refine_relevance,
1092
- refine_reasoning: r.refine_reasoning,
1093
- is_kept: true,
1094
- })) ?? []),
1095
- ...(shouldRecallRes.discarded?.map((d) => ({
1096
- memory_id: d.memory_id,
1097
- score: d.score,
1098
- refine_relevance: d.refine_relevance,
1099
- refine_reasoning: d.refine_reasoning,
1100
- is_kept: false,
1101
- })) ?? []),
1102
- ];
1103
-
1104
- client.createRecallEvent({
1105
- session_id: bgSessionId,
1106
- recall_type: "auto",
1107
- query_text: bgQueryText,
1108
- max_score: maxScore,
1109
- llm_confidence: shouldRecallRes.confidence ?? 0,
1110
- profile_injected: bgProfileInjected,
1111
- kept_count: storedMemoryIds.length,
1112
- discarded_count: storedDiscardedIds.length,
1113
- injected_count: newResults.length,
1114
- profile_content: bgProfileInjected && bgProfileBlock ? bgProfileBlock : undefined,
1115
- injected_content: bgInjectedContent,
1116
- items: items.length > 0 ? items : undefined,
1117
- }).catch((e: unknown) => {
1118
- logErr("memoryInjectionHook background createRecallEvent failed", { error: String(e) });
1119
- });
1120
- }
1121
- })
1122
- .catch((err: unknown) => {
1123
- const errMsg = err instanceof Error ? err.message : String(err);
1124
- logErr("memoryInjectionHook background fetch failed", { error: errMsg });
1125
- if (errMsg.includes("[cerebro]")) {
1126
- const cleanMsg = errMsg.replace(/^\[cerebro\]\s*/, "");
1127
- if (cleanMsg.startsWith("500")) {
1128
- showToast(tui, "🧠 Cerebro Server Error", cleanMsg.substring(0, 200), "error");
1129
- } else if (cleanMsg.includes("timed out")) {
1130
- showToast(tui, "🧠 Cerebro Service Timeout", cleanMsg.substring(0, 100), "error");
1131
- }
1132
- } else if (errMsg.includes("fetch") || errMsg.includes("network")) {
1133
- showToast(tui, "🧠 Cerebro Service Unavailable", "Network error · check API connection", "error");
1134
- }
1135
- });
1136
- }
1137
- } catch (err) {
1138
- const errMsg = err instanceof Error ? err.message : String(err);
1139
- if (errMsg.includes("[cerebro]")) {
1140
- const cleanMsg = errMsg.replace(/^\[cerebro\]\s*/, "");
1141
- if (cleanMsg.startsWith("500")) {
1142
- showToast(tui, "🧠 Cerebro Server Error", cleanMsg.substring(0, 200), "error");
1143
- } else if (cleanMsg.includes("timed out")) {
1144
- showToast(tui, "🧠 Cerebro Service Timeout", cleanMsg.substring(0, 100), "error");
1145
- } else {
1146
- showToast(tui, "🧠 Cerebro Error", cleanMsg.substring(0, 150), "error");
1147
- }
1148
- } else if (errMsg.includes("fetch") || errMsg.includes("network")) {
1149
- showToast(tui, "🧠 Cerebro Service Unavailable", "Network error · check API connection", "error");
1150
- } else {
1151
- showToast(tui, "🧠 Memory Recall Error", errMsg.substring(0, 100), "error");
1152
- }
1153
- }
1154
- };
1155
- }
1156
-
1157
617
  export function keywordDetectionHook(_client: CerebroClient, _containerTags: string[], threshold: number, _tui: any, _ingestMode: "smart" | "raw" = "smart", config: Partial<OmemPluginConfig> = {}, agentId?: string) {
1158
618
  const effectiveAgentId = agentId || process.env.OMEM_AGENT_ID || "opencode";
1159
619
  return async (
@@ -1297,7 +757,6 @@ export function compactingHook(client: CerebroClient, containerTags: string[], t
1297
757
  if (input.sessionID) {
1298
758
  sessionMessages.delete(input.sessionID);
1299
759
  profileInjectedSessions.delete(input.sessionID);
1300
- recallCache.delete(input.sessionID);
1301
760
  firstMessages.delete(input.sessionID);
1302
761
  }
1303
762
  return;
@@ -1329,7 +788,6 @@ export function compactingHook(client: CerebroClient, containerTags: string[], t
1329
788
  if (isAutoStoreEnabled && !isAutoStoreEnabled(input.sessionID)) {
1330
789
  sessionMessages.delete(input.sessionID);
1331
790
  profileInjectedSessions.delete(input.sessionID);
1332
- recallCache.delete(input.sessionID);
1333
791
  firstMessages.delete(input.sessionID);
1334
792
  } else {
1335
793
  const messages = sessionMessages.get(input.sessionID)!;
@@ -1358,13 +816,10 @@ export function compactingHook(client: CerebroClient, containerTags: string[], t
1358
816
  }
1359
817
  // Cleanup tracked messages regardless of ingest result
1360
818
  sessionMessages.delete(input.sessionID);
1361
- injectedSessions.delete(input.sessionID);
1362
819
  profileInjectedSessions.delete(input.sessionID);
1363
- recallCache.delete(input.sessionID);
1364
820
  firstMessages.delete(input.sessionID);
1365
821
  if (input.sessionID) {
1366
- const deleted = pendingToolCalls.delete(input.sessionID);
1367
- logDebug("compactingHook cleared session pendingToolCalls", { sessionID: input.sessionID, hadPending: deleted });
822
+ logDebug("compactingHook cleared session state", { sessionID: input.sessionID });
1368
823
  }
1369
824
  // Evict stale injectedMemoryIds if over size cap (200 sessions)
1370
825
  if (injectedMemoryIds.size > 200) {
@@ -1372,156 +827,10 @@ export function compactingHook(client: CerebroClient, containerTags: string[], t
1372
827
  }
1373
828
  }
1374
829
 
1375
- // Phase 2: compact inserts "[restore checkpointed" user message poll for that marker
1376
- if (sdkClient && input.sessionID) {
1377
- const pollSessionId = input.sessionID;
1378
- const pollEffectiveSessionId = effectiveSessionId;
1379
- const pollProjectName = projectName;
1380
- const pollProjectPath = projectPath;
1381
- const pollAgentId = effectiveAgentId;
1382
-
1383
- let baselineMsgIds: Set<string> = new Set();
1384
- try {
1385
- const preResp = await sdkClient.session.messages({ path: { id: pollSessionId } });
1386
- if (preResp?.data) {
1387
- baselineMsgIds = new Set(preResp.data.map((m: any) => m.info?.id).filter(Boolean));
1388
- }
1389
- logInfo("compactingHook: summary poll starting", { baselineCount: baselineMsgIds.size, sessionId: pollSessionId });
1390
- } catch (e) {
1391
- logErr("compactingHook: baseline snapshot failed", { error: String(e) });
1392
- }
1393
-
1394
- if (baselineMsgIds.size > 0) {
1395
- const maxAttempts = 12;
1396
- const pollInterval = 5000;
1397
- const COMPACT_MARKER = "[restore checkpointed";
1398
-
1399
- (async () => {
1400
- for (let i = 0; i < maxAttempts; i++) {
1401
- await new Promise(r => setTimeout(r, pollInterval));
1402
- try {
1403
- const resp = await sdkClient.session.messages({ path: { id: pollSessionId } });
1404
- if (!resp?.data) continue;
1405
-
1406
- const currentCount = resp.data.length;
1407
- logDebug("compactingHook: summary poll tick", {
1408
- attempt: i + 1, currentCount, baselineCount: baselineMsgIds.size,
1409
- });
1410
-
1411
- const compactMsg = resp.data.find((m: any) => {
1412
- if (m.info?.role !== "user") return false;
1413
- if (baselineMsgIds.has(m.info?.id)) return false;
1414
- const textParts = (m.parts || [])
1415
- .filter((p: any) => p.type === "text" && p.text)
1416
- .map((p: any) => p.text);
1417
- return textParts.join("\n").includes(COMPACT_MARKER);
1418
- });
1419
-
1420
- if (compactMsg) {
1421
- const compactIdx = resp.data.findIndex((m: any) => m.info?.id === compactMsg.info?.id);
1422
- const userTextParts = (compactMsg.parts || [])
1423
- .filter((p: any) => p.type === "text" && p.text)
1424
- .map((p: any) => p.text);
1425
- const userFullText = userTextParts.join("\n").trim();
1426
-
1427
- logInfo("compactingHook: compact completed detected", {
1428
- attempt: i + 1, msgId: compactMsg.info?.id,
1429
- compactIdx, userTextLen: userFullText.length,
1430
- partsCount: (compactMsg.parts || []).length,
1431
- partTypes: (compactMsg.parts || []).map((p: any) => p.type),
1432
- firstPartLen: userTextParts[0]?.length ?? 0,
1433
- msgsAfterCompact: resp.data.length - compactIdx - 1,
1434
- });
1435
-
1436
- if (userFullText.length > 0) {
1437
- logDebug("compactingHook: compact msg full text", {
1438
- text: userFullText.substring(0, 500),
1439
- });
1440
- }
1441
-
1442
- let summaryText: string | undefined;
1443
-
1444
- const markerLineIdx = userFullText.indexOf(COMPACT_MARKER);
1445
- if (markerLineIdx >= 0) {
1446
- const afterMarker = userFullText.substring(markerLineIdx);
1447
- const firstNewline = afterMarker.indexOf("\n");
1448
- const candidate = firstNewline >= 0 ? afterMarker.substring(firstNewline + 1).trim() : "";
1449
- if (candidate.length > 100) {
1450
- summaryText = candidate;
1451
- }
1452
- }
1453
-
1454
- if (!summaryText && compactIdx >= 0) {
1455
- for (let j = compactIdx + 1; j < resp.data.length; j++) {
1456
- const msg = resp.data[j];
1457
- if (msg.info?.role !== "assistant") continue;
1458
- const assistParts = (msg.parts || [])
1459
- .filter((p: any) => p.type === "text" && p.text)
1460
- .map((p: any) => p.text);
1461
- const assistText = assistParts.join("\n").trim();
1462
- logDebug("compactingHook: assistant msg after compact", {
1463
- idx: j, textLen: assistText.length, partTypes: (msg.parts || []).map((p: any) => p.type),
1464
- preview: assistText.substring(0, 200),
1465
- });
1466
- if (assistText.length > 200) {
1467
- summaryText = assistText;
1468
- break;
1469
- }
1470
- }
1471
- }
1472
-
1473
- if (!summaryText && userFullText.length > 100) {
1474
- summaryText = userFullText;
1475
- }
1476
-
1477
- if (summaryText) {
1478
- logInfo("compactingHook: storing compact summary", {
1479
- summaryLen: summaryText.length, msgId: compactMsg.info?.id,
1480
- });
1481
- // Dedup check: 30s cooldown per session+content hash
1482
- const summaryHash = `${pollSessionId}:${hashString(summaryText)}`;
1483
- const lastCompacting = compactingSummaryCooldown.get(summaryHash);
1484
- if (lastCompacting && Date.now() - lastCompacting < 30000) {
1485
- logDebug("compactingHook summary dedup", { sessionId: pollSessionId });
1486
- break;
1487
- }
1488
- compactingSummaryCooldown.set(summaryHash, Date.now());
1489
-
1490
- const prefixedSummary = `[Session Summary] ${summaryText}`;
1491
- try {
1492
- const result = await client.ingestMessages(
1493
- [{ role: "user" as const, content: prefixedSummary }],
1494
- {
1495
- mode: ingestMode,
1496
- tags: [...containerTags, "auto-capture", "compact-summary"],
1497
- sessionId: pollEffectiveSessionId,
1498
- projectName: pollProjectName,
1499
- agentId: pollAgentId,
1500
- projectPath: pollProjectPath,
1501
- },
1502
- );
1503
- logInfo("compactingHook: compact summary store result", {
1504
- result: result === null ? "null(blocked)" : "ok",
1505
- });
1506
- if (result !== null) {
1507
- showToast(tui, "📦 Compact Summary Stored", "Session summary archived to memory", "success");
1508
- }
1509
- } catch (e) {
1510
- logErr("compactingHook: compact summary store failed", { error: String(e) });
1511
- }
1512
- } else {
1513
- logInfo("compactingHook: no summary text found after compact marker", {
1514
- userTextLen: userFullText.length, compactIdx,
1515
- });
1516
- }
1517
- break;
1518
- }
1519
- } catch (e) {
1520
- logErr("compactingHook: summary poll error", { error: String(e), attempt: i + 1 });
1521
- }
1522
- }
1523
- })();
1524
- }
830
+ // After compacting, clear profile TTL so next autoRecallHook re-injects profile
831
+ if (input.sessionID) {
832
+ profileInjectedSessions.delete(input.sessionID);
833
+ logDebug("compactingHook cleared profile TTL for re-injection", { sessionID: input.sessionID });
1525
834
  }
1526
835
  };
1527
836
  }
@@ -1639,77 +948,137 @@ export function autocontinueHook(
1639
948
  const processedMessageIds = new Set<string>();
1640
949
  const pluginStartTime = Date.now();
1641
950
 
1642
- // ── Soul Whisper: pending tool call tracking (per-session isolation) ──
1643
- export const pendingToolCalls = new Map<string, Map<string, { toolName: string; timestamp: number }>>();
951
+ export function sessionIdleHook(
952
+ cerebroClient: CerebroClient,
953
+ containerTags: string[],
954
+ tui: any,
955
+ sdkClient: any,
956
+ ingestMode: "smart" | "raw" = "smart",
957
+ threshold: number = 0,
958
+ getMainSessionId?: () => string | undefined,
959
+ isAutoStoreEnabled?: (sessionId: string | undefined) => boolean,
960
+ agentId?: string,
961
+ config: Partial<OmemPluginConfig> = {},
962
+ onAgentResolved?: (name: string) => void,
963
+ directory?: string,
964
+ ) {
965
+ let idleTimeout: ReturnType<typeof setTimeout> | null = null;
966
+ let isCapturing = false;
967
+
968
+ async function handleSummaryCapture(props: any) {
969
+ const info = props?.info;
970
+ if (!info) return;
971
+ if (info.role !== "assistant" || !info.summary || !info.finish) return;
972
+
973
+ const sessionID = info.sessionID;
974
+ if (!sessionID) return;
1644
975
 
1645
- export function soulWhisperToolTracker(config: OmemPluginConfig) {
1646
- return async (input: { tool: string; sessionID: string; callID: string }, _output: { args: any }) => {
1647
- if (config.soulWhisper?.enabled === false) {
1648
- logDebug("soulWhisperToolTracker disabled by config", { tool: input.tool });
976
+ if (summarizedSessions.has(sessionID)) return;
977
+ summarizedSessions.add(sessionID);
978
+
979
+ if (!sdkClient) {
980
+ logInfo("handleSummaryCapture skipped: no sdkClient", { sessionID });
1649
981
  return;
1650
982
  }
1651
983
 
1652
- const sw = config.soulWhisper;
1653
- const toolName = input.tool;
984
+ logInfo("handleSummaryCapture triggered", { sessionID });
1654
985
 
1655
- const excludeTools = sw?.excludeTools ?? [];
1656
- if (excludeTools.includes(toolName)) {
1657
- logDebug("soulWhisperToolTracker excluded", { tool: toolName });
1658
- return;
986
+ if (getMainSessionId) {
987
+ const mainId = getMainSessionId();
988
+ if (mainId && sessionID !== mainId) {
989
+ logInfo("handleSummaryCapture: non-main session skipped", { sessionID, mainSessionId: mainId });
990
+ return;
991
+ }
1659
992
  }
1660
993
 
1661
- const includeTools = sw?.tools ?? ["*"];
1662
- const isWildcard = includeTools.includes("*");
1663
- if (!isWildcard && !includeTools.includes(toolName)) {
1664
- logDebug("soulWhisperToolTracker not in whitelist", { tool: toolName, whitelist: includeTools });
994
+ const effectiveAgentId = agentId || process.env.OMEM_AGENT_ID || "opencode";
995
+ const policy = resolveAgentPolicy(effectiveAgentId, config);
996
+ if (policy !== "readwrite") {
997
+ logInfo("handleSummaryCapture blocked by policy", { agentId: effectiveAgentId, policy });
1665
998
  return;
1666
999
  }
1667
1000
 
1668
- const sid = input.sessionID || "_default";
1669
- let sessionMap = pendingToolCalls.get(sid);
1670
- if (!sessionMap) {
1671
- sessionMap = new Map();
1672
- pendingToolCalls.set(sid, sessionMap);
1673
- }
1674
- sessionMap.set(input.callID, { toolName, timestamp: Date.now() });
1675
- logDebug("soulWhisperToolTracker recorded", { tool: toolName, callID: input.callID, sessionID: sid, totalSessions: pendingToolCalls.size, sessionCallCount: sessionMap.size });
1676
- };
1677
- }
1001
+ if (isAutoStoreEnabled && !isAutoStoreEnabled(sessionID)) return;
1678
1002
 
1679
- export function buildWhisperText(toolNames: string[], maxToolNames: number): string | null {
1680
- if (toolNames.length === 0) return null;
1003
+ try {
1004
+ const resp = await sdkClient.session.messages({ path: { id: sessionID } });
1005
+ const messages = resp?.data ?? resp;
1681
1006
 
1682
- const lines: string[] = ["<cerebro-memory-activation>"];
1007
+ const summaryMsg = (messages as Array<{ info: any; parts?: Array<{ type: string; text?: string }> }>).find((m) =>
1008
+ m.info?.role === "assistant" && m.info?.summary === true
1009
+ );
1683
1010
 
1684
- if (toolNames.length <= maxToolNames) {
1685
- lines.push(`Before using ${toolNames.join(", ")}, memory_search() may surface relevant past decisions or patterns. Brief recall → better outcomes.`);
1686
- } else {
1687
- lines.push("Before you act — memory_search() surfaces cross-session context: past decisions, user preferences, hard-won insights. The strongest responses are built on remembered context.");
1688
- }
1011
+ if (!summaryMsg?.parts) {
1012
+ logInfo("handleSummaryCapture: no summary parts found", { sessionID });
1013
+ return;
1014
+ }
1689
1015
 
1690
- lines.push("</cerebro-memory-activation>");
1016
+ const textParts = summaryMsg.parts.filter((p) => p.type === "text" && p.text).map((p) => p.text);
1017
+ const summaryContent = textParts.join("\n").trim();
1691
1018
 
1692
- return lines.join("\n");
1693
- }
1019
+ if (!summaryContent || summaryContent.length < 100) {
1020
+ logInfo("handleSummaryCapture: summary too short", { sessionID, length: summaryContent?.length ?? 0 });
1021
+ return;
1022
+ }
1694
1023
 
1695
- export function sessionIdleHook(
1696
- cerebroClient: CerebroClient,
1697
- _containerTags: string[],
1698
- tui: any,
1699
- sdkClient: any,
1700
- _ingestMode: "smart" | "raw" = "smart",
1701
- threshold: number = 0,
1702
- getMainSessionId?: () => string | undefined,
1703
- isAutoStoreEnabled?: (sessionId: string | undefined) => boolean,
1704
- agentId?: string,
1705
- config: Partial<OmemPluginConfig> = {},
1706
- onAgentResolved?: (name: string) => void,
1707
- directory?: string,
1708
- ) {
1709
- let idleTimeout: ReturnType<typeof setTimeout> | null = null;
1710
- let isCapturing = false;
1024
+ const effectiveSessionId = getMainSessionId?.() || sessionID;
1025
+
1026
+ let projectName: string | undefined;
1027
+ let projectPath: string | undefined;
1028
+ try {
1029
+ const sessionInfo = await sdkClient.session.get({ path: { id: sessionID } });
1030
+ projectPath = sessionInfo?.data?.directory || directory || process.env.OMEM_PROJECT_DIR;
1031
+ projectName = sessionInfo?.data?.directory
1032
+ ? await detectProjectName(sessionInfo.data.directory)
1033
+ : undefined;
1034
+ } catch (e) {
1035
+ logErr("handleSummaryCapture detectProjectName failed", { error: String(e) });
1036
+ }
1037
+ if (!projectPath) {
1038
+ projectPath = directory || process.env.OMEM_PROJECT_DIR;
1039
+ }
1040
+
1041
+ const prefixedSummary = `[Session Summary] ${summaryContent}`;
1042
+ const result = await cerebroClient.ingestMessages(
1043
+ [{ role: "user" as const, content: prefixedSummary }],
1044
+ {
1045
+ mode: ingestMode,
1046
+ tags: [...containerTags, "auto-capture", "compact-summary"],
1047
+ sessionId: effectiveSessionId,
1048
+ projectName,
1049
+ agentId: effectiveAgentId,
1050
+ projectPath,
1051
+ },
1052
+ );
1053
+
1054
+ logInfo("handleSummaryCapture store result", { result: result === null ? "null(blocked)" : "ok" });
1055
+ if (result !== null) {
1056
+ showToast(tui, "📦 Compact Summary Stored", "Session summary archived", "success");
1057
+ }
1058
+ } catch (err) {
1059
+ logErr("handleSummaryCapture failed", { error: String(err) });
1060
+ }
1061
+ }
1711
1062
 
1712
1063
  return async (input: { event: { type: string; properties?: any } }) => {
1064
+ if (input.event.type === "message.updated") {
1065
+ await handleSummaryCapture(input.event.properties);
1066
+ return;
1067
+ }
1068
+
1069
+ if (input.event.type === "session.deleted") {
1070
+ const sessionInfo = input.event.properties?.info;
1071
+ const sid = sessionInfo?.id;
1072
+ if (sid) {
1073
+ summarizedSessions.delete(sid);
1074
+ sessionMessages.delete(sid);
1075
+ profileInjectedSessions.delete(sid);
1076
+ firstMessages.delete(sid);
1077
+ logDebug("sessionIdleHook: session.deleted cleanup", { sessionID: sid });
1078
+ }
1079
+ return;
1080
+ }
1081
+
1713
1082
  if (input.event.type !== "session.idle") return;
1714
1083
 
1715
1084
  logDebug("sessionIdleHook event.properties dump", { keys: Object.keys(input.event.properties || {}), raw: JSON.stringify(input.event.properties).substring(0, 2000) });
@@ -1817,8 +1186,6 @@ export function sessionIdleHook(
1817
1186
  } finally {
1818
1187
  isCapturing = false;
1819
1188
  idleTimeout = null;
1820
- const deleted = pendingToolCalls.delete(sessionID);
1821
- if (deleted) logDebug("sessionIdleHook cleared session pendingToolCalls", { sessionID, hadPending: deleted });
1822
1189
  }
1823
1190
  }, 10000);
1824
1191
  };