@mingxy/cerebro 1.15.3 → 1.15.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/hooks.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { Model, UserMessage, Part } from "@opencode-ai/sdk";
2
- import type { CerebroClient, SearchResult } from "./client.js";
2
+ import type { CerebroClient, SearchResult, ShouldRecallResponse } from "./client.js";
3
3
  import { type OmemPluginConfig, resolveAgentPolicy } from "./config.js";
4
- import { detectKeyword, KEYWORD_NUDGE } from "./keywords.js";
4
+ import { detectSaveKeyword, KEYWORD_NUDGE } from "./keywords.js";
5
5
  import { logDebug, logInfo, logError as logErr } from "./logger.js";
6
6
  import { readFile } from "node:fs/promises";
7
7
  import { stripPrivateContent } from "./privacy.js";
@@ -167,7 +167,7 @@ function extractUserRequest(content: string): string {
167
167
  return text;
168
168
  }
169
169
 
170
- const keywordDetectedSessions = new Set<string>();
170
+ const saveKeywordDetectedSessions = new Set<string>();
171
171
  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 }>>();
@@ -175,6 +175,14 @@ const profileInjectedSessions = new Map<string, number>();
175
175
  const injectedSessions = new Set<string>();
176
176
  const compactingSummaryCooldown = new Map<string, number>();
177
177
 
