@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/dist/hooks.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { resolveAgentPolicy } from "./config.js";
2
- import { detectKeyword, KEYWORD_NUDGE } from "./keywords.js";
2
+ import { detectSaveKeyword, KEYWORD_NUDGE } from "./keywords.js";
3
3
  import { logDebug, logInfo, logError as logErr } from "./logger.js";
4
4
  import { readFile } from "node:fs/promises";
5
5
  import { stripPrivateContent } from "./privacy.js";
@@ -157,13 +157,15 @@ function extractUserRequest(content) {
157
157
  }
158
158
  return text;
159
159
  }
160
- const keywordDetectedSessions = new Set();
160
+ const saveKeywordDetectedSessions = new Set();
161
161
  const injectedMemoryIds = new Map();
162
162
  const firstMessages = new Map();
163
163
  const sessionMessages = new Map();
164
164
  const profileInjectedSessions = new Map();
165
165
  const injectedSessions = new Set();
166
166
  const compactingSummaryCooldown = new Map();
167
+ // Per-session async cache for fire-and-forget recall results
168
+ const recallCache = new Map();
167
169
  function hashString(str) {
168
170
  let hash = 0;
169
171
  for (let i = 0; i < str.length; i++) {
@@ -535,9 +537,9 @@ export function autoRecallHook(client, containerTags, tui, config = {}, getAgent
535
537
  : `${memCountMsg.trim()}${catSummary ? ` · ${catSummary}` : ""}`;
536
538
  }
537
539
  showToast(tui, toastTitle, toastMessage, "success", toastDelayMs);
538
- if (keywordDetectedSessions.has(input.sessionID)) {
540
+ if (saveKeywordDetectedSessions.has(input.sessionID)) {
539
541
  appendToSystem(output.system, KEYWORD_NUDGE);
540
- keywordDetectedSessions.delete(input.sessionID);
542
+ saveKeywordDetectedSessions.delete(input.sessionID);
541
543
  }
542
544
  }
543
545
  catch (err) {
@@ -564,6 +566,28 @@ export function autoRecallHook(client, containerTags, tui, config = {}, getAgent
564
566
  }
565
567
  };
566
568
  }
569
+ 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
+ }
567
591
  export function memoryInjectionHook(client, containerTags, tui, config = {}, getAgentName, directory) {
568
592
  const similarityThreshold = config.recall?.similarityThreshold ?? 0.4;
569
593
  const maxRecallResults = config.recall?.maxRecallResults ?? 10;
@@ -585,51 +609,11 @@ export function memoryInjectionHook(client, containerTags, tui, config = {}, get
585
609
  const policy = resolveAgentPolicy(agentId, config);
586
610
  if (policy === "none")
587
611
  return;
588
- const isFirstInjection = !injectedSessions.has(input.sessionID);
589
- const isKeywordTriggered = keywordDetectedSessions.has(input.sessionID);
590
- if (!isFirstInjection && !isKeywordTriggered)
591
- return;
612
+ const isSaveKeyword = saveKeywordDetectedSessions.has(input.sessionID);
592
613
  try {
593
- logDebug("memoryInjectionHook start", { sessionId: input.sessionID, agentId, policy, isFirstInjection, isKeywordTriggered, similarityThreshold, maxRecallResults });
614
+ logDebug("memoryInjectionHook start", { sessionId: input.sessionID, agentId, policy, isSaveKeyword, similarityThreshold, maxRecallResults });
594
615
  const messages = sessionMessages.get(input.sessionID) ?? [];
595
616
  const userMessages = messages.filter((m) => m.role === "user");
596
- // --- Profile Fetch ---
597
- const profile = await client.getProfile();
598
- let profileInjected = false;
599
- let profileCountText = "";
600
- let profileBlock = "";
601
- const lastInjected = profileInjectedSessions.get(input.sessionID);
602
- const ttlExpired = !lastInjected || (Date.now() - lastInjected > 30 * 60 * 1000);
603
- const profileIsFirstInjection = !lastInjected;
604
- if (profile && ttlExpired) {
605
- const prefs = (profile?.static_facts ?? [])
606
- .filter((sf) => {
607
- const t = sf.tags ?? [];
608
- return t.includes("preferences");
609
- })
610
- .map((sf) => sf.l2_content ?? sf.content ?? "")
611
- .filter(Boolean);
612
- const profileLines = prefs.length > 0
613
- ? prefs.map((c) => ` · ${c}`).join("\n")
614
- : " · (preferences queuing, will populate on next refresh)";
615
- profileBlock = [
616
- "<cerebro-profile>",
617
- profileLines,
618
- "</cerebro-profile>",
619
- ].join("\n");
620
- profileInjected = true;
621
- profileInjectedSessions.set(input.sessionID, Date.now());
622
- const p = profile;
623
- const dynamicCount = p?.dynamic_context?.length ?? 0;
624
- const staticCount = p?.static_facts?.length ?? 0;
625
- profileCountText = `Dynamic(${dynamicCount}) · Static(${staticCount})`;
626
- if (profileIsFirstInjection) {
627
- logDebug("memoryInjectionHook profile ready (first)", { dynamicCount, staticCount });
628
- }
629
- else {
630
- logDebug("memoryInjectionHook profile ready (TTL)", { dynamicCount, staticCount });
631
- }
632
- }
633
617
  if (userMessages.length === 0) {
634
618
  logDebug("memoryInjectionHook skipped: no user messages in session (post-compacting?)", { sessionId: input.sessionID });
635
619
  return;
@@ -637,7 +621,7 @@ export function memoryInjectionHook(client, containerTags, tui, config = {}, get
637
621
  const rawQuery = userMessages[userMessages.length - 1]?.content || firstMessages.get(input.sessionID) || "";
638
622
  const query_text = extractUserRequest(rawQuery);
639
623
  if (!query_text) {
640
- logDebug("memoryInjectionHook filtered system injection (profile already injected above)", { rawQueryPrefix: rawQuery.slice(0, 60) });
624
+ logDebug("memoryInjectionHook filtered system injection", { rawQueryPrefix: rawQuery.slice(0, 60) });
641
625
  return;
642
626
  }
643
627
  const last_query_text = userMessages.length >= 2 ? userMessages[userMessages.length - 2].content : undefined;
@@ -648,28 +632,239 @@ export function memoryInjectionHook(client, containerTags, tui, config = {}, get
648
632
  return stripped.length > 200 ? stripped.slice(0, 200) : stripped;
649
633
  })
650
634
  : undefined;
651
- const shouldRecallRes = await client.shouldRecall(query_text, last_query_text, input.sessionID, similarityThreshold, maxRecallResults, projectTags.length > 0 ? projectTags : undefined, conversationContext && conversationContext.length > 0 ? conversationContext : undefined, {
652
- fetch_multiplier: fetchMultiplier,
653
- topk_cap_multiplier: topkCapMultiplier,
654
- mmr_jaccard_threshold: mmrJaccardThreshold,
655
- mmr_penalty_factor: mmrPenaltyFactor,
656
- phase2_multiplier: phase2Multiplier,
657
- llm_max_eval: llmMaxEval,
658
- refine_strategy: refineStrategy,
659
- refine_medium_chars: refineMediumChars,
660
- }, directory || process.env.OMEM_PROJECT_DIR);
661
- if (!shouldRecallRes) {
662
- showToast(tui, "🧠 Cerebro Service Unavailable", "Unable to reach memory API · check connection", "error", toastDelayMs);
663
- return;
635
+ // ========== Phase A: synchronous path (zero await) ==========
636
+ const cached = recallCache.get(input.sessionID);
637
+ let profileBlock = "";
638
+ let profileInjected = false;
639
+ let profileCountText = "";
640
+ if (cached) {
641
+ // Phase A: 只读 profileBlock,不更新 TTL(TTL 管理完全由 Phase B 负责)
642
+ if (cached.profileBlock) {
643
+ profileBlock = cached.profileBlock;
644
+ profileInjected = true;
645
+ profileCountText = cached.profileData?.countText ?? "";
646
+ }
647
+ const shouldRecallRes = cached.recallResult;
648
+ if (!shouldRecallRes.should_recall) {
649
+ const partsToInject = [];
650
+ if (profileBlock)
651
+ partsToInject.push(profileBlock);
652
+ if (partsToInject.length > 0) {
653
+ const injectText = partsToInject.join("\n\n");
654
+ const contextPart = {
655
+ id: `prt_cerebro-context-${Date.now()}`,
656
+ sessionID: input.sessionID,
657
+ messageID: output.message.id,
658
+ type: "text",
659
+ text: injectText,
660
+ synthetic: true,
661
+ };
662
+ output.parts.unshift(contextPart);
663
+ logDebug("memoryInjectionHook profile injected from cache (no-recall)", { sessionId: input.sessionID });
664
+ }
665
+ injectedSessions.add(input.sessionID);
666
+ }
667
+ else {
668
+ const results = shouldRecallRes.memories ?? [];
669
+ const clustered = shouldRecallRes.clustered;
670
+ const existingIds = injectedMemoryIds.get(input.sessionID) ?? new Set();
671
+ const newResults = results.filter((r) => !existingIds.has(r.memory.id));
672
+ logDebug("memoryInjectionHook dedup (cached)", { totalResults: results.length, existingCount: existingIds.size, newCount: newResults.length });
673
+ if (newResults.length === 0) {
674
+ const partsToInject = [];
675
+ if (profileBlock)
676
+ partsToInject.push(profileBlock);
677
+ if (partsToInject.length > 0) {
678
+ const injectText = partsToInject.join("\n\n");
679
+ const contextPart = {
680
+ id: `prt_cerebro-context-${Date.now()}`,
681
+ sessionID: input.sessionID,
682
+ messageID: output.message.id,
683
+ type: "text",
684
+ text: injectText,
685
+ synthetic: true,
686
+ };
687
+ output.parts.unshift(contextPart);
688
+ logDebug("memoryInjectionHook profile injected from cache (dedup)", { sessionId: input.sessionID });
689
+ }
690
+ injectedSessions.add(input.sessionID);
691
+ }
692
+ else {
693
+ const profileChars = profileInjected ? profileBlock.length : 0;
694
+ const budgetRemaining = maxContentChars - profileChars;
695
+ const itemCount = clustered
696
+ ? (clustered.cluster_summaries.length + clustered.standalone_memories.length)
697
+ : newResults.length;
698
+ const dynamicMaxContentLength = itemCount > 0
699
+ ? Math.min(maxContentLength, Math.max(MIN_ITEM_CONTENT_CHARS, Math.floor(budgetRemaining / itemCount)))
700
+ : maxContentLength;
701
+ const block = clustered
702
+ ? buildClusteredContextBlock(clustered, dynamicMaxContentLength)
703
+ : buildContextBlock(newResults, dynamicMaxContentLength);
704
+ const partsToInject = [];
705
+ if (block)
706
+ partsToInject.push(block);
707
+ if (block)
708
+ partsToInject.push(FETCH_POLICY);
709
+ if (profileBlock)
710
+ partsToInject.push(profileBlock);
711
+ if (isSaveKeyword)
712
+ partsToInject.push(KEYWORD_NUDGE);
713
+ if (partsToInject.length > 0) {
714
+ const injectText = partsToInject.join("\n\n");
715
+ const contextPart = {
716
+ id: `prt_cerebro-context-${Date.now()}`,
717
+ sessionID: input.sessionID,
718
+ messageID: output.message.id,
719
+ type: "text",
720
+ text: injectText,
721
+ synthetic: true,
722
+ };
723
+ output.parts.unshift(contextPart);
724
+ logDebug("memoryInjectionHook block injected from cache", {
725
+ sessionId: input.sessionID,
726
+ injectTextLen: injectText.length,
727
+ blockPreview: block?.slice(0, 200),
728
+ });
729
+ }
730
+ injectedSessions.add(input.sessionID);
731
+ if (isSaveKeyword) {
732
+ saveKeywordDetectedSessions.delete(input.sessionID);
733
+ }
734
+ const newIds = newResults.map((r) => r.memory.id);
735
+ injectedMemoryIds.set(input.sessionID, new Set([...existingIds, ...newIds]));
736
+ const memDynamic = newResults.filter((r) => r.memory.memory_type === "fact" || r.memory.memory_type === "event").length;
737
+ const memStatic = newResults.filter((r) => r.memory.memory_type === "pinned" || r.memory.memory_type === "preference").length;
738
+ const memOther = newResults.length - memDynamic - memStatic;
739
+ let memCountMsg = "";
740
+ if (memDynamic > 0)
741
+ memCountMsg += `Dynamic(${memDynamic}) `;
742
+ if (memStatic > 0)
743
+ memCountMsg += `Static(${memStatic}) `;
744
+ if (memOther > 0)
745
+ memCountMsg += `Other(${memOther}) `;
746
+ const categories = categorize(newResults);
747
+ const catSummary = Array.from(categories.entries())
748
+ .map(([label, items]) => `${label}(${items.length})`)
749
+ .join(" · ");
750
+ let toastTitle;
751
+ let toastMessage;
752
+ if (clustered) {
753
+ const clusterCount = clustered.cluster_summaries.length;
754
+ const standaloneCount = clustered.standalone_memories.length;
755
+ toastTitle = `🧠 Context Injected · ${clusterCount} 主题簇${standaloneCount > 0 ? ` · ${standaloneCount} 补充` : ""}`;
756
+ toastMessage = profileInjected
757
+ ? `Profile: ${profileCountText} · 聚合记忆展示`
758
+ : `聚合记忆展示`;
759
+ }
760
+ else {
761
+ toastTitle = `🧠 Context Injected · ${newResults.length} fragments`;
762
+ toastMessage = profileInjected
763
+ ? `Profile: ${profileCountText} · Memories: ${memCountMsg.trim()}${catSummary ? ` · ${catSummary}` : ""}`
764
+ : `${memCountMsg.trim()}${catSummary ? ` · ${catSummary}` : ""}`;
765
+ }
766
+ showToast(tui, toastTitle, toastMessage, "success", toastDelayMs);
767
+ }
768
+ }
769
+ logDebug("memoryInjectionHook cache hit, injection complete", { sessionId: input.sessionID });
664
770
  }
665
- logDebug("memoryInjectionHook shouldRecall result", { shouldRecall: shouldRecallRes.should_recall, confidence: shouldRecallRes.confidence, memCount: shouldRecallRes.memories?.length ?? 0, discardedCount: shouldRecallRes.discarded?.length ?? 0, clustered: !!shouldRecallRes.clustered });
666
- const storedMemoryIds = shouldRecallRes.memories?.map((r) => r.memory.id) ?? [];
667
- const storedDiscardedIds = shouldRecallRes.discarded?.map((d) => d.memory_id) ?? [];
668
- const maxScore = storedMemoryIds.length > 0
669
- ? Math.max(...(shouldRecallRes.memories?.map((r) => r.score) ?? [0]))
670
- : 0;
671
- const createEventAndReturn = async (injectedCount, keptCount, discardedCount, injectedContent) => {
672
- try {
771
+ else {
772
+ logDebug("memoryInjectionHook cache miss, first message in session", { sessionId: input.sessionID });
773
+ }
774
+ // ========== Phase B: fire-and-forget async fetch for NEXT round ==========
775
+ const bgSessionId = input.sessionID;
776
+ const bgQueryText = query_text;
777
+ const bgLastQueryText = last_query_text;
778
+ const bgConversationContext = conversationContext;
779
+ const bgProjectTags = projectTags.length > 0 ? projectTags : undefined;
780
+ const bgDirectory = directory || process.env.OMEM_PROJECT_DIR;
781
+ Promise.allSettled([
782
+ client.getProfile(),
783
+ client.shouldRecall(bgQueryText, bgLastQueryText, bgSessionId, similarityThreshold, maxRecallResults, bgProjectTags, bgConversationContext && bgConversationContext.length > 0 ? bgConversationContext : undefined, {
784
+ fetch_multiplier: fetchMultiplier,
785
+ topk_cap_multiplier: topkCapMultiplier,
786
+ mmr_jaccard_threshold: mmrJaccardThreshold,
787
+ mmr_penalty_factor: mmrPenaltyFactor,
788
+ phase2_multiplier: phase2Multiplier,
789
+ llm_max_eval: llmMaxEval,
790
+ refine_strategy: refineStrategy,
791
+ refine_medium_chars: refineMediumChars,
792
+ }, bgDirectory),
793
+ ])
794
+ .then(([profileRes, recallRes]) => {
795
+ if (recallRes.status === 'rejected') {
796
+ logErr("memoryInjectionHook shouldRecall failed", { error: String(recallRes.reason) });
797
+ return;
798
+ }
799
+ const profile = profileRes.status === 'fulfilled' ? profileRes.value : null;
800
+ const shouldRecallRes = recallRes.value;
801
+ if (!shouldRecallRes) {
802
+ showToast(tui, "🧠 Cerebro Service Unavailable", "Unable to reach memory API · check connection", "error", toastDelayMs);
803
+ return;
804
+ }
805
+ logDebug("memoryInjectionHook background fetch complete", {
806
+ sessionId: bgSessionId,
807
+ shouldRecall: shouldRecallRes.should_recall,
808
+ confidence: shouldRecallRes.confidence,
809
+ memCount: shouldRecallRes.memories?.length ?? 0,
810
+ });
811
+ if (shouldRecallRes.should_recall && !Array.isArray(shouldRecallRes.memories)) {
812
+ logErr("memoryInjectionHook shouldRecall returned incomplete data", {
813
+ shouldRecall: shouldRecallRes.should_recall,
814
+ hasMemories: !!shouldRecallRes.memories,
815
+ });
816
+ return;
817
+ }
818
+ let bgProfileBlock = "";
819
+ let bgProfileCountText = "";
820
+ let bgProfileInjected = false;
821
+ if (profile) {
822
+ const lastInjected = profileInjectedSessions.get(bgSessionId);
823
+ const ttlExpired = !lastInjected || (Date.now() - lastInjected > 30 * 60 * 1000);
824
+ if (ttlExpired) {
825
+ const built = buildProfileBlock(profile);
826
+ if (built) {
827
+ bgProfileBlock = built.block;
828
+ bgProfileCountText = built.countText;
829
+ bgProfileInjected = true;
830
+ }
831
+ }
832
+ }
833
+ recallCache.set(bgSessionId, {
834
+ profileBlock: bgProfileBlock,
835
+ recallResult: shouldRecallRes,
836
+ profileData: { countText: bgProfileCountText },
837
+ timestamp: Date.now(),
838
+ });
839
+ if (recallCache.size > 50) {
840
+ let oldestKey = null;
841
+ let oldestTime = Infinity;
842
+ for (const [k, v] of recallCache) {
843
+ if (v.timestamp < oldestTime) {
844
+ oldestTime = v.timestamp;
845
+ oldestKey = k;
846
+ }
847
+ }
848
+ if (oldestKey)
849
+ recallCache.delete(oldestKey);
850
+ }
851
+ if (shouldRecallRes.should_recall) {
852
+ const results = shouldRecallRes.memories ?? [];
853
+ const existingIds = injectedMemoryIds.get(bgSessionId) ?? new Set();
854
+ const newResults = results.filter((r) => !existingIds.has(r.memory.id));
855
+ if (newResults.length > 0) {
856
+ const newIds = newResults.map((r) => r.memory.id);
857
+ injectedMemoryIds.set(bgSessionId, new Set([...existingIds, ...newIds]));
858
+ }
859
+ const storedMemoryIds = shouldRecallRes.memories?.map((r) => r.memory.id) ?? [];
860
+ const storedDiscardedIds = shouldRecallRes.discarded?.map((d) => d.memory_id) ?? [];
861
+ const maxScore = storedMemoryIds.length > 0
862
+ ? Math.max(...(shouldRecallRes.memories?.map((r) => r.score) ?? [0]))
863
+ : 0;
864
+ const bgBlock = shouldRecallRes.clustered
865
+ ? buildClusteredContextBlock(shouldRecallRes.clustered, maxContentLength)
866
+ : buildContextBlock(newResults, maxContentLength);
867
+ const bgInjectedContent = bgBlock ?? undefined;
673
868
  const items = [
674
869
  ...(shouldRecallRes.memories?.map((r) => ({
675
870
  memory_id: r.memory.id,
@@ -686,169 +881,40 @@ export function memoryInjectionHook(client, containerTags, tui, config = {}, get
686
881
  is_kept: false,
687
882
  })) ?? []),
688
883
  ];
689
- const result = await client.createRecallEvent({
690
- session_id: input.sessionID,
884
+ client.createRecallEvent({
885
+ session_id: bgSessionId,
691
886
  recall_type: "auto",
692
- query_text,
887
+ query_text: bgQueryText,
693
888
  max_score: maxScore,
694
889
  llm_confidence: shouldRecallRes.confidence ?? 0,
695
- profile_injected: profileInjected,
696
- kept_count: keptCount,
697
- discarded_count: discardedCount,
698
- injected_count: injectedCount,
699
- profile_content: profileInjected && profileBlock ? profileBlock : undefined,
700
- injected_content: injectedContent,
890
+ profile_injected: bgProfileInjected,
891
+ kept_count: storedMemoryIds.length,
892
+ discarded_count: storedDiscardedIds.length,
893
+ injected_count: newResults.length,
894
+ profile_content: bgProfileInjected && bgProfileBlock ? bgProfileBlock : undefined,
895
+ injected_content: bgInjectedContent,
701
896
  items: items.length > 0 ? items : undefined,
897
+ }).catch((e) => {
898
+ logErr("memoryInjectionHook background createRecallEvent failed", { error: String(e) });
702
899
  });
703
- return result?.event_id;
704
900
  }
705
- catch (e) {
706
- logErr("memoryInjectionHook createRecallEvent failed", { error: String(e) });
707
- return undefined;
708
- }
709
- };
710
- // --- no-recall path: inject profile only ---
711
- if (!shouldRecallRes.should_recall) {
712
- const partsToInject = [];
713
- if (profileBlock)
714
- partsToInject.push(profileBlock);
715
- if (partsToInject.length > 0) {
716
- const injectText = partsToInject.join("\n\n");
717
- const contextPart = {
718
- id: `prt_cerebro-context-${Date.now()}`,
719
- sessionID: input.sessionID,
720
- messageID: output.message.id,
721
- type: "text",
722
- text: injectText,
723
- synthetic: true,
724
- };
725
- output.parts.unshift(contextPart);
726
- logDebug("memoryInjectionHook profile injected (no-recall path)", { sessionId: input.sessionID });
727
- }
728
- injectedSessions.add(input.sessionID);
729
- if (profileInjected && profileIsFirstInjection) {
730
- await createEventAndReturn(0, 0, 0);
731
- showToast(tui, "👨 Profile Injected", `${profileCountText} · no memory recall needed`, "success", toastDelayMs);
732
- }
733
- return;
734
- }
735
- const results = shouldRecallRes.memories ?? [];
736
- const clustered = shouldRecallRes.clustered;
737
- const existingIds = injectedMemoryIds.get(input.sessionID) ?? new Set();
738
- const newResults = results.filter((r) => !existingIds.has(r.memory.id));
739
- logDebug("memoryInjectionHook dedup", { totalResults: results.length, existingCount: existingIds.size, newCount: newResults.length });
740
- // --- dedup path: inject profile only ---
741
- if (newResults.length === 0) {
742
- const partsToInject = [];
743
- if (profileBlock)
744
- partsToInject.push(profileBlock);
745
- if (partsToInject.length > 0) {
746
- const injectText = partsToInject.join("\n\n");
747
- const contextPart = {
748
- id: `prt_cerebro-context-${Date.now()}`,
749
- sessionID: input.sessionID,
750
- messageID: output.message.id,
751
- type: "text",
752
- text: injectText,
753
- synthetic: true,
754
- };
755
- output.parts.unshift(contextPart);
756
- logDebug("memoryInjectionHook profile injected (dedup path)", { sessionId: input.sessionID });
901
+ })
902
+ .catch((err) => {
903
+ const errMsg = err instanceof Error ? err.message : String(err);
904
+ logErr("memoryInjectionHook background fetch failed", { error: errMsg });
905
+ if (errMsg.includes("[cerebro]")) {
906
+ const cleanMsg = errMsg.replace(/^\[cerebro\]\s*/, "");
907
+ if (cleanMsg.startsWith("500")) {
908
+ showToast(tui, "🧠 Cerebro Server Error", cleanMsg.substring(0, 200), "error");
909
+ }
910
+ else if (cleanMsg.includes("timed out")) {
911
+ showToast(tui, "🧠 Cerebro Service Timeout", cleanMsg.substring(0, 100), "error");
912
+ }
757
913
  }
758
- injectedSessions.add(input.sessionID);
759
- if (profileInjected && profileIsFirstInjection) {
760
- showToast(tui, "👨 Profile Injected", `${profileCountText} · all memories already injected`, "success", toastDelayMs);
914
+ else if (errMsg.includes("fetch") || errMsg.includes("network")) {
915
+ showToast(tui, "🧠 Cerebro Service Unavailable", "Network error · check API connection", "error");
761
916
  }
762
- return;
763
- }
764
- // --- Token Budget Calculation ---
765
- const profileChars = profileInjected ? profileBlock.length : 0;
766
- const budgetRemaining = maxContentChars - profileChars;
767
- if (budgetRemaining < 0) {
768
- logDebug("memoryInjectionHook budget overflow", { profileChars, maxContentChars, deficit: -budgetRemaining });
769
- }
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
- logDebug("memoryInjectionHook budget", {
777
- maxContentChars, profileChars, budgetRemaining, itemCount,
778
- configuredMax: maxContentLength, dynamicMax: dynamicMaxContentLength,
779
917
  });
780
- const block = clustered
781
- ? buildClusteredContextBlock(clustered, dynamicMaxContentLength)
782
- : buildContextBlock(newResults, dynamicMaxContentLength);
783
- // ★★★ Core change: inject via output.parts.unshift + synthetic:true ★★★
784
- const partsToInject = [];
785
- if (profileBlock)
786
- partsToInject.push(profileBlock);
787
- if (block)
788
- partsToInject.push(block);
789
- if (block)
790
- partsToInject.push(FETCH_POLICY);
791
- if (isKeywordTriggered)
792
- partsToInject.push(KEYWORD_NUDGE);
793
- if (partsToInject.length > 0) {
794
- const injectText = partsToInject.join("\n\n");
795
- const contextPart = {
796
- id: `prt_cerebro-context-${Date.now()}`,
797
- sessionID: input.sessionID,
798
- messageID: output.message.id,
799
- type: "text",
800
- text: injectText,
801
- synthetic: true,
802
- };
803
- output.parts.unshift(contextPart);
804
- logDebug("memoryInjectionHook block injected to output.parts", {
805
- sessionId: input.sessionID,
806
- injectTextLen: injectText.length,
807
- blockPreview: block?.slice(0, 200),
808
- });
809
- }
810
- else {
811
- logDebug("memoryInjectionHook no content to inject", { sessionId: input.sessionID });
812
- }
813
- injectedSessions.add(input.sessionID);
814
- if (isKeywordTriggered) {
815
- keywordDetectedSessions.delete(input.sessionID);
816
- }
817
- const newIds = newResults.map((r) => r.memory.id);
818
- injectedMemoryIds.set(input.sessionID, new Set([...existingIds, ...newIds]));
819
- logDebug("memoryInjectionHook injection complete", { newIds: newIds.length, clustered: !!clustered, sessionId: input.sessionID });
820
- await createEventAndReturn(newResults.length, storedMemoryIds.length, storedDiscardedIds.length, block || undefined);
821
- const memDynamic = newResults.filter((r) => r.memory.memory_type === "fact" || r.memory.memory_type === "event").length;
822
- const memStatic = newResults.filter((r) => r.memory.memory_type === "pinned" || r.memory.memory_type === "preference").length;
823
- const memOther = newResults.length - memDynamic - memStatic;
824
- let memCountMsg = "";
825
- if (memDynamic > 0)
826
- memCountMsg += `Dynamic(${memDynamic}) `;
827
- if (memStatic > 0)
828
- memCountMsg += `Static(${memStatic}) `;
829
- if (memOther > 0)
830
- memCountMsg += `Other(${memOther}) `;
831
- const categories = categorize(newResults);
832
- const catSummary = Array.from(categories.entries())
833
- .map(([label, items]) => `${label}(${items.length})`)
834
- .join(" · ");
835
- let toastTitle;
836
- let toastMessage;
837
- if (clustered) {
838
- const clusterCount = clustered.cluster_summaries.length;
839
- const standaloneCount = clustered.standalone_memories.length;
840
- toastTitle = `🧠 Context Injected · ${clusterCount} 主题簇${standaloneCount > 0 ? ` · ${standaloneCount} 补充` : ""}`;
841
- toastMessage = profileInjected
842
- ? `Profile: ${profileCountText} · 聚合记忆展示`
843
- : `聚合记忆展示`;
844
- }
845
- else {
846
- toastTitle = `🧠 Context Injected · ${newResults.length} fragments`;
847
- toastMessage = profileInjected
848
- ? `Profile: ${profileCountText} · Memories: ${memCountMsg.trim()}${catSummary ? ` · ${catSummary}` : ""}`
849
- : `${memCountMsg.trim()}${catSummary ? ` · ${catSummary}` : ""}`;
850
- }
851
- showToast(tui, toastTitle, toastMessage, "success", toastDelayMs);
852
918
  }
853
919
  catch (err) {
854
920
  const errMsg = err instanceof Error ? err.message : String(err);
@@ -885,8 +951,8 @@ export function keywordDetectionHook(_client, _containerTags, threshold, _tui, _
885
951
  if (!firstMessages.has(input.sessionID)) {
886
952
  firstMessages.set(input.sessionID, textContent);
887
953
  }
888
- if (detectKeyword(textContent)) {
889
- keywordDetectedSessions.add(input.sessionID);
954
+ if (detectSaveKeyword(textContent)) {
955
+ saveKeywordDetectedSessions.add(input.sessionID);
890
956
  logDebug("keywordDetectionHook triggered", { sessionId: input.sessionID });
891
957
  }
892
958
  const policy = resolveAgentPolicy(effectiveAgentId, config);
@@ -998,6 +1064,7 @@ export function compactingHook(client, containerTags, tui, ingestMode = "smart",
998
1064
  if (input.sessionID) {
999
1065
  sessionMessages.delete(input.sessionID);
1000
1066
  profileInjectedSessions.delete(input.sessionID);
1067
+ recallCache.delete(input.sessionID);
1001
1068
  firstMessages.delete(input.sessionID);
1002
1069
  }
1003
1070
  return;
@@ -1027,6 +1094,7 @@ export function compactingHook(client, containerTags, tui, ingestMode = "smart",
1027
1094
  if (isAutoStoreEnabled && !isAutoStoreEnabled(input.sessionID)) {
1028
1095
  sessionMessages.delete(input.sessionID);
1029
1096
  profileInjectedSessions.delete(input.sessionID);
1097
+ recallCache.delete(input.sessionID);
1030
1098
  firstMessages.delete(input.sessionID);
1031
1099
  }
1032
1100
  else {
@@ -1058,7 +1126,9 @@ export function compactingHook(client, containerTags, tui, ingestMode = "smart",
1058
1126
  }
1059
1127
  // Cleanup tracked messages regardless of ingest result
1060
1128
  sessionMessages.delete(input.sessionID);
1129
+ injectedSessions.delete(input.sessionID);
1061
1130
  profileInjectedSessions.delete(input.sessionID);
1131
+ recallCache.delete(input.sessionID);
1062
1132
  firstMessages.delete(input.sessionID);
1063
1133
  if (input.sessionID) {
1064
1134
  const deleted = pendingToolCalls.delete(input.sessionID);