@mingxy/cerebro 1.14.6 → 1.15.2

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
@@ -130,7 +130,7 @@ function showToast(tui: any, title: string, message: string, variant: string = "
130
130
  try {
131
131
  tui.showToast({ body: { title, message, variant, duration: 5000 } });
132
132
  } catch (err) {
133
- console.error("[cerebro] showToast failed:", err);
133
+ logErr("showToast failed", { error: String(err) });
134
134
  }
135
135
  }, delayMs);
136
136
  }
@@ -172,6 +172,18 @@ const injectedMemoryIds = new Map<string, Set<string>>();
172
172
  const firstMessages = new Map<string, string>();
173
173
  const sessionMessages = new Map<string, Array<{ role: string; content: string }>>();
174
174
  const profileInjectedSessions = new Map<string, number>();
175
+ const injectedSessions = new Set<string>();
176
+ const compactingSummaryCooldown = new Map<string, number>();
177
+
178
+ function hashString(str: string): string {
179
+ let hash = 0;
180
+ for (let i = 0; i < str.length; i++) {
181
+ const char = str.charCodeAt(i);
182
+ hash = ((hash << 5) - hash) + char;
183
+ hash |= 0;
184
+ }
185
+ return hash.toString(36);
186
+ }
175
187
 
176
188
  function formatRelativeAge(isoDate: string): string {
177
189
  const diffMs = Date.now() - new Date(isoDate).getTime();
@@ -236,7 +248,9 @@ function formatMemoryLine(r: SearchResult, maxContentLength: number): string {
236
248
 
237
249
  const FETCH_POLICY = [
238
250
  "<cerebro-fetch-policy>",
239
- "Each memory above is a condensed summary with a retrievable ID. memory_get(\"id\") unlocks the full content your knowledge depth control. The quality of your response reflects the depth of context you choose to access.",
251
+ "IMPORTANT: Each memory above is a condensed summary. The full version contains critical details that may change your response quality.",
252
+ "You MUST use memory_get(\"id\") to retrieve the complete content, or memory_search(\"query\") to find specific memories before making decisions based on any summary.",
253
+ "Do NOT rely on condensed summaries alone — depth of recall determines quality of response.",
240
254
  "</cerebro-fetch-policy>",
241
255
  ].join("\n");
242
256
 
@@ -601,6 +615,348 @@ export function autoRecallHook(client: CerebroClient, containerTags: string[], t
601
615
  };
602
616
  }
603
617
 
618
+ export function memoryInjectionHook(
619
+ client: CerebroClient,
620
+ containerTags: string[],
621
+ tui: any,
622
+ config: Partial<OmemPluginConfig> = {},
623
+ getAgentName?: () => string,
624
+ directory?: string,
625
+ ) {
626
+ const similarityThreshold = config.recall?.similarityThreshold ?? 0.4;
627
+ const maxRecallResults = config.recall?.maxRecallResults ?? 10;
628
+ const fetchMultiplier = config.recall?.fetchMultiplier ?? 3;
629
+ const topkCapMultiplier = config.recall?.topkCapMultiplier ?? 2;
630
+ const mmrJaccardThreshold = config.recall?.mmrJaccardThreshold ?? 0.85;
631
+ const mmrPenaltyFactor = config.recall?.mmrPenaltyFactor ?? 0.5;
632
+ const phase2Multiplier = config.recall?.phase2Multiplier ?? 2;
633
+ const llmMaxEval = config.recall?.llmMaxEval ?? 15;
634
+ const refineStrategy = config.recall?.refineStrategy ?? "balanced";
635
+ const refineMediumChars = config.recall?.refineMediumChars ?? 200;
636
+ const maxContentLength = Math.max(MIN_CONTENT_LENGTH, config.content?.maxContentLength ?? 500);
637
+ const maxContentChars = Math.max(MIN_CONTENT_CHARS, config.content?.maxContentChars ?? 30000);
638
+ const toastDelayMs = config.ui?.toastDelayMs ?? 7000;
639
+
640
+ return async (
641
+ input: { sessionID?: string; messageID?: string; model: Model },
642
+ output: { message: UserMessage; parts: Part[] },
643
+ ) => {
644
+ if (!input.sessionID) return;
645
+
646
+ const agentId = getAgentName?.() || process.env.OMEM_AGENT_ID || "opencode";
647
+ const policy = resolveAgentPolicy(agentId, config);
648
+ if (policy === "none") return;
649
+
650
+ const isFirstInjection = !injectedSessions.has(input.sessionID);
651
+ const isKeywordTriggered = keywordDetectedSessions.has(input.sessionID);
652
+ if (!isFirstInjection && !isKeywordTriggered) return;
653
+
654
+ try {
655
+ logDebug("memoryInjectionHook start", { sessionId: input.sessionID, agentId, policy, isFirstInjection, isKeywordTriggered, similarityThreshold, maxRecallResults });
656
+ const messages = sessionMessages.get(input.sessionID) ?? [];
657
+ const userMessages = messages.filter((m) => m.role === "user");
658
+
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
+ if (userMessages.length === 0) {
697
+ logDebug("memoryInjectionHook skipped: no user messages in session (post-compacting?)", { sessionId: input.sessionID });
698
+ return;
699
+ }
700
+
701
+ const rawQuery = userMessages[userMessages.length - 1]?.content || firstMessages.get(input.sessionID) || "";
702
+ const query_text = extractUserRequest(rawQuery);
703
+ if (!query_text) {
704
+ logDebug("memoryInjectionHook filtered system injection (profile already injected above)", { rawQueryPrefix: rawQuery.slice(0, 60) });
705
+ return;
706
+ }
707
+ const last_query_text = userMessages.length >= 2 ? userMessages[userMessages.length - 2].content : undefined;
708
+
709
+ const projectTags = containerTags.filter(t => t.startsWith("omem_project_"));
710
+
711
+ const conversationContext = userMessages.length >= 2
712
+ ? userMessages.slice(-4, -1).map((m) => {
713
+ const stripped = stripPrivateContent(m.content);
714
+ return stripped.length > 200 ? stripped.slice(0, 200) : stripped;
715
+ })
716
+ : undefined;
717
+
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
+ };
791
+
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);
813
+ }
814
+ return;
815
+ }
816
+
817
+ const results = shouldRecallRes.memories ?? [];
818
+ const clustered = shouldRecallRes.clustered;
819
+
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 });
823
+
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
+ }
847
+
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
+ });
864
+
865
+ const block = clustered
866
+ ? buildClusteredContextBlock(clustered, dynamicMaxContentLength)
867
+ : buildContextBlock(newResults, dynamicMaxContentLength);
868
+
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
+ });
892
+ } else {
893
+ logDebug("memoryInjectionHook no content to inject", { sessionId: input.sessionID });
894
+ }
895
+
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);
907
+
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;
911
+
912
+ let memCountMsg = "";
913
+ if (memDynamic > 0) memCountMsg += `Dynamic(${memDynamic}) `;
914
+ if (memStatic > 0) memCountMsg += `Static(${memStatic}) `;
915
+ if (memOther > 0) memCountMsg += `Other(${memOther}) `;
916
+
917
+ const categories = categorize(newResults);
918
+ const catSummary = Array.from(categories.entries())
919
+ .map(([label, items]) => `${label}(${items.length})`)
920
+ .join(" · ");
921
+
922
+ let toastTitle: string;
923
+ let toastMessage: string;
924
+
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
+ }
938
+
939
+ showToast(tui, toastTitle, toastMessage, "success", toastDelayMs);
940
+ } catch (err) {
941
+ const errMsg = err instanceof Error ? err.message : String(err);
942
+ if (errMsg.includes("[cerebro]")) {
943
+ const cleanMsg = errMsg.replace(/^\[cerebro\]\s*/, "");
944
+ if (cleanMsg.startsWith("500")) {
945
+ showToast(tui, "🧠 Cerebro Server Error", cleanMsg.substring(0, 200), "error");
946
+ } else if (cleanMsg.includes("timed out")) {
947
+ showToast(tui, "🧠 Cerebro Service Timeout", cleanMsg.substring(0, 100), "error");
948
+ } else {
949
+ showToast(tui, "🧠 Cerebro Error", cleanMsg.substring(0, 150), "error");
950
+ }
951
+ } else if (errMsg.includes("fetch") || errMsg.includes("network")) {
952
+ showToast(tui, "🧠 Cerebro Service Unavailable", "Network error · check API connection", "error");
953
+ } else {
954
+ showToast(tui, "🧠 Memory Recall Error", errMsg.substring(0, 100), "error");
955
+ }
956
+ }
957
+ };
958
+ }
959
+
604
960
  export function keywordDetectionHook(_client: CerebroClient, _containerTags: string[], threshold: number, _tui: any, _ingestMode: "smart" | "raw" = "smart", config: Partial<OmemPluginConfig> = {}, agentId?: string) {
605
961
  const effectiveAgentId = agentId || process.env.OMEM_AGENT_ID || "opencode";
606
962
  return async (
@@ -643,6 +999,57 @@ export function keywordDetectionHook(_client: CerebroClient, _containerTags: str
643
999
  };
644
1000
  }
645
1001
 
1002
+ export function createCerebroCompactionPrompt(
1003
+ context: string[],
1004
+ projectMemories: SearchResult[],
1005
+ ): string {
1006
+ const sections: string[] = [
1007
+ "[Cerebro Compaction Context]",
1008
+ "",
1009
+ "## 1. User's Original Request",
1010
+ "Preserve the user's verbatim original request from the conversation above.",
1011
+ "",
1012
+ "## 2. Final Goal",
1013
+ "What is the ultimate objective the user wants to achieve?",
1014
+ "",
1015
+ "## 3. Work Completed",
1016
+ "List all completed work with file paths and technical decisions made.",
1017
+ "",
1018
+ "## 4. Remaining Tasks",
1019
+ "What is still unfinished or pending?",
1020
+ "",
1021
+ "## 5. Prohibited Actions",
1022
+ "Key constraints and forbidden operations to remember.",
1023
+ "",
1024
+ "## 6. Existing Project Knowledge",
1025
+ ];
1026
+
1027
+ if (projectMemories.length > 0) {
1028
+ const memBlock = projectMemories
1029
+ .slice(0, 10)
1030
+ .map((r) => {
1031
+ const content = r.memory.content ?? "";
1032
+ const truncated = content.length > 200 ? content.slice(0, 200) + "..." : content;
1033
+ return ` - [${r.memory.category ?? "general"}] ${truncated}`;
1034
+ })
1035
+ .join("\n");
1036
+ sections.push(memBlock);
1037
+ } else {
1038
+ sections.push(" (No project memories retrieved)");
1039
+ }
1040
+
1041
+ if (context.length > 0) {
1042
+ sections.push("");
1043
+ sections.push("### Additional Context");
1044
+ sections.push(...context);
1045
+ }
1046
+
1047
+ sections.push("");
1048
+ sections.push("IMPORTANT: Output must preserve the user's original language (Chinese/English/etc). Do not translate.");
1049
+
1050
+ return sections.join("\n");
1051
+ }
1052
+
646
1053
  export function compactingHook(client: CerebroClient, containerTags: string[], tui: any, ingestMode: "smart" | "raw" = "smart", isAutoStoreEnabled?: (sessionId: string | undefined) => boolean, getMainSessionId?: () => string | undefined, sdkClient?: any, config: Partial<OmemPluginConfig> = {}, agentId?: string, directory?: string) {
647
1054
  const effectiveAgentId = agentId || process.env.OMEM_AGENT_ID || "opencode";
648
1055
  return async (
@@ -654,18 +1061,18 @@ export function compactingHook(client: CerebroClient, containerTags: string[], t
654
1061
  // Search (read) always runs — even readonly agents need context during compacting
655
1062
  try {
656
1063
  const results = await client.searchMemories("*", 20, undefined, containerTags);
657
- const block = buildContextBlock(results);
658
- if (block) {
659
- if (output.context.length > 0) {
660
- output.context[output.context.length - 1] += "\n\n" + block;
661
- } else {
662
- output.context.push(block);
663
- }
664
- if (output.context.length > 0) {
665
- output.context[output.context.length - 1] += "\n\n" + FETCH_POLICY;
666
- } else {
667
- output.context.push(FETCH_POLICY);
668
- }
1064
+ const compactionPrompt = createCerebroCompactionPrompt(output.context, results);
1065
+ if (output.prompt !== undefined) {
1066
+ output.prompt = compactionPrompt;
1067
+ } else if (output.context.length > 0) {
1068
+ output.context[output.context.length - 1] += "\n\n" + compactionPrompt;
1069
+ } else {
1070
+ output.context.push(compactionPrompt);
1071
+ }
1072
+ if (output.context.length > 0) {
1073
+ output.context[output.context.length - 1] += "\n\n" + FETCH_POLICY;
1074
+ } else {
1075
+ output.context.push(FETCH_POLICY);
669
1076
  }
670
1077
  // 将compacting搜索结果的ID写入injectedMemoryIds,避免后续autoRecall重复注入
671
1078
  if (input.sessionID && results.length > 0) {
@@ -870,9 +1277,19 @@ export function compactingHook(client: CerebroClient, containerTags: string[], t
870
1277
  logInfo("compactingHook: storing compact summary", {
871
1278
  summaryLen: summaryText.length, msgId: compactMsg.info?.id,
872
1279
  });
1280
+ // Dedup check: 30s cooldown per session+content hash
1281
+ const summaryHash = `${pollSessionId}:${hashString(summaryText)}`;
1282
+ const lastCompacting = compactingSummaryCooldown.get(summaryHash);
1283
+ if (lastCompacting && Date.now() - lastCompacting < 30000) {
1284
+ logDebug("compactingHook summary dedup", { sessionId: pollSessionId });
1285
+ break;
1286
+ }
1287
+ compactingSummaryCooldown.set(summaryHash, Date.now());
1288
+
1289
+ const prefixedSummary = `[Session Summary] ${summaryText}`;
873
1290
  try {
874
1291
  const result = await client.ingestMessages(
875
- [{ role: "user" as const, content: summaryText }],
1292
+ [{ role: "user" as const, content: prefixedSummary }],
876
1293
  {
877
1294
  mode: ingestMode,
878
1295
  tags: [...containerTags, "auto-capture", "compact-summary"],
@@ -1064,9 +1481,9 @@ export function buildWhisperText(toolNames: string[], maxToolNames: number): str
1064
1481
  const lines: string[] = ["<cerebro-memory-activation>"];
1065
1482
 
1066
1483
  if (toolNames.length <= maxToolNames) {
1067
- lines.push(`Your ${toolNames.join(", ")} usage gets sharper with context. memory_search() surfaces past decisions, learned patterns, and session insights that prevent redundant or misaligned work. A moment of recall elevates every action.`);
1484
+ lines.push(`Before using ${toolNames.join(", ")}, memory_search() may surface relevant past decisions or patterns. Brief recall better outcomes.`);
1068
1485
  } else {
1069
- lines.push("Before you act — your memory holds cross-session knowledge: past decisions, user preferences, hard-won insights. memory_search() activates this advantage. The strongest responses are built on remembered context.");
1486
+ 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.");
1070
1487
  }
1071
1488
 
1072
1489
  lines.push("</cerebro-memory-activation>");
package/src/index.ts CHANGED
@@ -4,7 +4,7 @@ import { join, dirname } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { CerebroClient } from "./client.js";
7
- import { autoRecallHook, autocontinueHook, compactingHook, keywordDetectionHook, sessionIdleHook, soulWhisperToolTracker, pendingToolCalls, buildWhisperText } from "./hooks.js";
7
+ import { autoRecallHook, memoryInjectionHook, autocontinueHook, compactingHook, keywordDetectionHook, sessionIdleHook, soulWhisperToolTracker, pendingToolCalls, buildWhisperText } from "./hooks.js";
8
8
  import { getUserTag, getProjectTag } from "./tags.js";
9
9
  import { buildTools } from "./tools.js";
10
10
  import { logInfo, logDebug, logError } from "./logger.js";
@@ -55,7 +55,7 @@ function showToast(tui: any, title: string, message?: string, variant: string =
55
55
  }
56
56
  tui.showToast({ body });
57
57
  } catch (err) {
58
- console.error("[cerebro] showToast failed:", err);
58
+ logError("showToast failed", { error: String(err) });
59
59
  }
60
60
  }, 3000);
61
61
  }
@@ -119,11 +119,7 @@ const OmemPlugin: Plugin = async (input) => {
119
119
  let mainSessionLocked = false;
120
120
  let cachedAgentName: string | undefined;
121
121
 
122
- const recallHook = autoRecallHook(cerebroClient, containerTags, tui, config, () => cachedAgentName || agentId, directory);
123
-
124
- const wrappedRecallHook = async (input: any, output: any) => {
125
- await recallHook(input, output);
126
-
122
+ const soulWhisperSystemHook = async (input: any, output: any) => {
127
123
  // ── Soul Whisper: inject to system prompt (v2 — system.transform) ──
128
124
  if (config.soulWhisper?.enabled !== false) {
129
125
  const sid = input.sessionID || "_default";
@@ -145,6 +141,29 @@ const OmemPlugin: Plugin = async (input) => {
145
141
  }
146
142
  };
147
143
 
144
+ // ── Fallback strategy: "parts" (new) vs "system" (legacy) ──
145
+ const strategy = config.injectionStrategy ?? "parts";
146
+
147
+ const chatMessageHook = strategy === "parts"
148
+ ? async (input: any, output: any) => {
149
+ // New path: keyword detection + memory injection
150
+ await keywordDetectionHook(cerebroClient, containerTags, config.ingest.autoCaptureThreshold, tui, config.ingest.ingestMode, config, agentId)(input, output);
151
+ await memoryInjectionHook(cerebroClient, containerTags, tui, config, () => cachedAgentName || agentId, directory)(input, output);
152
+ }
153
+ : async (input: any, output: any) => {
154
+ // Fallback: keyword detection only (memory injection via system.transform legacy autoRecallHook)
155
+ await keywordDetectionHook(cerebroClient, containerTags, config.ingest.autoCaptureThreshold, tui, config.ingest.ingestMode, config, agentId)(input, output);
156
+ };
157
+
158
+ const systemTransformHook = strategy === "parts"
159
+ ? soulWhisperSystemHook
160
+ : async (input: any, output: any) => {
161
+ // Fallback: legacy autoRecallHook + soulWhisper
162
+ const recallHook = autoRecallHook(cerebroClient, containerTags, tui, config, () => cachedAgentName || agentId, directory);
163
+ await recallHook(input, output);
164
+ await soulWhisperSystemHook(input, output);
165
+ };
166
+
148
167
  return {
149
168
  config: async (cfg: any) => {
150
169
  cfg.command ??= {};
@@ -154,15 +173,15 @@ const OmemPlugin: Plugin = async (input) => {
154
173
  };
155
174
  },
156
175
  "experimental.chat.system.transform": async (input: any, output: any) => {
157
- logDebug("transform input", { sessionID: input.sessionID });
176
+ logDebug("transform input", { sessionID: input.sessionID, strategy });
158
177
  if (input.sessionID && !mainSessionLocked) {
159
178
  mainSessionId = input.sessionID;
160
179
  mainSessionLocked = true;
161
180
  logInfo("mainSessionId locked", { sessionId: input.sessionID });
162
181
  }
163
- return wrappedRecallHook(input, output);
182
+ return systemTransformHook(input, output);
164
183
  },
165
- "chat.message": keywordDetectionHook(cerebroClient, containerTags, config.ingest.autoCaptureThreshold, tui, config.ingest.ingestMode, config, agentId),
184
+ "chat.message": chatMessageHook,
166
185
  "experimental.session.compacting": compactingHook(cerebroClient, containerTags, tui, config.ingest.ingestMode, isAutoStoreEnabled, () => mainSessionId, client, config, agentId, directory),
167
186
  "experimental.compaction.autocontinue": autocontinueHook(cerebroClient, containerTags, tui, config.ingest.ingestMode, isAutoStoreEnabled, () => mainSessionId, client, config, agentId, directory),
168
187
  tool: buildTools(cerebroClient, containerTags, { agentId, getSessionId: () => mainSessionId, getAgentName: () => cachedAgentName || agentId, getProjectPath: () => directory }),