178
+ // Per-session async cache for fire-and-forget recall results
179
+ const recallCache = new Map<string, {
180
+ profileBlock: string;
181
+ recallResult: ShouldRecallResponse;
182
+ profileData: { countText: string };
183
+ timestamp: number;
184
+ }>();
185
+
178
186
  function hashString(str: string): string {
179
187
  let hash = 0;
180
188
  for (let i = 0; i < str.length; i++) {
@@ -590,9 +598,9 @@ export function autoRecallHook(client: CerebroClient, containerTags: string[], t
590
598
 
591
599
  showToast(tui, toastTitle, toastMessage, "success", toastDelayMs);
592
600
 
593
- if (keywordDetectedSessions.has(input.sessionID)) {
601
+ if (saveKeywordDetectedSessions.has(input.sessionID)) {
594
602
  appendToSystem(output.system, KEYWORD_NUDGE);
595
- keywordDetectedSessions.delete(input.sessionID);
603
+ saveKeywordDetectedSessions.delete(input.sessionID);
596
604
  }
597
605
  } catch (err) {
598
606
  const errMsg = err instanceof Error ? err.message : String(err);
@@ -615,6 +623,29 @@ export function autoRecallHook(client: CerebroClient, containerTags: string[], t
615
623
  };
616
624
  }
617
625
 
626
+ 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
+
618
649
  export function memoryInjectionHook(
619
650
  client: CerebroClient,
620
651
  containerTags: string[],
@@ -647,52 +678,13 @@ export function memoryInjectionHook(
647
678
  const policy = resolveAgentPolicy(agentId, config);
648
679
  if (policy === "none") return;
649
680
 
650
- const isFirstInjection = !injectedSessions.has(input.sessionID);
651
- const isKeywordTriggered = keywordDetectedSessions.has(input.sessionID);
652
- if (!isFirstInjection && !isKeywordTriggered) return;
681
+ const isSaveKeyword = saveKeywordDetectedSessions.has(input.sessionID);
653
682
 
654
683
  try {
655
- logDebug("memoryInjectionHook start", { sessionId: input.sessionID, agentId, policy, isFirstInjection, isKeywordTriggered, similarityThreshold, maxRecallResults });
684
+ logDebug("memoryInjectionHook start", { sessionId: input.sessionID, agentId, policy, isSaveKeyword, similarityThreshold, maxRecallResults });
656
685
  const messages = sessionMessages.get(input.sessionID) ?? [];
657
686
  const userMessages = messages.filter((m) => m.role === "user");
658
687
 
659
- // --- Profile Fetch ---
660
- const profile = await client.getProfile();
661
- let profileInjected = false;
662
- let profileCountText = "";
663
- let profileBlock = "";
664
- const lastInjected = profileInjectedSessions.get(input.sessionID);
665
- const ttlExpired = !lastInjected || (Date.now() - lastInjected > 30 * 60 * 1000);
666
- const profileIsFirstInjection = !lastInjected;
667
- if (profile && ttlExpired) {
668
- const prefs = ((profile as any)?.static_facts ?? [])
669
- .filter((sf: any) => {
670
- const t: string[] = sf.tags ?? [];
671
- return t.includes("preferences");
672
- })
673
- .map((sf: any) => sf.l2_content ?? sf.content ?? "")
674
- .filter(Boolean);
675
- const profileLines = prefs.length > 0
676
- ? prefs.map((c: string) => ` · ${c}`).join("\n")
677
- : " · (preferences queuing, will populate on next refresh)";
678
- profileBlock = [
679
- "<cerebro-profile>",
680
- profileLines,
681
- "</cerebro-profile>",
682
- ].join("\n");
683
- profileInjected = true;
684
- profileInjectedSessions.set(input.sessionID, Date.now());
685
- const p = profile as any;
686
- const dynamicCount = p?.dynamic_context?.length ?? 0;
687
- const staticCount = p?.static_facts?.length ?? 0;
688
- profileCountText = `Dynamic(${dynamicCount}) · Static(${staticCount})`;
689
- if (profileIsFirstInjection) {
690
- logDebug("memoryInjectionHook profile ready (first)", { dynamicCount, staticCount });
691
- } else {
692
- logDebug("memoryInjectionHook profile ready (TTL)", { dynamicCount, staticCount });
693
- }
694
- }
695
-
696
688
  if (userMessages.length === 0) {
697
689
  logDebug("memoryInjectionHook skipped: no user messages in session (post-compacting?)", { sessionId: input.sessionID });
698
690
  return;
@@ -701,7 +693,7 @@ export function memoryInjectionHook(
701
693
  const rawQuery = userMessages[userMessages.length - 1]?.content || firstMessages.get(input.sessionID) || "";
702
694
  const query_text = extractUserRequest(rawQuery);
703
695
  if (!query_text) {
704
- logDebug("memoryInjectionHook filtered system injection (profile already injected above)", { rawQueryPrefix: rawQuery.slice(0, 60) });
696
+ logDebug("memoryInjectionHook filtered system injection", { rawQueryPrefix: rawQuery.slice(0, 60) });
705
697
  return;
706
698
  }
707
699
  const last_query_text = userMessages.length >= 2 ? userMessages[userMessages.length - 2].content : undefined;
@@ -715,228 +707,309 @@ export function memoryInjectionHook(
715
707
  })
716
708
  : undefined;
717
709
 
718
- const shouldRecallRes = await client.shouldRecall(
719
- query_text, last_query_text, input.sessionID,
720
- similarityThreshold, maxRecallResults,
721
- projectTags.length > 0 ? projectTags : undefined,
722
- conversationContext && conversationContext.length > 0 ? conversationContext : undefined,
723
- {
724
- fetch_multiplier: fetchMultiplier,
725
- topk_cap_multiplier: topkCapMultiplier,
726
- mmr_jaccard_threshold: mmrJaccardThreshold,
727
- mmr_penalty_factor: mmrPenaltyFactor,
728
- phase2_multiplier: phase2Multiplier,
729
- llm_max_eval: llmMaxEval,
730
- refine_strategy: refineStrategy,
731
- refine_medium_chars: refineMediumChars,
732
- },
733
- directory || process.env.OMEM_PROJECT_DIR,
734
- );
735
-
736
- if (!shouldRecallRes) {
737
- showToast(tui, "🧠 Cerebro Service Unavailable", "Unable to reach memory API · check connection", "error", toastDelayMs);
738
- return;
739
- }
740
- logDebug("memoryInjectionHook shouldRecall result", { shouldRecall: shouldRecallRes.should_recall, confidence: shouldRecallRes.confidence, memCount: shouldRecallRes.memories?.length ?? 0, discardedCount: shouldRecallRes.discarded?.length ?? 0, clustered: !!shouldRecallRes.clustered });
741
-
742
- const storedMemoryIds = shouldRecallRes.memories?.map((r) => r.memory.id) ?? [];
743
- const storedDiscardedIds = shouldRecallRes.discarded?.map((d) => d.memory_id) ?? [];
744
- const maxScore = storedMemoryIds.length > 0
745
- ? Math.max(...(shouldRecallRes.memories?.map((r) => r.score) ?? [0]))
746
- : 0;
747
-
748
- const createEventAndReturn = async (
749
- injectedCount: number,
750
- keptCount: number,
751
- discardedCount: number,
752
- injectedContent?: string,
753
- ): Promise<string | undefined> => {
754
- try {
755
- const items = [
756
- ...(shouldRecallRes.memories?.map((r) => ({
757
- memory_id: r.memory.id,
758
- score: r.score,
759
- refine_relevance: r.refine_relevance,
760
- refine_reasoning: r.refine_reasoning,
761
- is_kept: true,
762
- })) ?? []),
763
- ...(shouldRecallRes.discarded?.map((d) => ({
764
- memory_id: d.memory_id,
765
- score: d.score,
766
- refine_relevance: d.refine_relevance,
767
- refine_reasoning: d.refine_reasoning,
768
- is_kept: false,
769
- })) ?? []),
770
- ];
771
- const result = await client.createRecallEvent({
772
- session_id: input.sessionID!,
773
- recall_type: "auto",
774
- query_text,
775
- max_score: maxScore,
776
- llm_confidence: shouldRecallRes.confidence ?? 0,
777
- profile_injected: profileInjected,
778
- kept_count: keptCount,
779
- discarded_count: discardedCount,
780
- injected_count: injectedCount,
781
- profile_content: profileInjected && profileBlock ? profileBlock : undefined,
782
- injected_content: injectedContent,
783
- items: items.length > 0 ? items : undefined,
784
- });
785
- return result?.event_id;
786
- } catch (e) {
787
- logErr("memoryInjectionHook createRecallEvent failed", { error: String(e) });
788
- return undefined;
789
- }
790
- };
710
+ // ========== Phase A: synchronous path (zero await) ==========
711
+ const cached = recallCache.get(input.sessionID);
712
+ let profileBlock = "";
713
+ let profileInjected = false;
714
+ let profileCountText = "";
791
715
 
792
- // --- no-recall path: inject profile only ---
793
- if (!shouldRecallRes.should_recall) {
794
- const partsToInject: string[] = [];
795
- if (profileBlock) partsToInject.push(profileBlock);
796
- if (partsToInject.length > 0) {
797
- const injectText = partsToInject.join("\n\n");
798
- const contextPart: Part = {
799
- id: `prt_cerebro-context-${Date.now()}`,
800
- sessionID: input.sessionID,
801
- messageID: output.message.id,
802
- type: "text",
803
- text: injectText,
804
- synthetic: true,
805
- };
806
- output.parts.unshift(contextPart);
807
- logDebug("memoryInjectionHook profile injected (no-recall path)", { sessionId: input.sessionID });
808
- }
809
- injectedSessions.add(input.sessionID);
810
- if (profileInjected && profileIsFirstInjection) {
811
- await createEventAndReturn(0, 0, 0);
812
- showToast(tui, "👨 Profile Injected", `${profileCountText} · no memory recall needed`, "success", toastDelayMs);
716
+ if (cached) {
717
+ // Phase A: 只读 profileBlock,不更新 TTL(TTL 管理完全由 Phase B 负责)
718
+ if (cached.profileBlock) {
719
+ profileBlock = cached.profileBlock;
720
+ profileInjected = true;
721
+ profileCountText = cached.profileData?.countText ?? "";
813
722
  }
814
- return;
815
- }
816
723
 
817
- const results = shouldRecallRes.memories ?? [];
818
- const clustered = shouldRecallRes.clustered;
724
+ const shouldRecallRes = cached.recallResult;
725
+
726
+ if (!shouldRecallRes.should_recall) {
727
+ const partsToInject: string[] = [];
728
+ if (profileBlock) partsToInject.push(profileBlock);
729
+ if (partsToInject.length > 0) {
730
+ const injectText = partsToInject.join("\n\n");
731
+ const contextPart: Part = {
732
+ id: `prt_cerebro-context-${Date.now()}`,
733
+ sessionID: input.sessionID,
734
+ messageID: output.message.id,
735
+ type: "text",
736
+ text: injectText,
737
+ synthetic: true,
738
+ };
739
+ output.parts.unshift(contextPart);
740
+ logDebug("memoryInjectionHook profile injected from cache (no-recall)", { sessionId: input.sessionID });
741
+ }
742
+ injectedSessions.add(input.sessionID);
743
+ } else {
744
+ const results = shouldRecallRes.memories ?? [];
745
+ const clustered = shouldRecallRes.clustered;
746
+ const existingIds = injectedMemoryIds.get(input.sessionID) ?? new Set<string>();
747
+ const newResults = results.filter((r) => !existingIds.has(r.memory.id));
748
+ logDebug("memoryInjectionHook dedup (cached)", { totalResults: results.length, existingCount: existingIds.size, newCount: newResults.length });
749
+
750
+ if (newResults.length === 0) {
751
+ const partsToInject: string[] = [];
752
+ if (profileBlock) partsToInject.push(profileBlock);
753
+ if (partsToInject.length > 0) {
754
+ const injectText = partsToInject.join("\n\n");
755
+ const contextPart: Part = {
756
+ id: `prt_cerebro-context-${Date.now()}`,
757
+ sessionID: input.sessionID,
758
+ messageID: output.message.id,
759
+ type: "text",
760
+ text: injectText,
761
+ synthetic: true,
762
+ };
763
+ output.parts.unshift(contextPart);
764
+ logDebug("memoryInjectionHook profile injected from cache (dedup)", { sessionId: input.sessionID });
765
+ }
766
+ injectedSessions.add(input.sessionID);
767
+ } else {
768
+ const profileChars = profileInjected ? profileBlock.length : 0;
769
+ const budgetRemaining = maxContentChars - profileChars;
770
+ const itemCount = clustered
771
+ ? (clustered.cluster_summaries.length + clustered.standalone_memories.length)
772
+ : newResults.length;
773
+ const dynamicMaxContentLength = itemCount > 0
774
+ ? Math.min(maxContentLength, Math.max(MIN_ITEM_CONTENT_CHARS, Math.floor(budgetRemaining / itemCount)))
775
+ : maxContentLength;
776
+
777
+ const block = clustered
778
+ ? buildClusteredContextBlock(clustered, dynamicMaxContentLength)
779
+ : buildContextBlock(newResults, dynamicMaxContentLength);
780
+
781
+ const partsToInject: string[] = [];
782
+ if (block) partsToInject.push(block);
783
+ if (block) partsToInject.push(FETCH_POLICY);
784
+ if (profileBlock) partsToInject.push(profileBlock);
785
+ if (isSaveKeyword) partsToInject.push(KEYWORD_NUDGE);
786
+
787
+ if (partsToInject.length > 0) {
788
+ const injectText = partsToInject.join("\n\n");
789
+ const contextPart: Part = {
790
+ id: `prt_cerebro-context-${Date.now()}`,
791
+ sessionID: input.sessionID,
792
+ messageID: output.message.id,
793
+ type: "text",
794
+ text: injectText,
795
+ synthetic: true,
796
+ };
797
+ output.parts.unshift(contextPart);
798
+ logDebug("memoryInjectionHook block injected from cache", {
799
+ sessionId: input.sessionID,
800
+ injectTextLen: injectText.length,
801
+ blockPreview: block?.slice(0, 200),
802
+ });
803
+ }
819
804
 
820
- const existingIds = injectedMemoryIds.get(input.sessionID) ?? new Set<string>();
821
- const newResults = results.filter((r) => !existingIds.has(r.memory.id));
822
- logDebug("memoryInjectionHook dedup", { totalResults: results.length, existingCount: existingIds.size, newCount: newResults.length });
805
+ injectedSessions.add(input.sessionID);
823
806
 
824
- // --- dedup path: inject profile only ---
825
- if (newResults.length === 0) {
826
- const partsToInject: string[] = [];
827
- if (profileBlock) partsToInject.push(profileBlock);
828
- if (partsToInject.length > 0) {
829
- const injectText = partsToInject.join("\n\n");
830
- const contextPart: Part = {
831
- id: `prt_cerebro-context-${Date.now()}`,
832
- sessionID: input.sessionID,
833
- messageID: output.message.id,
834
- type: "text",
835
- text: injectText,
836
- synthetic: true,
837
- };
838
- output.parts.unshift(contextPart);
839
- logDebug("memoryInjectionHook profile injected (dedup path)", { sessionId: input.sessionID });
840
- }
841
- injectedSessions.add(input.sessionID);
842
- if (profileInjected && profileIsFirstInjection) {
843
- showToast(tui, "👨 Profile Injected", `${profileCountText} · all memories already injected`, "success", toastDelayMs);
844
- }
845
- return;
846
- }
807
+ if (isSaveKeyword) {
808
+ saveKeywordDetectedSessions.delete(input.sessionID);
809
+ }
847
810
 
848
- // --- Token Budget Calculation ---
849
- const profileChars = profileInjected ? profileBlock.length : 0;
850
- const budgetRemaining = maxContentChars - profileChars;
851
- if (budgetRemaining < 0) {
852
- logDebug("memoryInjectionHook budget overflow", { profileChars, maxContentChars, deficit: -budgetRemaining });
853
- }
854
- const itemCount = clustered
855
- ? (clustered.cluster_summaries.length + clustered.standalone_memories.length)
856
- : newResults.length;
857
- const dynamicMaxContentLength = itemCount > 0
858
- ? Math.min(maxContentLength, Math.max(MIN_ITEM_CONTENT_CHARS, Math.floor(budgetRemaining / itemCount)))
859
- : maxContentLength;
860
- logDebug("memoryInjectionHook budget", {
861
- maxContentChars, profileChars, budgetRemaining, itemCount,
862
- configuredMax: maxContentLength, dynamicMax: dynamicMaxContentLength,
863
- });
811
+ const newIds = newResults.map((r) => r.memory.id);
812
+ injectedMemoryIds.set(input.sessionID, new Set([...existingIds, ...newIds]));
813
+
814
+ const memDynamic = newResults.filter((r) => r.memory.memory_type === "fact" || r.memory.memory_type === "event").length;
815
+ const memStatic = newResults.filter((r) => r.memory.memory_type === "pinned" || r.memory.memory_type === "preference").length;
816
+ const memOther = newResults.length - memDynamic - memStatic;
817
+
818
+ let memCountMsg = "";
819
+ if (memDynamic > 0) memCountMsg += `Dynamic(${memDynamic}) `;
820
+ if (memStatic > 0) memCountMsg += `Static(${memStatic}) `;
821
+ if (memOther > 0) memCountMsg += `Other(${memOther}) `;
822
+
823
+ const categories = categorize(newResults);
824
+ const catSummary = Array.from(categories.entries())
825
+ .map(([label, items]) => `${label}(${items.length})`)
826
+ .join(" · ");
827
+
828
+ let toastTitle: string;
829
+ let toastMessage: string;
830
+
831
+ if (clustered) {
832
+ const clusterCount = clustered.cluster_summaries.length;
833
+ const standaloneCount = clustered.standalone_memories.length;
834
+ toastTitle = `🧠 Context Injected · ${clusterCount} 主题簇${standaloneCount > 0 ? ` · ${standaloneCount} 补充` : ""}`;
835
+ toastMessage = profileInjected
836
+ ? `Profile: ${profileCountText} · 聚合记忆展示`
837
+ : `聚合记忆展示`;
838
+ } else {
839
+ toastTitle = `🧠 Context Injected · ${newResults.length} fragments`;
840
+ toastMessage = profileInjected
841
+ ? `Profile: ${profileCountText} · Memories: ${memCountMsg.trim()}${catSummary ? ` · ${catSummary}` : ""}`
842
+ : `${memCountMsg.trim()}${catSummary ? ` · ${catSummary}` : ""}`;
843
+ }
864
844
 
865
- const block = clustered
866
- ? buildClusteredContextBlock(clustered, dynamicMaxContentLength)
867
- : buildContextBlock(newResults, dynamicMaxContentLength);
845
+ showToast(tui, toastTitle, toastMessage, "success", toastDelayMs);
846
+ }
847
+ }
868
848
 
869
- // ★★★ Core change: inject via output.parts.unshift + synthetic:true ★★★
870
- const partsToInject: string[] = [];
871
- if (profileBlock) partsToInject.push(profileBlock);
872
- if (block) partsToInject.push(block);
873
- if (block) partsToInject.push(FETCH_POLICY);
874
- if (isKeywordTriggered) partsToInject.push(KEYWORD_NUDGE);
875
-
876
- if (partsToInject.length > 0) {
877
- const injectText = partsToInject.join("\n\n");
878
- const contextPart: Part = {
879
- id: `prt_cerebro-context-${Date.now()}`,
880
- sessionID: input.sessionID,
881
- messageID: output.message.id,
882
- type: "text",
883
- text: injectText,
884
- synthetic: true,
885
- };
886
- output.parts.unshift(contextPart);
887
- logDebug("memoryInjectionHook block injected to output.parts", {
888
- sessionId: input.sessionID,
889
- injectTextLen: injectText.length,
890
- blockPreview: block?.slice(0, 200),
891
- });
849
+ logDebug("memoryInjectionHook cache hit, injection complete", { sessionId: input.sessionID });
892
850
  } else {
893
- logDebug("memoryInjectionHook no content to inject", { sessionId: input.sessionID });
851
+ logDebug("memoryInjectionHook cache miss, first message in session", { sessionId: input.sessionID });
894
852
  }
895
853
 
896
- injectedSessions.add(input.sessionID);
897
-
898
- if (isKeywordTriggered) {
899
- keywordDetectedSessions.delete(input.sessionID);
900
- }
901
-
902
- const newIds = newResults.map((r) => r.memory.id);
903
- injectedMemoryIds.set(input.sessionID, new Set([...existingIds, ...newIds]));
904
- logDebug("memoryInjectionHook injection complete", { newIds: newIds.length, clustered: !!clustered, sessionId: input.sessionID });
905
-
906
- await createEventAndReturn(newResults.length, storedMemoryIds.length, storedDiscardedIds.length, block || undefined);
854
+ // ========== Phase B: fire-and-forget async fetch for NEXT round ==========
855
+ const bgSessionId = input.sessionID;
856
+ const bgQueryText = query_text;
857
+ const bgLastQueryText = last_query_text;
858
+ const bgConversationContext = conversationContext;
859
+ const bgProjectTags = projectTags.length > 0 ? projectTags : undefined;
860
+ const bgDirectory = directory || process.env.OMEM_PROJECT_DIR;
861
+
862
+ Promise.allSettled([
863
+ client.getProfile(),
864
+ client.shouldRecall(
865
+ bgQueryText, bgLastQueryText, bgSessionId,
866
+ similarityThreshold, maxRecallResults,
867
+ bgProjectTags,
868
+ bgConversationContext && bgConversationContext.length > 0 ? bgConversationContext : undefined,
869
+ {
870
+ fetch_multiplier: fetchMultiplier,
871
+ topk_cap_multiplier: topkCapMultiplier,
872
+ mmr_jaccard_threshold: mmrJaccardThreshold,
873
+ mmr_penalty_factor: mmrPenaltyFactor,
874
+ phase2_multiplier: phase2Multiplier,
875
+ llm_max_eval: llmMaxEval,
876
+ refine_strategy: refineStrategy,
877
+ refine_medium_chars: refineMediumChars,
878
+ },
879
+ bgDirectory,
880
+ ),
881
+ ])
882
+ .then(([profileRes, recallRes]) => {
883
+ if (recallRes.status === 'rejected') {
884
+ logErr("memoryInjectionHook shouldRecall failed", { error: String(recallRes.reason) });
885
+ return;
886
+ }
887
+ const profile = profileRes.status === 'fulfilled' ? profileRes.value : null;
888
+ const shouldRecallRes = recallRes.value;
889
+ if (!shouldRecallRes) {
890
+ showToast(tui, "🧠 Cerebro Service Unavailable", "Unable to reach memory API · check connection", "error", toastDelayMs);
891
+ return;
892
+ }
893
+ logDebug("memoryInjectionHook background fetch complete", {
894
+ sessionId: bgSessionId,
895
+ shouldRecall: shouldRecallRes.should_recall,
896
+ confidence: shouldRecallRes.confidence,
897
+ memCount: shouldRecallRes.memories?.length ?? 0,
898
+ });
907
899
 
908
- const memDynamic = newResults.filter((r) => r.memory.memory_type === "fact" || r.memory.memory_type === "event").length;
909
- const memStatic = newResults.filter((r) => r.memory.memory_type === "pinned" || r.memory.memory_type === "preference").length;
910
- const memOther = newResults.length - memDynamic - memStatic;
900
+ if (shouldRecallRes.should_recall && !Array.isArray(shouldRecallRes.memories)) {
901
+ logErr("memoryInjectionHook shouldRecall returned incomplete data", {
902
+ shouldRecall: shouldRecallRes.should_recall,
903
+ hasMemories: !!shouldRecallRes.memories,
904
+ });
905
+ return;
906
+ }
911
907
 
912
- let memCountMsg = "";
913
- if (memDynamic > 0) memCountMsg += `Dynamic(${memDynamic}) `;
914
- if (memStatic > 0) memCountMsg += `Static(${memStatic}) `;
915
- if (memOther > 0) memCountMsg += `Other(${memOther}) `;
908
+ let bgProfileBlock = "";
909
+ let bgProfileCountText = "";
910
+ let bgProfileInjected = false;
911
+
912
+ if (profile) {
913
+ const lastInjected = profileInjectedSessions.get(bgSessionId);
914
+ const ttlExpired = !lastInjected || (Date.now() - lastInjected > 30 * 60 * 1000);
915
+ if (ttlExpired) {
916
+ const built = buildProfileBlock(profile);
917
+ if (built) {
918
+ bgProfileBlock = built.block;
919
+ bgProfileCountText = built.countText;
920
+ bgProfileInjected = true;
921
+ }
922
+ }
923
+ }
916
924
 
917
- const categories = categorize(newResults);
918
- const catSummary = Array.from(categories.entries())
919
- .map(([label, items]) => `${label}(${items.length})`)
920
- .join(" · ");
925
+ recallCache.set(bgSessionId, {
926
+ profileBlock: bgProfileBlock,
927
+ recallResult: shouldRecallRes,
928
+ profileData: { countText: bgProfileCountText },
929
+ timestamp: Date.now(),
930
+ });
921
931
 
922
- let toastTitle: string;
923
- let toastMessage: string;
932
+ if (recallCache.size > 50) {
933
+ let oldestKey: string | null = null;
934
+ let oldestTime = Infinity;
935
+ for (const [k, v] of recallCache) {
936
+ if (v.timestamp < oldestTime) {
937
+ oldestTime = v.timestamp;
938
+ oldestKey = k;
939
+ }
940
+ }
941
+ if (oldestKey) recallCache.delete(oldestKey);
942
+ }
924
943
 
925
- if (clustered) {
926
- const clusterCount = clustered.cluster_summaries.length;
927
- const standaloneCount = clustered.standalone_memories.length;
928
- toastTitle = `🧠 Context Injected · ${clusterCount} 主题簇${standaloneCount > 0 ? ` · ${standaloneCount} 补充` : ""}`;
929
- toastMessage = profileInjected
930
- ? `Profile: ${profileCountText} · 聚合记忆展示`
931
- : `聚合记忆展示`;
932
- } else {
933
- toastTitle = `🧠 Context Injected · ${newResults.length} fragments`;
934
- toastMessage = profileInjected
935
- ? `Profile: ${profileCountText} · Memories: ${memCountMsg.trim()}${catSummary ? ` · ${catSummary}` : ""}`
936
- : `${memCountMsg.trim()}${catSummary ? ` · ${catSummary}` : ""}`;
937
- }
944
+ if (shouldRecallRes.should_recall) {
945
+ const results = shouldRecallRes.memories ?? [];
946
+ const existingIds = injectedMemoryIds.get(bgSessionId) ?? new Set<string>();
947
+ const newResults = results.filter((r) => !existingIds.has(r.memory.id));
948
+ if (newResults.length > 0) {
949
+ const newIds = newResults.map((r) => r.memory.id);
950
+ injectedMemoryIds.set(bgSessionId, new Set([...existingIds, ...newIds]));
951
+ }
938
952
 
939
- showToast(tui, toastTitle, toastMessage, "success", toastDelayMs);
953
+ const storedMemoryIds = shouldRecallRes.memories?.map((r) => r.memory.id) ?? [];
954
+ const storedDiscardedIds = shouldRecallRes.discarded?.map((d) => d.memory_id) ?? [];
955
+ const maxScore = storedMemoryIds.length > 0
956
+ ? Math.max(...(shouldRecallRes.memories?.map((r) => r.score) ?? [0]))
957
+ : 0;
958
+
959
+ const bgBlock = shouldRecallRes.clustered
960
+ ? buildClusteredContextBlock(shouldRecallRes.clustered, maxContentLength)
961
+ : buildContextBlock(newResults, maxContentLength);
962
+ const bgInjectedContent = bgBlock ?? undefined;
963
+
964
+ const items = [
965
+ ...(shouldRecallRes.memories?.map((r) => ({
966
+ memory_id: r.memory.id,
967
+ score: r.score,
968
+ refine_relevance: r.refine_relevance,
969
+ refine_reasoning: r.refine_reasoning,
970
+ is_kept: true,
971
+ })) ?? []),
972
+ ...(shouldRecallRes.discarded?.map((d) => ({
973
+ memory_id: d.memory_id,
974
+ score: d.score,
975
+ refine_relevance: d.refine_relevance,
976
+ refine_reasoning: d.refine_reasoning,
977
+ is_kept: false,
978
+ })) ?? []),
979
+ ];
980
+
981
+ client.createRecallEvent({
982
+ session_id: bgSessionId,
983
+ recall_type: "auto",
984
+ query_text: bgQueryText,
985
+ max_score: maxScore,
986
+ llm_confidence: shouldRecallRes.confidence ?? 0,
987
+ profile_injected: bgProfileInjected,
988
+ kept_count: storedMemoryIds.length,
989
+ discarded_count: storedDiscardedIds.length,
990
+ injected_count: newResults.length,
991
+ profile_content: bgProfileInjected && bgProfileBlock ? bgProfileBlock : undefined,
992
+ injected_content: bgInjectedContent,
993
+ items: items.length > 0 ? items : undefined,
994
+ }).catch((e: unknown) => {
995
+ logErr("memoryInjectionHook background createRecallEvent failed", { error: String(e) });
996
+ });
997
+ }
998
+ })
999
+ .catch((err: unknown) => {
1000
+ const errMsg = err instanceof Error ? err.message : String(err);
1001
+ logErr("memoryInjectionHook background fetch failed", { error: errMsg });
1002
+ if (errMsg.includes("[cerebro]")) {
1003
+ const cleanMsg = errMsg.replace(/^\[cerebro\]\s*/, "");
1004
+ if (cleanMsg.startsWith("500")) {
1005
+ showToast(tui, "🧠 Cerebro Server Error", cleanMsg.substring(0, 200), "error");
1006
+ } else if (cleanMsg.includes("timed out")) {
1007
+ showToast(tui, "🧠 Cerebro Service Timeout", cleanMsg.substring(0, 100), "error");
1008
+ }
1009
+ } else if (errMsg.includes("fetch") || errMsg.includes("network")) {
1010
+ showToast(tui, "🧠 Cerebro Service Unavailable", "Network error · check API connection", "error");
1011
+ }
1012
+ });
940
1013
  } catch (err) {
941
1014
  const errMsg = err instanceof Error ? err.message : String(err);
942
1015
  if (errMsg.includes("[cerebro]")) {
@@ -974,8 +1047,8 @@ export function keywordDetectionHook(_client: CerebroClient, _containerTags: str
974
1047
  firstMessages.set(input.sessionID, textContent);
975
1048
  }
976
1049
 
977
- if (detectKeyword(textContent)) {
978
- keywordDetectedSessions.add(input.sessionID);
1050
+ if (detectSaveKeyword(textContent)) {
1051
+ saveKeywordDetectedSessions.add(input.sessionID);
979
1052
  logDebug("keywordDetectionHook triggered", { sessionId: input.sessionID });
980
1053
  }
981
1054
 
@@ -1100,6 +1173,7 @@ export function compactingHook(client: CerebroClient, containerTags: string[], t
1100
1173
  if (input.sessionID) {
1101
1174
  sessionMessages.delete(input.sessionID);
1102
1175
  profileInjectedSessions.delete(input.sessionID);
1176
+ recallCache.delete(input.sessionID);
1103
1177
  firstMessages.delete(input.sessionID);
1104
1178
  }
1105
1179
  return;
@@ -1131,6 +1205,7 @@ export function compactingHook(client: CerebroClient, containerTags: string[], t
1131
1205
  if (isAutoStoreEnabled && !isAutoStoreEnabled(input.sessionID)) {
1132
1206
  sessionMessages.delete(input.sessionID);
1133
1207
  profileInjectedSessions.delete(input.sessionID);
1208
+ recallCache.delete(input.sessionID);
1134
1209
  firstMessages.delete(input.sessionID);
1135
1210
  } else {
1136
1211
  const messages = sessionMessages.get(input.sessionID)!;
@@ -1159,7 +1234,9 @@ export function compactingHook(client: CerebroClient, containerTags: string[], t
1159
1234
  }
1160
1235
  // Cleanup tracked messages regardless of ingest result
1161
1236
  sessionMessages.delete(input.sessionID);
1237
+ injectedSessions.delete(input.sessionID);
1162
1238
  profileInjectedSessions.delete(input.sessionID);
1239
+ recallCache.delete(input.sessionID);
1163
1240
  firstMessages.delete(input.sessionID);
1164
1241
  if (input.sessionID) {
1165
1242
  const deleted = pendingToolCalls.delete(input.sessionID);