@mingxy/cerebro 1.15.13 → 1.15.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/hooks.js CHANGED
@@ -116,7 +116,7 @@ async function detectProjectName(rootPath) {
116
116
  }
117
117
  return result;
118
118
  }
119
- function showToast(tui, title, message, variant = "info", delayMs = 7000) {
119
+ export function showToast(tui, title, message, variant = "info", delayMs = 7000) {
120
120
  if (!tui)
121
121
  return;
122
122
  setTimeout(() => {
@@ -162,19 +162,7 @@ const injectedMemoryIds = new Map();
162
162
  const firstMessages = new Map();
163
163
  const sessionMessages = new Map();
164
164
  export const profileInjectedSessions = new Map();
165
- const injectedSessions = new Set();
166
- const compactingSummaryCooldown = new Map();
167
- // Per-session async cache for fire-and-forget recall results
168
- export const recallCache = new Map();
169
- function hashString(str) {
170
- let hash = 0;
171
- for (let i = 0; i < str.length; i++) {
172
- const char = str.charCodeAt(i);
173
- hash = ((hash << 5) - hash) + char;
174
- hash |= 0;
175
- }
176
- return hash.toString(36);
177
- }
165
+ const summarizedSessions = new Set();
178
166
  function formatRelativeAge(isoDate) {
179
167
  const diffMs = Date.now() - new Date(isoDate).getTime();
180
168
  const minutes = Math.floor(diffMs / 60_000);
@@ -323,41 +311,27 @@ export function autoRecallHook(client, containerTags, tui, config = {}, getAgent
323
311
  logDebug("autoRecallHook start", { sessionId: input.sessionID, agentId, policy, similarityThreshold, maxRecallResults, fetchMultiplier, topkCapMultiplier, mmrJaccardThreshold, mmrPenaltyFactor, phase2Multiplier, llmMaxEval, refineStrategy, refineMediumChars });
324
312
  const messages = sessionMessages.get(input.sessionID) ?? [];
325
313
  const userMessages = messages.filter((m) => m.role === "user");
326
- // --- Profile Fetch (before query_text check, but injection deferred to after context) ---
327
- const profile = await client.getProfile();
314
+ // --- Profile Fetch (V2 inject API with TTL gate) ---
315
+ const profileTtlMs = config.profile?.ttlMs ?? 300000; // default 5 minutes
316
+ const lastInjected = profileInjectedSessions.get(input.sessionID);
317
+ const profileTtlExpired = !lastInjected || (Date.now() - lastInjected > profileTtlMs);
318
+ let profileBlock = "";
328
319
  let profileInjected = false;
329
320
  let profileCountText = "";
330
- let profileBlock = "";
331
- const lastInjected = profileInjectedSessions.get(input.sessionID);
332
- const ttlExpired = !lastInjected || (Date.now() - lastInjected > 10 * 60 * 1000);
333
- const isFirstInjection = !lastInjected;
334
- if (profile && ttlExpired) {
335
- const prefs = (profile?.static_facts ?? [])
336
- .filter((sf) => {
337
- const t = sf.tags ?? [];
338
- return t.includes("preferences");
339
- })
340
- .map((sf) => sf.l2_content ?? sf.content ?? "")
341
- .filter(Boolean);
342
- const profileLines = prefs.length > 0
343
- ? prefs.map((c) => ` · ${c}`).join("\n")
344
- : " · (preferences queuing, will populate on next refresh)";
345
- profileBlock = [
346
- "<cerebro-profile>",
347
- profileLines,
348
- "</cerebro-profile>",
349
- ].join("\n");
350
- profileInjected = true;
351
- profileInjectedSessions.set(input.sessionID, Date.now());
352
- const p = profile;
353
- const dynamicCount = p?.dynamic_context?.length ?? 0;
354
- const staticCount = p?.static_facts?.length ?? 0;
355
- profileCountText = `Dynamic(${dynamicCount}) · Static(${staticCount})`;
356
- if (isFirstInjection) {
357
- logDebug("autoRecallHook profile ready (first)", { dynamicCount, staticCount });
321
+ if (profileTtlExpired) {
322
+ try {
323
+ const injection = await client.getInjection(directory || process.env.OMEM_PROJECT_DIR);
324
+ if (injection?.content) {
325
+ profileBlock = injection.content;
326
+ profileCountText = `${injection.preference_count} preferences`;
327
+ profileInjected = true;
328
+ profileInjectedSessions.set(input.sessionID, Date.now());
329
+ logDebug("autoRecallHook profile ready (V2 injection)", { preferenceCount: injection.preference_count, estimatedTokens: injection.estimated_tokens });
330
+ }
358
331
  }
359
- else {
360
- logDebug("autoRecallHook profile ready (TTL)", { dynamicCount, staticCount });
332
+ catch (e) {
333
+ logErr("autoRecallHook getInjection failed, skipping profile", { error: String(e) });
334
+ // profile failure does not block shouldRecall
361
335
  }
362
336
  }
363
337
  // After compacting, sessionMessages is cleared but firstMessages gets repopulated
@@ -444,8 +418,7 @@ export function autoRecallHook(client, containerTags, tui, config = {}, getAgent
444
418
  appendToSystem(output.system, profileBlock);
445
419
  logDebug("autoRecallHook profile injected (no-recall path)", { sessionId: input.sessionID, outputSystemLength: output.system.length });
446
420
  }
447
- if (profileInjected && isFirstInjection) {
448
- await createEventAndReturn(0, 0, 0);
421
+ if (profileInjected && !lastInjected) {
449
422
  showToast(tui, "👨 Profile Injected", `${profileCountText} · no memory recall needed`, "success", toastDelayMs);
450
423
  }
451
424
  return;
@@ -460,7 +433,7 @@ export function autoRecallHook(client, containerTags, tui, config = {}, getAgent
460
433
  appendToSystem(output.system, profileBlock);
461
434
  logDebug("autoRecallHook profile injected (dedup path)", { sessionId: input.sessionID, outputSystemLength: output.system.length });
462
435
  }
463
- if (profileInjected && isFirstInjection) {
436
+ if (profileInjected && !lastInjected) {
464
437
  showToast(tui, "👨 Profile Injected", `${profileCountText} · all memories already injected`, "success", toastDelayMs);
465
438
  }
466
439
  return;
@@ -566,495 +539,6 @@ export function autoRecallHook(client, containerTags, tui, config = {}, getAgent
566
539
  }
567
540
  };
568
541
  }
569
- export function buildProfileBlock(profile) {
570
- const prefs = (profile?.static_facts ?? [])
571
- .filter((sf) => {
572
- const t = sf.tags ?? [];
573
- return t.includes("preferences");
574
- })
575
- .map((sf) => sf.l2_content ?? sf.content ?? "")
576
- .filter(Boolean);
577
- const profileLines = prefs.length > 0
578
- ? prefs.map((c) => ` · ${c}`).join("\n")
579
- : " · (preferences queuing, will populate on next refresh)";
580
- const block = [
581
- "<cerebro-profile>",
582
- profileLines,
583
- "</cerebro-profile>",
584
- ].join("\n");
585
- const p = profile;
586
- const dynamicCount = p?.dynamic_context?.length ?? 0;
587
- const staticCount = p?.static_facts?.length ?? 0;
588
- const countText = `Dynamic(${dynamicCount}) · Static(${staticCount})`;
589
- return { block, countText };
590
- }
591
- export function memoryInjectionHook(client, containerTags, tui, config = {}, getAgentName, directory) {
592
- const similarityThreshold = config.recall?.similarityThreshold ?? 0.4;
593
- const maxRecallResults = config.recall?.maxRecallResults ?? 10;
594
- const fetchMultiplier = config.recall?.fetchMultiplier ?? 3;
595
- const topkCapMultiplier = config.recall?.topkCapMultiplier ?? 2;
596
- const mmrJaccardThreshold = config.recall?.mmrJaccardThreshold ?? 0.85;
597
- const mmrPenaltyFactor = config.recall?.mmrPenaltyFactor ?? 0.5;
598
- const phase2Multiplier = config.recall?.phase2Multiplier ?? 2;
599
- const llmMaxEval = config.recall?.llmMaxEval ?? 15;
600
- const refineStrategy = config.recall?.refineStrategy ?? "balanced";
601
- const refineMediumChars = config.recall?.refineMediumChars ?? 200;
602
- const maxContentLength = Math.max(MIN_CONTENT_LENGTH, config.content?.maxContentLength ?? 500);
603
- const maxContentChars = Math.max(MIN_CONTENT_CHARS, config.content?.maxContentChars ?? 30000);
604
- const toastDelayMs = config.ui?.toastDelayMs ?? 7000;
605
- return async (input, output) => {
606
- if (!input.sessionID)
607
- return;
608
- const agentId = getAgentName?.() || process.env.OMEM_AGENT_ID || "opencode";
609
- const policy = resolveAgentPolicy(agentId, config);
610
- if (policy === "none")
611
- return;
612
- const isSaveKeyword = saveKeywordDetectedSessions.has(input.sessionID);
613
- try {
614
- logDebug("memoryInjectionHook start", { sessionId: input.sessionID, agentId, policy, isSaveKeyword, similarityThreshold, maxRecallResults });
615
- const messages = sessionMessages.get(input.sessionID) ?? [];
616
- const userMessages = messages.filter((m) => m.role === "user");
617
- if (userMessages.length === 0) {
618
- logDebug("memoryInjectionHook skipped: no user messages in session (post-compacting?)", { sessionId: input.sessionID });
619
- return;
620
- }
621
- const rawQuery = userMessages[userMessages.length - 1]?.content || firstMessages.get(input.sessionID) || "";
622
- const query_text = extractUserRequest(rawQuery);
623
- if (!query_text) {
624
- logDebug("memoryInjectionHook filtered system injection", { rawQueryPrefix: rawQuery.slice(0, 60) });
625
- return;
626
- }
627
- const last_query_text = userMessages.length >= 2 ? userMessages[userMessages.length - 2].content : undefined;
628
- const projectTags = containerTags.filter(t => t.startsWith("omem_project_"));
629
- const conversationContext = userMessages.length >= 2
630
- ? userMessages.slice(-4, -1).map((m) => {
631
- const stripped = stripPrivateContent(m.content);
632
- return stripped.length > 200 ? stripped.slice(0, 200) : stripped;
633
- })
634
- : undefined;
635
- // ========== Phase A: unified data fetch + injection ==========
636
- let shouldRecallRes;
637
- let profileBlock = "";
638
- let profileInjected = false;
639
- let profileCountText = "";
640
- let isCacheHit = false;
641
- const cached = recallCache.get(input.sessionID);
642
- if (cached && cached.recallResult) {
643
- isCacheHit = true;
644
- shouldRecallRes = cached.recallResult;
645
- if (cached.profileBlock) {
646
- profileBlock = cached.profileBlock;
647
- profileInjected = true;
648
- profileCountText = cached.profileData?.countText ?? "";
649
- }
650
- }
651
- else {
652
- // cache miss: synchronous await (first message takes 5-8s, but gets injection)
653
- const [profile, recallRes] = await Promise.all([
654
- client.getProfile(),
655
- client.shouldRecall(query_text, last_query_text, input.sessionID, similarityThreshold, maxRecallResults, projectTags.length > 0 ? projectTags : undefined, conversationContext && conversationContext.length > 0 ? conversationContext : undefined, {
656
- fetch_multiplier: fetchMultiplier,
657
- topk_cap_multiplier: topkCapMultiplier,
658
- mmr_jaccard_threshold: mmrJaccardThreshold,
659
- mmr_penalty_factor: mmrPenaltyFactor,
660
- phase2_multiplier: phase2Multiplier,
661
- llm_max_eval: llmMaxEval,
662
- refine_strategy: "loose",
663
- refine_medium_chars: refineMediumChars,
664
- skip_llm_gate: true,
665
- }, directory || process.env.OMEM_PROJECT_DIR),
666
- ]);
667
- if (!recallRes) {
668
- showToast(tui, "🧠 Cerebro Service Unavailable", "Unable to reach memory API", "error", toastDelayMs);
669
- return;
670
- }
671
- shouldRecallRes = recallRes;
672
- // build profile block (with TTL check)
673
- if (profile) {
674
- const lastInjected = profileInjectedSessions.get(input.sessionID);
675
- const ttlExpired = !lastInjected || (Date.now() - lastInjected > 10 * 60 * 1000);
676
- if (ttlExpired) {
677
- const built = buildProfileBlock(profile);
678
- if (built) {
679
- profileBlock = built.block;
680
- profileCountText = built.countText;
681
- profileInjected = true;
682
- profileInjectedSessions.set(input.sessionID, Date.now());
683
- }
684
- }
685
- }
686
- // write cache for next round
687
- recallCache.set(input.sessionID, {
688
- profileBlock,
689
- recallResult: shouldRecallRes,
690
- profileData: { countText: profileCountText },
691
- timestamp: Date.now(),
692
- });
693
- // LRU eviction
694
- if (recallCache.size > 50) {
695
- let oldestKey = null;
696
- let oldestTime = Infinity;
697
- for (const [k, v] of recallCache) {
698
- if (v.timestamp < oldestTime) {
699
- oldestTime = v.timestamp;
700
- oldestKey = k;
701
- }
702
- }
703
- if (oldestKey)
704
- recallCache.delete(oldestKey);
705
- }
706
- // defensive check
707
- if (shouldRecallRes.should_recall && !Array.isArray(shouldRecallRes.memories)) {
708
- logErr("memoryInjectionHook shouldRecall returned incomplete data", { shouldRecall: shouldRecallRes.should_recall, hasMemories: !!shouldRecallRes.memories });
709
- return;
710
- }
711
- logDebug("memoryInjectionHook cache miss, fetched synchronously", { sessionId: input.sessionID, shouldRecall: shouldRecallRes.should_recall, memCount: shouldRecallRes.memories?.length ?? 0 });
712
- }
713
- // ========== unified injection logic (cache hit + cache miss share this) ==========
714
- if (!shouldRecallRes.should_recall) {
715
- // no-recall path: inject profile only
716
- const partsToInject = [];
717
- if (profileBlock)
718
- partsToInject.push(profileBlock);
719
- if (partsToInject.length > 0) {
720
- const injectText = partsToInject.join("\n\n");
721
- const contextPart = {
722
- id: `prt_cerebro-context-${Date.now()}`,
723
- sessionID: input.sessionID,
724
- messageID: output.message.id,
725
- type: "text",
726
- text: injectText,
727
- synthetic: true,
728
- };
729
- output.parts.unshift(contextPart);
730
- logDebug("memoryInjectionHook profile injected (no-recall)", { sessionId: input.sessionID });
731
- }
732
- injectedSessions.add(input.sessionID);
733
- const cacheTag = isCacheHit ? " (cached)" : "";
734
- showToast(tui, `🧠 Profile Injected${cacheTag}`, profileCountText ? `Profile: ${profileCountText} · no recall needed` : "No memory recall needed", "success", toastDelayMs);
735
- }
736
- else {
737
- const results = shouldRecallRes.memories ?? [];
738
- const clustered = shouldRecallRes.clustered;
739
- const existingIds = injectedMemoryIds.get(input.sessionID) ?? new Set();
740
- const newResults = results.filter((r) => !existingIds.has(r.memory.id));
741
- logDebug("memoryInjectionHook dedup", { totalResults: results.length, existingCount: existingIds.size, newCount: newResults.length });
742
- if (newResults.length === 0) {
743
- const partsToInject = [];
744
- if (profileBlock)
745
- partsToInject.push(profileBlock);
746
- if (partsToInject.length > 0) {
747
- const injectText = partsToInject.join("\n\n");
748
- const contextPart = {
749
- id: `prt_cerebro-context-${Date.now()}`,
750
- sessionID: input.sessionID,
751
- messageID: output.message.id,
752
- type: "text",
753
- text: injectText,
754
- synthetic: true,
755
- };
756
- output.parts.unshift(contextPart);
757
- logDebug("memoryInjectionHook profile injected (dedup)", { sessionId: input.sessionID });
758
- }
759
- injectedSessions.add(input.sessionID);
760
- }
761
- else {
762
- const profileChars = profileInjected ? profileBlock.length : 0;
763
- const budgetRemaining = maxContentChars - profileChars;
764
- const itemCount = clustered
765
- ? (clustered.cluster_summaries.length + clustered.standalone_memories.length)
766
- : newResults.length;
767
- const dynamicMaxContentLength = itemCount > 0
768
- ? Math.min(maxContentLength, Math.max(MIN_ITEM_CONTENT_CHARS, Math.floor(budgetRemaining / itemCount)))
769
- : maxContentLength;
770
- const block = clustered
771
- ? buildClusteredContextBlock(clustered, dynamicMaxContentLength)
772
- : buildContextBlock(newResults, dynamicMaxContentLength);
773
- const partsToInject = [];
774
- if (block)
775
- partsToInject.push(block);
776
- if (block)
777
- partsToInject.push(FETCH_POLICY);
778
- if (profileBlock)
779
- partsToInject.push(profileBlock);
780
- if (isSaveKeyword)
781
- partsToInject.push(KEYWORD_NUDGE);
782
- if (partsToInject.length > 0) {
783
- const injectText = partsToInject.join("\n\n");
784
- const contextPart = {
785
- id: `prt_cerebro-context-${Date.now()}`,
786
- sessionID: input.sessionID,
787
- messageID: output.message.id,
788
- type: "text",
789
- text: injectText,
790
- synthetic: true,
791
- };
792
- output.parts.unshift(contextPart);
793
- logDebug("memoryInjectionHook block injected", {
794
- sessionId: input.sessionID,
795
- injectTextLen: injectText.length,
796
- blockPreview: block?.slice(0, 200),
797
- });
798
- }
799
- injectedSessions.add(input.sessionID);
800
- if (isSaveKeyword) {
801
- saveKeywordDetectedSessions.delete(input.sessionID);
802
- }
803
- const newIds = newResults.map((r) => r.memory.id);
804
- injectedMemoryIds.set(input.sessionID, new Set([...existingIds, ...newIds]));
805
- const memDynamic = newResults.filter((r) => r.memory.memory_type === "fact" || r.memory.memory_type === "event").length;
806
- const memStatic = newResults.filter((r) => r.memory.memory_type === "pinned" || r.memory.memory_type === "preference").length;
807
- const memOther = newResults.length - memDynamic - memStatic;
808
- let memCountMsg = "";
809
- if (memDynamic > 0)
810
- memCountMsg += `Dynamic(${memDynamic}) `;
811
- if (memStatic > 0)
812
- memCountMsg += `Static(${memStatic}) `;
813
- if (memOther > 0)
814
- memCountMsg += `Other(${memOther}) `;
815
- const categories = categorize(newResults);
816
- const catSummary = Array.from(categories.entries())
817
- .map(([label, items]) => `${label}(${items.length})`)
818
- .join(" · ");
819
- let toastTitle;
820
- let toastMessage;
821
- if (clustered) {
822
- const clusterCount = clustered.cluster_summaries.length;
823
- const standaloneCount = clustered.standalone_memories.length;
824
- toastTitle = `🧠 Context Injected${isCacheHit ? " (cached)" : ""} · ${clusterCount} clusters${standaloneCount > 0 ? ` · ${standaloneCount} standalone` : ""}`;
825
- toastMessage = profileInjected
826
- ? `Profile: ${profileCountText} · Clustered memory display`
827
- : `Clustered memory display`;
828
- }
829
- else {
830
- toastTitle = `🧠 Context Injected${isCacheHit ? " (cached)" : ""} · ${newResults.length} fragments`;
831
- toastMessage = profileInjected
832
- ? `Profile: ${profileCountText} · Memories: ${memCountMsg.trim()}${catSummary ? ` · ${catSummary}` : ""}`
833
- : `${memCountMsg.trim()}${catSummary ? ` · ${catSummary}` : ""}`;
834
- }
835
- showToast(tui, toastTitle, toastMessage, "success", toastDelayMs);
836
- }
837
- }
838
- // cache miss: fire-and-forget createRecallEvent so web UI shows the record
839
- if (!isCacheHit) {
840
- if (shouldRecallRes.should_recall) {
841
- const results = shouldRecallRes.memories ?? [];
842
- const existingIds = injectedMemoryIds.get(input.sessionID) ?? new Set();
843
- const newResults = results.filter((r) => !existingIds.has(r.memory.id));
844
- const storedMemoryIds = results.map((r) => r.memory.id);
845
- const storedDiscardedIds = shouldRecallRes.discarded?.map((d) => d.memory_id) ?? [];
846
- const maxScore = storedMemoryIds.length > 0
847
- ? Math.max(...(results.map((r) => r.score) ?? [0]))
848
- : 0;
849
- const bgBlock = shouldRecallRes.clustered
850
- ? buildClusteredContextBlock(shouldRecallRes.clustered, maxContentLength)
851
- : buildContextBlock(newResults, maxContentLength);
852
- const items = [
853
- ...(results.map((r) => ({
854
- memory_id: r.memory.id, score: r.score,
855
- refine_relevance: r.refine_relevance, refine_reasoning: r.refine_reasoning, is_kept: true,
856
- }))),
857
- ...(shouldRecallRes.discarded?.map((d) => ({
858
- memory_id: d.memory_id, score: d.score,
859
- refine_relevance: d.refine_relevance, refine_reasoning: d.refine_reasoning, is_kept: false,
860
- })) ?? []),
861
- ];
862
- client.createRecallEvent({
863
- session_id: input.sessionID, recall_type: "auto", query_text,
864
- max_score: maxScore, llm_confidence: shouldRecallRes.confidence ?? 0,
865
- profile_injected: profileInjected,
866
- kept_count: storedMemoryIds.length, discarded_count: storedDiscardedIds.length,
867
- injected_count: newResults.length,
868
- profile_content: profileInjected && profileBlock ? profileBlock : undefined,
869
- injected_content: bgBlock ?? undefined,
870
- items: items.length > 0 ? items : undefined,
871
- }).catch((e) => {
872
- logErr("memoryInjectionHook cache-miss createRecallEvent failed", { error: String(e) });
873
- });
874
- }
875
- else if (profileInjected) {
876
- client.createRecallEvent({
877
- session_id: input.sessionID, recall_type: "auto", query_text,
878
- max_score: 0, llm_confidence: shouldRecallRes.confidence ?? 0,
879
- profile_injected: true,
880
- kept_count: 0, discarded_count: 0, injected_count: 0,
881
- profile_content: profileBlock || undefined,
882
- }).catch((e) => {
883
- logErr("memoryInjectionHook cache-miss profile-only createRecallEvent failed", { error: String(e) });
884
- });
885
- }
886
- }
887
- logDebug("memoryInjectionHook injection complete", { sessionId: input.sessionID, isCacheHit });
888
- // ========== Phase B: fire-and-forget async fetch for NEXT round (cache hit only) ==========
889
- if (isCacheHit) {
890
- const bgSessionId = input.sessionID;
891
- const bgQueryText = query_text;
892
- const bgLastQueryText = last_query_text;
893
- const bgConversationContext = conversationContext;
894
- const bgProjectTags = projectTags.length > 0 ? projectTags : undefined;
895
- const bgDirectory = directory || process.env.OMEM_PROJECT_DIR;
896
- Promise.allSettled([
897
- client.getProfile(),
898
- client.shouldRecall(bgQueryText, bgLastQueryText, bgSessionId, similarityThreshold, maxRecallResults, bgProjectTags, bgConversationContext && bgConversationContext.length > 0 ? bgConversationContext : undefined, {
899
- fetch_multiplier: fetchMultiplier,
900
- topk_cap_multiplier: topkCapMultiplier,
901
- mmr_jaccard_threshold: mmrJaccardThreshold,
902
- mmr_penalty_factor: mmrPenaltyFactor,
903
- phase2_multiplier: phase2Multiplier,
904
- llm_max_eval: llmMaxEval,
905
- refine_strategy: refineStrategy,
906
- refine_medium_chars: refineMediumChars,
907
- }, bgDirectory),
908
- ])
909
- .then(([profileRes, recallRes]) => {
910
- if (recallRes.status === 'rejected') {
911
- logErr("memoryInjectionHook shouldRecall failed", { error: String(recallRes.reason) });
912
- return;
913
- }
914
- const profile = profileRes.status === 'fulfilled' ? profileRes.value : null;
915
- const shouldRecallRes = recallRes.value;
916
- if (!shouldRecallRes) {
917
- showToast(tui, "🧠 Cerebro Service Unavailable", "Unable to reach memory API · check connection", "error", toastDelayMs);
918
- return;
919
- }
920
- logDebug("memoryInjectionHook background fetch complete", {
921
- sessionId: bgSessionId,
922
- shouldRecall: shouldRecallRes.should_recall,
923
- confidence: shouldRecallRes.confidence,
924
- memCount: shouldRecallRes.memories?.length ?? 0,
925
- });
926
- if (shouldRecallRes.should_recall && !Array.isArray(shouldRecallRes.memories)) {
927
- logErr("memoryInjectionHook shouldRecall returned incomplete data", {
928
- shouldRecall: shouldRecallRes.should_recall,
929
- hasMemories: !!shouldRecallRes.memories,
930
- });
931
- return;
932
- }
933
- let bgProfileBlock = "";
934
- let bgProfileCountText = "";
935
- let bgProfileInjected = false;
936
- if (profile) {
937
- const lastInjected = profileInjectedSessions.get(bgSessionId);
938
- const ttlExpired = !lastInjected || (Date.now() - lastInjected > 10 * 60 * 1000);
939
- if (ttlExpired) {
940
- const built = buildProfileBlock(profile);
941
- if (built) {
942
- bgProfileBlock = built.block;
943
- bgProfileCountText = built.countText;
944
- bgProfileInjected = true;
945
- }
946
- }
947
- }
948
- recallCache.set(bgSessionId, {
949
- profileBlock: bgProfileBlock,
950
- recallResult: shouldRecallRes,
951
- profileData: { countText: bgProfileCountText },
952
- timestamp: Date.now(),
953
- });
954
- if (recallCache.size > 50) {
955
- let oldestKey = null;
956
- let oldestTime = Infinity;
957
- for (const [k, v] of recallCache) {
958
- if (v.timestamp < oldestTime) {
959
- oldestTime = v.timestamp;
960
- oldestKey = k;
961
- }
962
- }
963
- if (oldestKey)
964
- recallCache.delete(oldestKey);
965
- }
966
- if (shouldRecallRes.should_recall) {
967
- const results = shouldRecallRes.memories ?? [];
968
- const existingIds = injectedMemoryIds.get(bgSessionId) ?? new Set();
969
- const newResults = results.filter((r) => !existingIds.has(r.memory.id));
970
- if (newResults.length > 0) {
971
- const newIds = newResults.map((r) => r.memory.id);
972
- injectedMemoryIds.set(bgSessionId, new Set([...existingIds, ...newIds]));
973
- }
974
- const storedMemoryIds = shouldRecallRes.memories?.map((r) => r.memory.id) ?? [];
975
- const storedDiscardedIds = shouldRecallRes.discarded?.map((d) => d.memory_id) ?? [];
976
- const maxScore = storedMemoryIds.length > 0
977
- ? Math.max(...(shouldRecallRes.memories?.map((r) => r.score) ?? [0]))
978
- : 0;
979
- const bgBlock = shouldRecallRes.clustered
980
- ? buildClusteredContextBlock(shouldRecallRes.clustered, maxContentLength)
981
- : buildContextBlock(newResults, maxContentLength);
982
- const bgInjectedContent = bgBlock ?? undefined;
983
- const items = [
984
- ...(shouldRecallRes.memories?.map((r) => ({
985
- memory_id: r.memory.id,
986
- score: r.score,
987
- refine_relevance: r.refine_relevance,
988
- refine_reasoning: r.refine_reasoning,
989
- is_kept: true,
990
- })) ?? []),
991
- ...(shouldRecallRes.discarded?.map((d) => ({
992
- memory_id: d.memory_id,
993
- score: d.score,
994
- refine_relevance: d.refine_relevance,
995
- refine_reasoning: d.refine_reasoning,
996
- is_kept: false,
997
- })) ?? []),
998
- ];
999
- client.createRecallEvent({
1000
- session_id: bgSessionId,
1001
- recall_type: "auto",
1002
- query_text: bgQueryText,
1003
- max_score: maxScore,
1004
- llm_confidence: shouldRecallRes.confidence ?? 0,
1005
- profile_injected: bgProfileInjected,
1006
- kept_count: storedMemoryIds.length,
1007
- discarded_count: storedDiscardedIds.length,
1008
- injected_count: newResults.length,
1009
- profile_content: bgProfileInjected && bgProfileBlock ? bgProfileBlock : undefined,
1010
- injected_content: bgInjectedContent,
1011
- items: items.length > 0 ? items : undefined,
1012
- }).catch((e) => {
1013
- logErr("memoryInjectionHook background createRecallEvent failed", { error: String(e) });
1014
- });
1015
- }
1016
- })
1017
- .catch((err) => {
1018
- const errMsg = err instanceof Error ? err.message : String(err);
1019
- logErr("memoryInjectionHook background fetch failed", { error: errMsg });
1020
- if (errMsg.includes("[cerebro]")) {
1021
- const cleanMsg = errMsg.replace(/^\[cerebro\]\s*/, "");
1022
- if (cleanMsg.startsWith("500")) {
1023
- showToast(tui, "🧠 Cerebro Server Error", cleanMsg.substring(0, 200), "error");
1024
- }
1025
- else if (cleanMsg.includes("timed out")) {
1026
- showToast(tui, "🧠 Cerebro Service Timeout", cleanMsg.substring(0, 100), "error");
1027
- }
1028
- }
1029
- else if (errMsg.includes("fetch") || errMsg.includes("network")) {
1030
- showToast(tui, "🧠 Cerebro Service Unavailable", "Network error · check API connection", "error");
1031
- }
1032
- });
1033
- }
1034
- }
1035
- catch (err) {
1036
- const errMsg = err instanceof Error ? err.message : String(err);
1037
- if (errMsg.includes("[cerebro]")) {
1038
- const cleanMsg = errMsg.replace(/^\[cerebro\]\s*/, "");
1039
- if (cleanMsg.startsWith("500")) {
1040
- showToast(tui, "🧠 Cerebro Server Error", cleanMsg.substring(0, 200), "error");
1041
- }
1042
- else if (cleanMsg.includes("timed out")) {
1043
- showToast(tui, "🧠 Cerebro Service Timeout", cleanMsg.substring(0, 100), "error");
1044
- }
1045
- else {
1046
- showToast(tui, "🧠 Cerebro Error", cleanMsg.substring(0, 150), "error");
1047
- }
1048
- }
1049
- else if (errMsg.includes("fetch") || errMsg.includes("network")) {
1050
- showToast(tui, "🧠 Cerebro Service Unavailable", "Network error · check API connection", "error");
1051
- }
1052
- else {
1053
- showToast(tui, "🧠 Memory Recall Error", errMsg.substring(0, 100), "error");
1054
- }
1055
- }
1056
- };
1057
- }
1058
542
  export function keywordDetectionHook(_client, _containerTags, threshold, _tui, _ingestMode = "smart", config = {}, agentId) {
1059
543
  const effectiveAgentId = agentId || process.env.OMEM_AGENT_ID || "opencode";
1060
544
  return async (input, output) => {
@@ -1180,7 +664,6 @@ export function compactingHook(client, containerTags, tui, ingestMode = "smart",
1180
664
  if (input.sessionID) {
1181
665
  sessionMessages.delete(input.sessionID);
1182
666
  profileInjectedSessions.delete(input.sessionID);
1183
- recallCache.delete(input.sessionID);
1184
667
  firstMessages.delete(input.sessionID);
1185
668
  }
1186
669
  return;
@@ -1210,7 +693,6 @@ export function compactingHook(client, containerTags, tui, ingestMode = "smart",
1210
693
  if (isAutoStoreEnabled && !isAutoStoreEnabled(input.sessionID)) {
1211
694
  sessionMessages.delete(input.sessionID);
1212
695
  profileInjectedSessions.delete(input.sessionID);
1213
- recallCache.delete(input.sessionID);
1214
696
  firstMessages.delete(input.sessionID);
1215
697
  }
1216
698
  else {
@@ -1242,160 +724,20 @@ export function compactingHook(client, containerTags, tui, ingestMode = "smart",
1242
724
  }
1243
725
  // Cleanup tracked messages regardless of ingest result
1244
726
  sessionMessages.delete(input.sessionID);
1245
- injectedSessions.delete(input.sessionID);
1246
727
  profileInjectedSessions.delete(input.sessionID);
1247
- recallCache.delete(input.sessionID);
1248
728
  firstMessages.delete(input.sessionID);
1249
729
  if (input.sessionID) {
1250
- const deleted = pendingToolCalls.delete(input.sessionID);
1251
- logDebug("compactingHook cleared session pendingToolCalls", { sessionID: input.sessionID, hadPending: deleted });
730
+ logDebug("compactingHook cleared session state", { sessionID: input.sessionID });
1252
731
  }
1253
732
  // Evict stale injectedMemoryIds if over size cap (200 sessions)
1254
733
  if (injectedMemoryIds.size > 200) {
1255
734
  injectedMemoryIds.clear();
1256
735
  }
1257
736
  }
1258
- // Phase 2: compact inserts "[restore checkpointed" user message poll for that marker
1259
- if (sdkClient && input.sessionID) {
1260
- const pollSessionId = input.sessionID;
1261
- const pollEffectiveSessionId = effectiveSessionId;
1262
- const pollProjectName = projectName;
1263
- const pollProjectPath = projectPath;
1264
- const pollAgentId = effectiveAgentId;
1265
- let baselineMsgIds = new Set();
1266
- try {
1267
- const preResp = await sdkClient.session.messages({ path: { id: pollSessionId } });
1268
- if (preResp?.data) {
1269
- baselineMsgIds = new Set(preResp.data.map((m) => m.info?.id).filter(Boolean));
1270
- }
1271
- logInfo("compactingHook: summary poll starting", { baselineCount: baselineMsgIds.size, sessionId: pollSessionId });
1272
- }
1273
- catch (e) {
1274
- logErr("compactingHook: baseline snapshot failed", { error: String(e) });
1275
- }
1276
- if (baselineMsgIds.size > 0) {
1277
- const maxAttempts = 12;
1278
- const pollInterval = 5000;
1279
- const COMPACT_MARKER = "[restore checkpointed";
1280
- (async () => {
1281
- for (let i = 0; i < maxAttempts; i++) {
1282
- await new Promise(r => setTimeout(r, pollInterval));
1283
- try {
1284
- const resp = await sdkClient.session.messages({ path: { id: pollSessionId } });
1285
- if (!resp?.data)
1286
- continue;
1287
- const currentCount = resp.data.length;
1288
- logDebug("compactingHook: summary poll tick", {
1289
- attempt: i + 1, currentCount, baselineCount: baselineMsgIds.size,
1290
- });
1291
- const compactMsg = resp.data.find((m) => {
1292
- if (m.info?.role !== "user")
1293
- return false;
1294
- if (baselineMsgIds.has(m.info?.id))
1295
- return false;
1296
- const textParts = (m.parts || [])
1297
- .filter((p) => p.type === "text" && p.text)
1298
- .map((p) => p.text);
1299
- return textParts.join("\n").includes(COMPACT_MARKER);
1300
- });
1301
- if (compactMsg) {
1302
- const compactIdx = resp.data.findIndex((m) => m.info?.id === compactMsg.info?.id);
1303
- const userTextParts = (compactMsg.parts || [])
1304
- .filter((p) => p.type === "text" && p.text)
1305
- .map((p) => p.text);
1306
- const userFullText = userTextParts.join("\n").trim();
1307
- logInfo("compactingHook: compact completed detected", {
1308
- attempt: i + 1, msgId: compactMsg.info?.id,
1309
- compactIdx, userTextLen: userFullText.length,
1310
- partsCount: (compactMsg.parts || []).length,
1311
- partTypes: (compactMsg.parts || []).map((p) => p.type),
1312
- firstPartLen: userTextParts[0]?.length ?? 0,
1313
- msgsAfterCompact: resp.data.length - compactIdx - 1,
1314
- });
1315
- if (userFullText.length > 0) {
1316
- logDebug("compactingHook: compact msg full text", {
1317
- text: userFullText.substring(0, 500),
1318
- });
1319
- }
1320
- let summaryText;
1321
- const markerLineIdx = userFullText.indexOf(COMPACT_MARKER);
1322
- if (markerLineIdx >= 0) {
1323
- const afterMarker = userFullText.substring(markerLineIdx);
1324
- const firstNewline = afterMarker.indexOf("\n");
1325
- const candidate = firstNewline >= 0 ? afterMarker.substring(firstNewline + 1).trim() : "";
1326
- if (candidate.length > 100) {
1327
- summaryText = candidate;
1328
- }
1329
- }
1330
- if (!summaryText && compactIdx >= 0) {
1331
- for (let j = compactIdx + 1; j < resp.data.length; j++) {
1332
- const msg = resp.data[j];
1333
- if (msg.info?.role !== "assistant")
1334
- continue;
1335
- const assistParts = (msg.parts || [])
1336
- .filter((p) => p.type === "text" && p.text)
1337
- .map((p) => p.text);
1338
- const assistText = assistParts.join("\n").trim();
1339
- logDebug("compactingHook: assistant msg after compact", {
1340
- idx: j, textLen: assistText.length, partTypes: (msg.parts || []).map((p) => p.type),
1341
- preview: assistText.substring(0, 200),
1342
- });
1343
- if (assistText.length > 200) {
1344
- summaryText = assistText;
1345
- break;
1346
- }
1347
- }
1348
- }
1349
- if (!summaryText && userFullText.length > 100) {
1350
- summaryText = userFullText;
1351
- }
1352
- if (summaryText) {
1353
- logInfo("compactingHook: storing compact summary", {
1354
- summaryLen: summaryText.length, msgId: compactMsg.info?.id,
1355
- });
1356
- // Dedup check: 30s cooldown per session+content hash
1357
- const summaryHash = `${pollSessionId}:${hashString(summaryText)}`;
1358
- const lastCompacting = compactingSummaryCooldown.get(summaryHash);
1359
- if (lastCompacting && Date.now() - lastCompacting < 30000) {
1360
- logDebug("compactingHook summary dedup", { sessionId: pollSessionId });
1361
- break;
1362
- }
1363
- compactingSummaryCooldown.set(summaryHash, Date.now());
1364
- const prefixedSummary = `[Session Summary] ${summaryText}`;
1365
- try {
1366
- const result = await client.ingestMessages([{ role: "user", content: prefixedSummary }], {
1367
- mode: ingestMode,
1368
- tags: [...containerTags, "auto-capture", "compact-summary"],
1369
- sessionId: pollEffectiveSessionId,
1370
- projectName: pollProjectName,
1371
- agentId: pollAgentId,
1372
- projectPath: pollProjectPath,
1373
- });
1374
- logInfo("compactingHook: compact summary store result", {
1375
- result: result === null ? "null(blocked)" : "ok",
1376
- });
1377
- if (result !== null) {
1378
- showToast(tui, "📦 Compact Summary Stored", "Session summary archived to memory", "success");
1379
- }
1380
- }
1381
- catch (e) {
1382
- logErr("compactingHook: compact summary store failed", { error: String(e) });
1383
- }
1384
- }
1385
- else {
1386
- logInfo("compactingHook: no summary text found after compact marker", {
1387
- userTextLen: userFullText.length, compactIdx,
1388
- });
1389
- }
1390
- break;
1391
- }
1392
- }
1393
- catch (e) {
1394
- logErr("compactingHook: summary poll error", { error: String(e), attempt: i + 1 });
1395
- }
1396
- }
1397
- })();
1398
- }
737
+ // After compacting, clear profile TTL so next autoRecallHook re-injects profile
738
+ if (input.sessionID) {
739
+ profileInjectedSessions.delete(input.sessionID);
740
+ logDebug("compactingHook cleared profile TTL for re-injection", { sessionID: input.sessionID });
1399
741
  }
1400
742
  };
1401
743
  }
@@ -1483,54 +825,106 @@ export function autocontinueHook(client, containerTags, tui, ingestMode = "smart
1483
825
  }
1484
826
  const processedMessageIds = new Set();
1485
827
  const pluginStartTime = Date.now();
1486
- // ── Soul Whisper: pending tool call tracking (per-session isolation) ──
1487
- export const pendingToolCalls = new Map();
1488
- export function soulWhisperToolTracker(config) {
1489
- return async (input, _output) => {
1490
- if (config.soulWhisper?.enabled === false) {
1491
- logDebug("soulWhisperToolTracker disabled by config", { tool: input.tool });
828
+ export function sessionIdleHook(cerebroClient, containerTags, tui, sdkClient, ingestMode = "smart", threshold = 0, getMainSessionId, isAutoStoreEnabled, agentId, config = {}, onAgentResolved, directory) {
829
+ let idleTimeout = null;
830
+ let isCapturing = false;
831
+ async function handleSummaryCapture(props) {
832
+ const info = props?.info;
833
+ if (!info)
834
+ return;
835
+ if (info.role !== "assistant" || !info.summary || !info.finish)
836
+ return;
837
+ const sessionID = info.sessionID;
838
+ if (!sessionID)
839
+ return;
840
+ if (summarizedSessions.has(sessionID))
841
+ return;
842
+ summarizedSessions.add(sessionID);
843
+ if (!sdkClient) {
844
+ logInfo("handleSummaryCapture skipped: no sdkClient", { sessionID });
1492
845
  return;
1493
846
  }
1494
- const sw = config.soulWhisper;
1495
- const toolName = input.tool;
1496
- const excludeTools = sw?.excludeTools ?? [];
1497
- if (excludeTools.includes(toolName)) {
1498
- logDebug("soulWhisperToolTracker excluded", { tool: toolName });
847
+ logInfo("handleSummaryCapture triggered", { sessionID });
848
+ if (getMainSessionId) {
849
+ const mainId = getMainSessionId();
850
+ if (mainId && sessionID !== mainId) {
851
+ logInfo("handleSummaryCapture: non-main session skipped", { sessionID, mainSessionId: mainId });
852
+ return;
853
+ }
854
+ }
855
+ const effectiveAgentId = agentId || process.env.OMEM_AGENT_ID || "opencode";
856
+ const policy = resolveAgentPolicy(effectiveAgentId, config);
857
+ if (policy !== "readwrite") {
858
+ logInfo("handleSummaryCapture blocked by policy", { agentId: effectiveAgentId, policy });
1499
859
  return;
1500
860
  }
1501
- const includeTools = sw?.tools ?? ["*"];
1502
- const isWildcard = includeTools.includes("*");
1503
- if (!isWildcard && !includeTools.includes(toolName)) {
1504
- logDebug("soulWhisperToolTracker not in whitelist", { tool: toolName, whitelist: includeTools });
861
+ if (isAutoStoreEnabled && !isAutoStoreEnabled(sessionID))
1505
862
  return;
863
+ try {
864
+ const resp = await sdkClient.session.messages({ path: { id: sessionID } });
865
+ const messages = resp?.data ?? resp;
866
+ const summaryMsg = messages.find((m) => m.info?.role === "assistant" && m.info?.summary === true);
867
+ if (!summaryMsg?.parts) {
868
+ logInfo("handleSummaryCapture: no summary parts found", { sessionID });
869
+ return;
870
+ }
871
+ const textParts = summaryMsg.parts.filter((p) => p.type === "text" && p.text).map((p) => p.text);
872
+ const summaryContent = textParts.join("\n").trim();
873
+ if (!summaryContent || summaryContent.length < 100) {
874
+ logInfo("handleSummaryCapture: summary too short", { sessionID, length: summaryContent?.length ?? 0 });
875
+ return;
876
+ }
877
+ const effectiveSessionId = getMainSessionId?.() || sessionID;
878
+ let projectName;
879
+ let projectPath;
880
+ try {
881
+ const sessionInfo = await sdkClient.session.get({ path: { id: sessionID } });
882
+ projectPath = sessionInfo?.data?.directory || directory || process.env.OMEM_PROJECT_DIR;
883
+ projectName = sessionInfo?.data?.directory
884
+ ? await detectProjectName(sessionInfo.data.directory)
885
+ : undefined;
886
+ }
887
+ catch (e) {
888
+ logErr("handleSummaryCapture detectProjectName failed", { error: String(e) });
889
+ }
890
+ if (!projectPath) {
891
+ projectPath = directory || process.env.OMEM_PROJECT_DIR;
892
+ }
893
+ const prefixedSummary = `[Session Summary] ${summaryContent}`;
894
+ const result = await cerebroClient.ingestMessages([{ role: "user", content: prefixedSummary }], {
895
+ mode: ingestMode,
896
+ tags: [...containerTags, "auto-capture", "compact-summary"],
897
+ sessionId: effectiveSessionId,
898
+ projectName,
899
+ agentId: effectiveAgentId,
900
+ projectPath,
901
+ });
902
+ logInfo("handleSummaryCapture store result", { result: result === null ? "null(blocked)" : "ok" });
903
+ if (result !== null) {
904
+ showToast(tui, "📦 Compact Summary Stored", "Session summary archived", "success");
905
+ }
1506
906
  }
1507
- const sid = input.sessionID || "_default";
1508
- let sessionMap = pendingToolCalls.get(sid);
1509
- if (!sessionMap) {
1510
- sessionMap = new Map();
1511
- pendingToolCalls.set(sid, sessionMap);
907
+ catch (err) {
908
+ logErr("handleSummaryCapture failed", { error: String(err) });
1512
909
  }
1513
- sessionMap.set(input.callID, { toolName, timestamp: Date.now() });
1514
- logDebug("soulWhisperToolTracker recorded", { tool: toolName, callID: input.callID, sessionID: sid, totalSessions: pendingToolCalls.size, sessionCallCount: sessionMap.size });
1515
- };
1516
- }
1517
- export function buildWhisperText(toolNames, maxToolNames) {
1518
- if (toolNames.length === 0)
1519
- return null;
1520
- const lines = ["<cerebro-memory-activation>"];
1521
- if (toolNames.length <= maxToolNames) {
1522
- lines.push(`Before using ${toolNames.join(", ")}, memory_search() may surface relevant past decisions or patterns. Brief recall → better outcomes.`);
1523
- }
1524
- else {
1525
- 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.");
1526
910
  }
1527
- lines.push("</cerebro-memory-activation>");
1528
- return lines.join("\n");
1529
- }
1530
- export function sessionIdleHook(cerebroClient, _containerTags, tui, sdkClient, _ingestMode = "smart", threshold = 0, getMainSessionId, isAutoStoreEnabled, agentId, config = {}, onAgentResolved, directory) {
1531
- let idleTimeout = null;
1532
- let isCapturing = false;
1533
911
  return async (input) => {
912
+ if (input.event.type === "message.updated") {
913
+ await handleSummaryCapture(input.event.properties);
914
+ return;
915
+ }
916
+ if (input.event.type === "session.deleted") {
917
+ const sessionInfo = input.event.properties?.info;
918
+ const sid = sessionInfo?.id;
919
+ if (sid) {
920
+ summarizedSessions.delete(sid);
921
+ sessionMessages.delete(sid);
922
+ profileInjectedSessions.delete(sid);
923
+ firstMessages.delete(sid);
924
+ logDebug("sessionIdleHook: session.deleted cleanup", { sessionID: sid });
925
+ }
926
+ return;
927
+ }
1534
928
  if (input.event.type !== "session.idle")
1535
929
  return;
1536
930
  logDebug("sessionIdleHook event.properties dump", { keys: Object.keys(input.event.properties || {}), raw: JSON.stringify(input.event.properties).substring(0, 2000) });
@@ -1634,9 +1028,6 @@ export function sessionIdleHook(cerebroClient, _containerTags, tui, sdkClient, _
1634
1028
  finally {
1635
1029
  isCapturing = false;
1636
1030
  idleTimeout = null;
1637
- const deleted = pendingToolCalls.delete(sessionID);
1638
- if (deleted)
1639
- logDebug("sessionIdleHook cleared session pendingToolCalls", { sessionID, hadPending: deleted });
1640
1031
  }
1641
1032
  }, 10000);
1642
1033
  };