@mingxy/cerebro 1.18.16 → 1.18.18
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/package.json +1 -1
- package/src/client.ts +4 -2
- package/src/hooks.ts +154 -385
- package/src/index.ts +46 -11
- package/src/keywords.ts +28 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mingxy/cerebro",
|
|
3
|
-
"version": "1.18.
|
|
3
|
+
"version": "1.18.18",
|
|
4
4
|
"description": "Cerebro persistent memory plugin for OpenCode — auto-recall, auto-capture, 9 memory tools with clustering, project-scoped memory isolation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
package/src/client.ts
CHANGED
|
@@ -298,9 +298,11 @@ export class CerebroClient {
|
|
|
298
298
|
return this.request("/v2/profile/stats");
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
-
async listRecent(limit = 20): Promise<MemoryDto[]> {
|
|
301
|
+
async listRecent(limit = 20, projectPath?: string): Promise<MemoryDto[]> {
|
|
302
|
+
const params = new URLSearchParams({ limit: String(limit), offset: "0", sort: "updated_at", order: "desc" });
|
|
303
|
+
if (projectPath) params.set("project_path", projectPath);
|
|
302
304
|
const res = await this.request<ListResponse>(
|
|
303
|
-
`/v1/memories
|
|
305
|
+
`/v1/memories?${params}`,
|
|
304
306
|
);
|
|
305
307
|
return res?.memories ?? [];
|
|
306
308
|
}
|
package/src/hooks.ts
CHANGED
|
@@ -1,26 +1,13 @@
|
|
|
1
1
|
import type { Model, UserMessage, Part } from "@opencode-ai/sdk";
|
|
2
2
|
import type { CerebroClient, SearchResult } from "./client.js";
|
|
3
3
|
import { type OmemPluginConfig, resolveAgentPolicy } from "./config.js";
|
|
4
|
-
import { detectSaveKeyword, KEYWORD_NUDGE } from "./keywords.js";
|
|
5
4
|
import { logDebug, logInfo, logError as logErr } from "./logger.js";
|
|
6
5
|
import { readFile } from "node:fs/promises";
|
|
7
|
-
import { stripPrivateContent } from "./privacy.js";
|
|
8
6
|
|
|
9
7
|
const BOUNDARY_SEARCH_RATIO = 0.6;
|
|
10
|
-
const MIN_ITEM_CONTENT_CHARS = 100;
|
|
11
|
-
const MIN_CONTENT_CHARS = 1000;
|
|
12
|
-
const MIN_CONTENT_LENGTH = 50;
|
|
13
8
|
|
|
14
9
|
const projectNameCache = new Map<string, string>();
|
|
15
10
|
|
|
16
|
-
function appendToSystem(system: string[], content: string) {
|
|
17
|
-
if (system.length > 0) {
|
|
18
|
-
system[system.length - 1] += "\n\n" + content;
|
|
19
|
-
} else {
|
|
20
|
-
system.push(content);
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
11
|
async function detectProjectName(rootPath: string): Promise<string | undefined> {
|
|
25
12
|
const cached = projectNameCache.get(rootPath);
|
|
26
13
|
if (cached !== undefined) {
|
|
@@ -175,11 +162,11 @@ function extractUserRequest(content: string): string {
|
|
|
175
162
|
return text;
|
|
176
163
|
}
|
|
177
164
|
|
|
178
|
-
const saveKeywordDetectedSessions = new Set<string>();
|
|
179
|
-
const firstMessages = new Map<string, string>();
|
|
180
|
-
const sessionMessages = new Map<string, Array<{ role: string; content: string }>>();
|
|
165
|
+
export const saveKeywordDetectedSessions = new Set<string>();
|
|
166
|
+
export const firstMessages = new Map<string, string>();
|
|
167
|
+
export const sessionMessages = new Map<string, Array<{ role: string; content: string }>>();
|
|
181
168
|
export const profileInjectedSessions = new Map<string, number>();
|
|
182
|
-
const lastProfileBlock = new Map<string, { content: string; count: number }>();
|
|
169
|
+
export const lastProfileBlock = new Map<string, { content: string; count: number }>();
|
|
183
170
|
const lastUserMsgCount = new Map<string, number>();
|
|
184
171
|
const summarizedSessions = new Set<string>();
|
|
185
172
|
|
|
@@ -216,34 +203,6 @@ function truncate(text: string, maxLength: number): string {
|
|
|
216
203
|
return truncated + "…";
|
|
217
204
|
}
|
|
218
205
|
|
|
219
|
-
function categorize(results: SearchResult[]): Map<string, SearchResult[]> {
|
|
220
|
-
const groups = new Map<string, SearchResult[]>();
|
|
221
|
-
for (const r of results) {
|
|
222
|
-
const cat = r.memory.category || "General";
|
|
223
|
-
const label =
|
|
224
|
-
cat === "preferences"
|
|
225
|
-
? "Preferences"
|
|
226
|
-
: cat === "knowledge"
|
|
227
|
-
? "Knowledge"
|
|
228
|
-
: cat.charAt(0).toUpperCase() + cat.slice(1);
|
|
229
|
-
if (!groups.has(label)) groups.set(label, []);
|
|
230
|
-
groups.get(label)!.push(r);
|
|
231
|
-
}
|
|
232
|
-
return groups;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
function formatMemoryLine(r: SearchResult, maxContentLength: number): string {
|
|
236
|
-
const age = formatRelativeAge(r.memory.created_at);
|
|
237
|
-
const tags = r.memory.tags.length > 0 ? ` [${r.memory.tags.join(", ")}]` : "";
|
|
238
|
-
const idTag = ` [id:${r.memory.id}]`;
|
|
239
|
-
const relTag = r.memory.relations && r.memory.relations.length > 0
|
|
240
|
-
? ` [rel:${r.memory.relations.map((rel) => rel.target_id).join(",")}]`
|
|
241
|
-
: "";
|
|
242
|
-
const refineTag = r.refine_relevance?.trim() ? ` [${r.refine_relevance.trim()}]` : "";
|
|
243
|
-
const content = truncate(r.memory.content, maxContentLength);
|
|
244
|
-
return ` - (${age}${idTag}${relTag}${refineTag}${tags}) ${content}`;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
206
|
const FETCH_POLICY = [
|
|
248
207
|
"<cerebro-fetch-policy>",
|
|
249
208
|
"IMPORTANT: Each memory above is a condensed summary. The full version contains critical details that may change your response quality.",
|
|
@@ -252,370 +211,172 @@ const FETCH_POLICY = [
|
|
|
252
211
|
"</cerebro-fetch-policy>",
|
|
253
212
|
].join("\n");
|
|
254
213
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
*/
|
|
259
|
-
interface ContextBlockResult {
|
|
214
|
+
const INJECTION_MAX_CHARS_FALLBACK = 4000;
|
|
215
|
+
|
|
216
|
+
interface InjectionResult {
|
|
260
217
|
text: string;
|
|
261
|
-
|
|
262
|
-
|
|
218
|
+
profileCount: number;
|
|
219
|
+
memoryCount: number;
|
|
220
|
+
projectMemoryCount: number;
|
|
263
221
|
}
|
|
264
222
|
|
|
265
|
-
function
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
):
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
223
|
+
export async function buildMemoryInjection(
|
|
224
|
+
client: CerebroClient,
|
|
225
|
+
projectPath: string | undefined,
|
|
226
|
+
query: string,
|
|
227
|
+
config: Partial<OmemPluginConfig>,
|
|
228
|
+
): Promise<InjectionResult> {
|
|
229
|
+
const maxChars = config.content?.maxContentLength ?? INJECTION_MAX_CHARS_FALLBACK;
|
|
230
|
+
|
|
231
|
+
const [profile, projectMemories, searchResults] = await Promise.all([
|
|
232
|
+
Promise.race([
|
|
233
|
+
client.getInjection(),
|
|
234
|
+
new Promise<null>((resolve) => setTimeout(() => resolve(null), 1000)),
|
|
235
|
+
]).catch(() => null),
|
|
236
|
+
Promise.race([
|
|
237
|
+
client.listRecent(5, projectPath),
|
|
238
|
+
new Promise<never[]>((resolve) => setTimeout(() => resolve([]), 1000)),
|
|
239
|
+
]).catch(() => []),
|
|
240
|
+
query
|
|
241
|
+
? Promise.race([
|
|
242
|
+
client.searchMemories(query, 10, undefined, undefined, projectPath),
|
|
243
|
+
new Promise<never[]>((resolve) => setTimeout(() => resolve([]), 1500)),
|
|
244
|
+
]).catch(() => [])
|
|
245
|
+
: Promise.resolve([]),
|
|
246
|
+
]);
|
|
247
|
+
|
|
248
|
+
const sections: string[] = ["[CEREBRO-MEMORY]", ""];
|
|
249
|
+
|
|
250
|
+
if (profile?.content) {
|
|
251
|
+
sections.push(profile.content);
|
|
252
|
+
sections.push("");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const seenIds = new Set<string>();
|
|
256
|
+
|
|
257
|
+
if (projectMemories.length > 0) {
|
|
258
|
+
sections.push("## Recent Project Activity");
|
|
259
|
+
for (const m of projectMemories) {
|
|
260
|
+
seenIds.add(m.id);
|
|
261
|
+
const age = formatRelativeAge(m.updated_at || m.created_at) || "unknown";
|
|
262
|
+
const content = truncate(m.content, 200);
|
|
263
|
+
sections.push(`- (${age}) ${content}`);
|
|
264
|
+
}
|
|
265
|
+
sections.push("");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const dedupedResults = (searchResults || []).filter((r) => !seenIds.has(r.memory.id));
|
|
269
|
+
if (dedupedResults.length > 0) {
|
|
270
|
+
sections.push("## Relevant Memories");
|
|
271
|
+
for (const r of dedupedResults) {
|
|
272
|
+
const age = formatRelativeAge(r.memory.created_at) || "unknown";
|
|
273
|
+
const content = truncate(r.memory.content, 300);
|
|
274
|
+
sections.push(`- (${age}) ${content}`);
|
|
275
|
+
}
|
|
276
|
+
sections.push("");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
sections.push("[/CEREBRO-MEMORY]");
|
|
280
|
+
|
|
281
|
+
let text = sections.join("\n");
|
|
282
|
+
if (text.length > maxChars) {
|
|
283
|
+
const cutoff = text.lastIndexOf('\n', maxChars);
|
|
284
|
+
text = text.slice(0, cutoff > 0 ? cutoff : maxChars) + "\n…\n[/CEREBRO-MEMORY]";
|
|
287
285
|
}
|
|
288
286
|
|
|
289
287
|
return {
|
|
290
|
-
text
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
"</cerebro-context>",
|
|
295
|
-
].join("\n"),
|
|
296
|
-
injectedMemoryIds: results.map((r) => r.memory.id),
|
|
297
|
-
injectedCount: results.length,
|
|
288
|
+
text,
|
|
289
|
+
profileCount: profile?.preference_count ?? 0,
|
|
290
|
+
memoryCount: dedupedResults?.length ?? 0,
|
|
291
|
+
projectMemoryCount: projectMemories.length,
|
|
298
292
|
};
|
|
299
293
|
}
|
|
300
294
|
|
|
301
|
-
|
|
302
|
-
const similarityThreshold = config.recall?.similarityThreshold ?? 0.4;
|
|
303
|
-
const maxRecallResults = config.recall?.maxRecallResults ?? 10;
|
|
304
|
-
const fetchMultiplier = config.recall?.fetchMultiplier ?? 3;
|
|
305
|
-
const topkCapMultiplier = config.recall?.topkCapMultiplier ?? 2;
|
|
306
|
-
const mmrJaccardThreshold = config.recall?.mmrJaccardThreshold ?? 0.85;
|
|
307
|
-
const mmrPenaltyFactor = config.recall?.mmrPenaltyFactor ?? 0.5;
|
|
308
|
-
const phase2Multiplier = config.recall?.phase2Multiplier ?? 2;
|
|
309
|
-
const llmMaxEval = config.recall?.llmMaxEval ?? 15;
|
|
310
|
-
const refineStrategy = config.recall?.refineStrategy ?? "balanced";
|
|
311
|
-
const maxContentLength = Math.max(MIN_CONTENT_LENGTH, config.content?.maxContentLength ?? 500);
|
|
312
|
-
const maxContentChars = Math.max(MIN_CONTENT_CHARS, config.content?.maxContentChars ?? 30000);
|
|
313
|
-
const toastDelayMs = config.ui?.toastDelayMs ?? 7000;
|
|
295
|
+
const injectedSessions = new Set<string>();
|
|
314
296
|
|
|
297
|
+
export function chatMessageRecallHook(
|
|
298
|
+
client: CerebroClient,
|
|
299
|
+
_containerTags: string[],
|
|
300
|
+
tui: any,
|
|
301
|
+
config: Partial<OmemPluginConfig> = {},
|
|
302
|
+
getAgentName?: () => string,
|
|
303
|
+
directory?: string,
|
|
304
|
+
) {
|
|
315
305
|
return async (
|
|
316
|
-
input: { sessionID
|
|
317
|
-
output: {
|
|
306
|
+
input: { sessionID: string; messageID?: string },
|
|
307
|
+
output: { message: UserMessage; parts: Part[] },
|
|
318
308
|
) => {
|
|
319
309
|
if (!input.sessionID) return;
|
|
310
|
+
if (injectedSessions.has(input.sessionID)) return;
|
|
320
311
|
|
|
321
|
-
// 5a: agent memory policy check — skip recall entirely for 'none' agents
|
|
322
312
|
const agentId = getAgentName?.() || process.env.OMEM_AGENT_ID || "opencode";
|
|
323
313
|
const policy = resolveAgentPolicy(agentId, config);
|
|
324
|
-
if (policy === "none")
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
logDebug("autoRecallHook start", { sessionId: input.sessionID, agentId, policy, similarityThreshold, maxRecallResults, fetchMultiplier, topkCapMultiplier, mmrJaccardThreshold, mmrPenaltyFactor, phase2Multiplier, llmMaxEval, refineStrategy });
|
|
328
|
-
const messages = sessionMessages.get(input.sessionID) ?? [];
|
|
329
|
-
const userMessages = messages.filter((m) => m.role === "user");
|
|
330
|
-
|
|
331
|
-
const prevCount = lastUserMsgCount.get(input.sessionID) ?? 0;
|
|
332
|
-
if (userMessages.length <= prevCount) {
|
|
333
|
-
logDebug("autoRecallHook skipped: no new user message", { sessionId: input.sessionID, prevCount, currentCount: userMessages.length });
|
|
334
|
-
return;
|
|
335
|
-
}
|
|
336
|
-
lastUserMsgCount.set(input.sessionID, userMessages.length);
|
|
337
|
-
|
|
338
|
-
// --- Profile Fetch (V2 inject API with TTL gate + module-level cache) ---
|
|
339
|
-
const profileTtlMs = config.profile?.ttlMs ?? 300000; // default 5 minutes
|
|
340
|
-
const lastInjected = profileInjectedSessions.get(input.sessionID);
|
|
341
|
-
const profileTtlExpired = !lastInjected || (Date.now() - lastInjected > profileTtlMs);
|
|
342
|
-
|
|
343
|
-
let profileBlock = "";
|
|
344
|
-
let profileCountText = "";
|
|
345
|
-
|
|
346
|
-
if (profileTtlExpired) {
|
|
347
|
-
const maxRetries = 2;
|
|
348
|
-
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
349
|
-
try {
|
|
350
|
-
const injection = await client.getInjection(directory || process.env.OMEM_PROJECT_DIR);
|
|
351
|
-
if (injection?.content) {
|
|
352
|
-
profileBlock = injection.content;
|
|
353
|
-
profileCountText = `${injection.preference_count} preferences`;
|
|
354
|
-
profileInjectedSessions.set(input.sessionID, Date.now());
|
|
355
|
-
lastProfileBlock.set(input.sessionID, { content: profileBlock, count: injection.preference_count });
|
|
356
|
-
logDebug("autoRecallHook profile fetched (V2 injection)", { preferenceCount: injection.preference_count, estimatedTokens: injection.estimated_tokens });
|
|
357
|
-
}
|
|
358
|
-
break;
|
|
359
|
-
} catch (e) {
|
|
360
|
-
if (attempt < maxRetries) {
|
|
361
|
-
logDebug("autoRecallHook getInjection retry", { attempt: attempt + 1, error: String(e) });
|
|
362
|
-
} else {
|
|
363
|
-
logErr("autoRecallHook getInjection failed after retries", { error: String(e) });
|
|
364
|
-
showToast(tui, "⚠️ Profile Inject Failed", "Preference injection skipped · will retry next turn", "error", toastDelayMs);
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
} else {
|
|
369
|
-
// TTL 未过期 — 从缓存恢复 profile 内容
|
|
370
|
-
const cached = lastProfileBlock.get(input.sessionID);
|
|
371
|
-
if (cached) {
|
|
372
|
-
profileBlock = cached.content;
|
|
373
|
-
profileCountText = `${cached.count} preferences`;
|
|
374
|
-
logDebug("autoRecallHook profile restored from cache", { preferenceCount: cached.count, contentLen: cached.content.length });
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// After compacting, sessionMessages is cleared but firstMessages gets repopulated
|
|
379
|
-
// by keywordDetectionHook with compact summary — skip recall in this transient state
|
|
380
|
-
if (userMessages.length === 0) {
|
|
381
|
-
logDebug("autoRecallHook skipped: no user messages in session (post-compacting?)", { sessionId: input.sessionID });
|
|
382
|
-
return;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
const rawQuery = userMessages[userMessages.length - 1]?.content || firstMessages.get(input.sessionID) || "";
|
|
386
|
-
const query_text = extractUserRequest(rawQuery);
|
|
387
|
-
if (!query_text) {
|
|
388
|
-
logDebug("autoRecallHook filtered system injection (profile already injected above)", { rawQueryPrefix: rawQuery.slice(0, 60) });
|
|
389
|
-
return;
|
|
390
|
-
}
|
|
391
|
-
const last_query_text = userMessages.length >= 2 ? userMessages[userMessages.length - 2].content : undefined;
|
|
392
|
-
|
|
393
|
-
const projectTags = containerTags.filter(t => t.startsWith("omem_project_"));
|
|
394
|
-
|
|
395
|
-
const conversationContext = userMessages.length >= 2
|
|
396
|
-
? userMessages.slice(-4, -1).map((m) => {
|
|
397
|
-
const stripped = stripPrivateContent(m.content);
|
|
398
|
-
return stripped.length > 200 ? stripped.slice(0, 200) : stripped;
|
|
399
|
-
})
|
|
400
|
-
: undefined;
|
|
401
|
-
|
|
402
|
-
const shouldRecallRes = await client.shouldRecall(
|
|
403
|
-
query_text, last_query_text, input.sessionID,
|
|
404
|
-
similarityThreshold, maxRecallResults,
|
|
405
|
-
projectTags.length > 0 ? projectTags : undefined,
|
|
406
|
-
conversationContext && conversationContext.length > 0 ? conversationContext : undefined,
|
|
407
|
-
{
|
|
408
|
-
fetch_multiplier: fetchMultiplier,
|
|
409
|
-
topk_cap_multiplier: topkCapMultiplier,
|
|
410
|
-
mmr_jaccard_threshold: mmrJaccardThreshold,
|
|
411
|
-
mmr_penalty_factor: mmrPenaltyFactor,
|
|
412
|
-
phase2_multiplier: phase2Multiplier,
|
|
413
|
-
llm_max_eval: llmMaxEval,
|
|
414
|
-
refine_strategy: refineStrategy,
|
|
415
|
-
},
|
|
416
|
-
directory || process.env.OMEM_PROJECT_DIR,
|
|
417
|
-
);
|
|
418
|
-
|
|
419
|
-
if (!shouldRecallRes) {
|
|
420
|
-
showToast(tui, "🧠 Cerebro Service Unavailable", "Unable to reach memory API · check connection", "error", toastDelayMs);
|
|
421
|
-
return;
|
|
422
|
-
}
|
|
423
|
-
logDebug("autoRecallHook shouldRecall result", { shouldRecall: shouldRecallRes.should_recall, confidence: shouldRecallRes.confidence, memCount: shouldRecallRes.memories?.length ?? 0, discardedCount: shouldRecallRes.discarded?.length ?? 0 });
|
|
424
|
-
|
|
425
|
-
const storedMemoryIds = shouldRecallRes.memories?.map((r) => r.memory.id) ?? [];
|
|
426
|
-
const storedDiscardedIds = shouldRecallRes.discarded?.map((d) => d.memory_id) ?? [];
|
|
427
|
-
const maxScore = storedMemoryIds.length > 0
|
|
428
|
-
? Math.max(...(shouldRecallRes.memories?.map((r) => r.score) ?? [0]))
|
|
429
|
-
: 0;
|
|
430
|
-
|
|
431
|
-
const createEventAndReturn = async (
|
|
432
|
-
opts: {
|
|
433
|
-
injectedContent?: string;
|
|
434
|
-
actualProfileInjected: boolean;
|
|
435
|
-
actualProfileContent?: string;
|
|
436
|
-
actualInjectedCount: number;
|
|
437
|
-
injectedMemoryIds: string[];
|
|
438
|
-
keptCount: number;
|
|
439
|
-
discardedCount: number;
|
|
440
|
-
},
|
|
441
|
-
): Promise<string | undefined> => {
|
|
442
|
-
try {
|
|
443
|
-
const items = [
|
|
444
|
-
...(shouldRecallRes.memories?.map((r) => ({
|
|
445
|
-
memory_id: r.memory.id,
|
|
446
|
-
score: r.score,
|
|
447
|
-
refine_relevance: r.refine_relevance,
|
|
448
|
-
refine_reasoning: r.refine_reasoning,
|
|
449
|
-
is_kept: opts.injectedMemoryIds.includes(r.memory.id),
|
|
450
|
-
})) ?? []),
|
|
451
|
-
...(shouldRecallRes.discarded?.map((d) => ({
|
|
452
|
-
memory_id: d.memory_id,
|
|
453
|
-
score: d.score,
|
|
454
|
-
refine_relevance: d.refine_relevance,
|
|
455
|
-
refine_reasoning: d.refine_reasoning,
|
|
456
|
-
is_kept: false,
|
|
457
|
-
})) ?? []),
|
|
458
|
-
];
|
|
459
|
-
const result = await client.createRecallEvent({
|
|
460
|
-
session_id: input.sessionID!,
|
|
461
|
-
recall_type: "auto",
|
|
462
|
-
query_text,
|
|
463
|
-
max_score: maxScore,
|
|
464
|
-
llm_confidence: shouldRecallRes.confidence ?? 0,
|
|
465
|
-
profile_injected: opts.actualProfileInjected,
|
|
466
|
-
kept_count: opts.keptCount,
|
|
467
|
-
discarded_count: opts.discardedCount,
|
|
468
|
-
injected_count: opts.actualInjectedCount,
|
|
469
|
-
profile_content: opts.actualProfileContent,
|
|
470
|
-
injected_content: opts.injectedContent,
|
|
471
|
-
items: items.length > 0 ? items : undefined,
|
|
472
|
-
});
|
|
473
|
-
return result?.event_id;
|
|
474
|
-
} catch (e) {
|
|
475
|
-
logErr("autoRecallHook createRecallEvent failed", { error: String(e) });
|
|
476
|
-
return undefined;
|
|
477
|
-
}
|
|
478
|
-
};
|
|
479
|
-
|
|
480
|
-
if (!shouldRecallRes.should_recall) {
|
|
481
|
-
if (profileTtlExpired && profileBlock) {
|
|
482
|
-
appendToSystem(output.system, profileBlock);
|
|
483
|
-
logDebug("autoRecallHook profile injected (no-recall path)", { sessionId: input.sessionID, outputSystemLength: output.system.length });
|
|
484
|
-
|
|
485
|
-
createEventAndReturn({
|
|
486
|
-
keptCount: 0,
|
|
487
|
-
discardedCount: 0,
|
|
488
|
-
actualProfileInjected: true,
|
|
489
|
-
actualProfileContent: profileBlock,
|
|
490
|
-
actualInjectedCount: 0,
|
|
491
|
-
injectedMemoryIds: [],
|
|
492
|
-
});
|
|
493
|
-
|
|
494
|
-
showToast(tui, "👨 Profile Injected", `${profileCountText} · no recall needed`, "success", toastDelayMs);
|
|
495
|
-
}
|
|
496
|
-
return;
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
const results = shouldRecallRes.memories ?? [];
|
|
500
|
-
|
|
501
|
-
// --- Token Budget Calculation ---
|
|
502
|
-
const profileChars = profileBlock ? profileBlock.length : 0;
|
|
503
|
-
const budgetRemaining = maxContentChars - profileChars;
|
|
504
|
-
if (budgetRemaining < 0) {
|
|
505
|
-
logDebug("autoRecallHook budget overflow", { profileChars, maxContentChars, deficit: -budgetRemaining });
|
|
506
|
-
}
|
|
507
|
-
logDebug("autoRecallHook budget", {
|
|
508
|
-
maxContentChars, profileChars, budgetRemaining,
|
|
509
|
-
configuredMax: maxContentLength,
|
|
510
|
-
});
|
|
511
|
-
|
|
512
|
-
const ctxResult = buildContextBlock(results, budgetRemaining, maxContentLength, MIN_ITEM_CONTENT_CHARS);
|
|
513
|
-
if (ctxResult.text) {
|
|
514
|
-
appendToSystem(output.system, ctxResult.text);
|
|
515
|
-
appendToSystem(output.system, FETCH_POLICY);
|
|
516
|
-
logDebug("autoRecallHook block injected to output.system", {
|
|
517
|
-
sessionId: input.sessionID,
|
|
518
|
-
blockPreview: ctxResult.text.slice(0, 200),
|
|
519
|
-
outputSystemLength: output.system.length,
|
|
520
|
-
});
|
|
521
|
-
} else {
|
|
522
|
-
logDebug("autoRecallHook block was EMPTY — no injection", { sessionId: input.sessionID });
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
if (profileTtlExpired && profileBlock) {
|
|
526
|
-
appendToSystem(output.system, profileBlock);
|
|
527
|
-
logDebug("autoRecallHook profile injected after context", { sessionId: input.sessionID, outputSystemLength: output.system.length });
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
logDebug("autoRecallHook injection complete", { sessionId: input.sessionID });
|
|
531
|
-
|
|
532
|
-
const didInjectProfile = !!profileBlock;
|
|
533
|
-
const didInjectContext = !!ctxResult.text;
|
|
534
|
-
|
|
535
|
-
createEventAndReturn({
|
|
536
|
-
keptCount: ctxResult.injectedCount,
|
|
537
|
-
discardedCount: storedDiscardedIds.length,
|
|
538
|
-
injectedContent: didInjectContext ? ctxResult.text : undefined,
|
|
539
|
-
actualProfileInjected: didInjectProfile,
|
|
540
|
-
actualProfileContent: profileBlock || undefined,
|
|
541
|
-
actualInjectedCount: ctxResult.injectedCount,
|
|
542
|
-
injectedMemoryIds: ctxResult.injectedMemoryIds,
|
|
543
|
-
});
|
|
544
|
-
|
|
545
|
-
// --- Toast (every branch shows toast) ---
|
|
546
|
-
if (didInjectProfile && didInjectContext) {
|
|
547
|
-
showToast(tui, "🧠 Context + Profile Injected", `${profileCountText} · recall active`, "success", toastDelayMs);
|
|
548
|
-
} else if (didInjectProfile) {
|
|
549
|
-
showToast(tui, "👨 Profile Injected", `${profileCountText} · no recall needed`, "success", toastDelayMs);
|
|
550
|
-
} else if (didInjectContext) {
|
|
551
|
-
showToast(tui, "🧠 Context Injected", `Recall active · profile cached`, "success", toastDelayMs);
|
|
552
|
-
} else {
|
|
553
|
-
showToast(tui, "🧠 Cerebro", "profile cached · no recall needed", "info", toastDelayMs);
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
if (saveKeywordDetectedSessions.has(input.sessionID)) {
|
|
557
|
-
appendToSystem(output.system, KEYWORD_NUDGE);
|
|
558
|
-
saveKeywordDetectedSessions.delete(input.sessionID);
|
|
559
|
-
}
|
|
560
|
-
} catch (err) {
|
|
561
|
-
const errMsg = err instanceof Error ? err.message : String(err);
|
|
562
|
-
if (errMsg.includes("[cerebro]")) {
|
|
563
|
-
// Server returned error (500, etc.) with details
|
|
564
|
-
const cleanMsg = errMsg.replace(/^\[cerebro\]\s*/, "");
|
|
565
|
-
if (cleanMsg.startsWith("500")) {
|
|
566
|
-
showToast(tui, "🧠 Cerebro Server Error", cleanMsg.substring(0, 200), "error");
|
|
567
|
-
} else if (cleanMsg.includes("timed out")) {
|
|
568
|
-
showToast(tui, "🧠 Cerebro Service Timeout", cleanMsg.substring(0, 100), "error");
|
|
569
|
-
} else {
|
|
570
|
-
showToast(tui, "🧠 Cerebro Error", cleanMsg.substring(0, 150), "error");
|
|
571
|
-
}
|
|
572
|
-
} else if (errMsg.includes("fetch") || errMsg.includes("network")) {
|
|
573
|
-
showToast(tui, "🧠 Cerebro Service Unavailable", "Network error · check API connection", "error");
|
|
574
|
-
} else {
|
|
575
|
-
showToast(tui, "🧠 Memory Recall Error", errMsg.substring(0, 100), "error");
|
|
576
|
-
}
|
|
314
|
+
if (policy === "none") {
|
|
315
|
+
injectedSessions.add(input.sessionID);
|
|
316
|
+
return;
|
|
577
317
|
}
|
|
578
|
-
};
|
|
579
|
-
}
|
|
580
318
|
|
|
581
|
-
export function keywordDetectionHook(_client: CerebroClient, _containerTags: string[], threshold: number, _tui: any, _ingestMode: "smart" | "raw" = "smart", config: Partial<OmemPluginConfig> = {}, agentId?: string) {
|
|
582
|
-
const effectiveAgentId = agentId || process.env.OMEM_AGENT_ID || "opencode";
|
|
583
|
-
return async (
|
|
584
|
-
input: { sessionID: string; messageID?: string },
|
|
585
|
-
output: { message: UserMessage; parts: Part[] },
|
|
586
|
-
) => {
|
|
587
319
|
const textContent = output.parts
|
|
588
|
-
.filter((p
|
|
589
|
-
.map((p) =>
|
|
320
|
+
.filter((p: any) => p.type === "text")
|
|
321
|
+
.map((p: any) => p.text || (p as any).content || "")
|
|
590
322
|
.join(" ")
|
|
591
323
|
|| (output.message as any).content
|
|
592
324
|
|| "";
|
|
593
325
|
|
|
594
|
-
|
|
595
|
-
firstMessages.set(input.sessionID, textContent);
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
if (detectSaveKeyword(textContent)) {
|
|
599
|
-
saveKeywordDetectedSessions.add(input.sessionID);
|
|
600
|
-
logDebug("keywordDetectionHook triggered", { sessionId: input.sessionID });
|
|
601
|
-
}
|
|
326
|
+
const query = extractUserRequest(textContent);
|
|
602
327
|
|
|
603
|
-
const
|
|
604
|
-
if (
|
|
328
|
+
const TRIVIAL_PATTERNS = /^(hi|hello|hey|你好|嗨|嗯|ok|okay|好的|收到|\s*)$/i;
|
|
329
|
+
if (!query || TRIVIAL_PATTERNS.test(query.trim())) {
|
|
330
|
+
logDebug("chatMessageRecallHook: trivial query, will retry next turn", { sessionId: input.sessionID });
|
|
605
331
|
return;
|
|
606
332
|
}
|
|
607
333
|
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
334
|
+
try {
|
|
335
|
+
const injection = await buildMemoryInjection(client, directory, query, config);
|
|
336
|
+
|
|
337
|
+
const hasContent = (injection.profileCount ?? 0) > 0
|
|
338
|
+
|| (injection.memoryCount ?? 0) > 0
|
|
339
|
+
|| (injection.projectMemoryCount ?? 0) > 0;
|
|
340
|
+
|
|
341
|
+
if (injection.text && hasContent && injection.text.length > 20) {
|
|
342
|
+
injectedSessions.add(input.sessionID);
|
|
343
|
+
|
|
344
|
+
output.parts.unshift({
|
|
345
|
+
type: "text",
|
|
346
|
+
text: injection.text,
|
|
347
|
+
synthetic: true,
|
|
348
|
+
} as any);
|
|
349
|
+
|
|
350
|
+
showToast(tui, "🧠 Memory Injected",
|
|
351
|
+
`${injection.profileCount} prefs · ${injection.projectMemoryCount} project · ${injection.memoryCount} relevant`,
|
|
352
|
+
"success");
|
|
353
|
+
|
|
354
|
+
client.createRecallEvent({
|
|
355
|
+
session_id: input.sessionID,
|
|
356
|
+
recall_type: "auto",
|
|
357
|
+
query_text: query,
|
|
358
|
+
max_score: 0,
|
|
359
|
+
llm_confidence: 0,
|
|
360
|
+
profile_injected: injection.profileCount > 0,
|
|
361
|
+
kept_count: injection.projectMemoryCount + injection.memoryCount,
|
|
362
|
+
discarded_count: 0,
|
|
363
|
+
injected_count: injection.projectMemoryCount + injection.memoryCount,
|
|
364
|
+
injected_content: injection.text,
|
|
365
|
+
}).catch((e: unknown) => {
|
|
366
|
+
logErr("chatMessageRecallHook createRecallEvent failed", { error: String(e) });
|
|
367
|
+
});
|
|
368
|
+
} else if (!hasContent) {
|
|
369
|
+
logDebug("chatMessageRecallHook: no content available, will retry next turn", {
|
|
370
|
+
sessionId: input.sessionID,
|
|
371
|
+
profileCount: injection.profileCount,
|
|
372
|
+
memoryCount: injection.memoryCount,
|
|
373
|
+
projectMemoryCount: injection.projectMemoryCount,
|
|
374
|
+
});
|
|
375
|
+
showToast(tui, "🧠 Memory Unavailable", "API timeout or no memories yet", "warning");
|
|
376
|
+
}
|
|
377
|
+
} catch (err) {
|
|
378
|
+
logErr("chatMessageRecallHook failed", { error: String(err) });
|
|
379
|
+
showToast(tui, "🧠 Memory Injection Failed", "Check connection", "error");
|
|
619
380
|
}
|
|
620
381
|
};
|
|
621
382
|
}
|
|
@@ -778,6 +539,8 @@ export function compactingHook(client: CerebroClient, containerTags: string[], t
|
|
|
778
539
|
profileInjectedSessions.delete(input.sessionID);
|
|
779
540
|
lastUserMsgCount.delete(input.sessionID);
|
|
780
541
|
firstMessages.delete(input.sessionID);
|
|
542
|
+
processedMessageIds.delete(input.sessionID);
|
|
543
|
+
injectedSessions.delete(input.sessionID);
|
|
781
544
|
if (input.sessionID) {
|
|
782
545
|
logDebug("compactingHook cleared session state", { sessionID: input.sessionID });
|
|
783
546
|
}
|
|
@@ -787,6 +550,8 @@ export function compactingHook(client: CerebroClient, containerTags: string[], t
|
|
|
787
550
|
if (input.sessionID) {
|
|
788
551
|
profileInjectedSessions.delete(input.sessionID);
|
|
789
552
|
lastUserMsgCount.delete(input.sessionID);
|
|
553
|
+
processedMessageIds.delete(input.sessionID);
|
|
554
|
+
injectedSessions.delete(input.sessionID);
|
|
790
555
|
logDebug("compactingHook cleared profile TTL for re-injection", { sessionID: input.sessionID });
|
|
791
556
|
}
|
|
792
557
|
};
|
|
@@ -902,7 +667,7 @@ export function autocontinueHook(
|
|
|
902
667
|
};
|
|
903
668
|
}
|
|
904
669
|
|
|
905
|
-
const processedMessageIds = new Set<string
|
|
670
|
+
const processedMessageIds = new Map<string, Set<string>>();
|
|
906
671
|
const pluginStartTime = Date.now();
|
|
907
672
|
|
|
908
673
|
export function sessionIdleHook(
|
|
@@ -1071,7 +836,11 @@ export function sessionIdleHook(
|
|
|
1071
836
|
|
|
1072
837
|
for (const msg of messages) {
|
|
1073
838
|
const msgId = msg.info?.id;
|
|
1074
|
-
if (!msgId
|
|
839
|
+
if (!msgId) continue;
|
|
840
|
+
if (!processedMessageIds.has(sessionID)) {
|
|
841
|
+
processedMessageIds.set(sessionID, new Set());
|
|
842
|
+
}
|
|
843
|
+
if (processedMessageIds.get(sessionID)!.has(msgId)) continue;
|
|
1075
844
|
|
|
1076
845
|
const msgTime = msg.info?.createdAt ? new Date(msg.info.createdAt).getTime() : 0;
|
|
1077
846
|
if (msgTime > 0 && msgTime < pluginStartTime) continue;
|
|
@@ -1131,7 +900,7 @@ export function sessionIdleHook(
|
|
|
1131
900
|
await cerebroClient.sessionIngest(conversationMessages, sessionID, effectiveAgentId, sessionTitle, projectName, projectPath);
|
|
1132
901
|
logInfo("sessionIdleHook sessionIngest ok");
|
|
1133
902
|
for (const id of newMessageIds) {
|
|
1134
|
-
processedMessageIds.add(id);
|
|
903
|
+
processedMessageIds.get(sessionID)!.add(id);
|
|
1135
904
|
}
|
|
1136
905
|
showToast(tui, "🧠 Memory Sealed", `${conversationMessages.length} dialogues captured · entrusted to the heavens for refinement`, "success");
|
|
1137
906
|
} catch (err) {
|
package/src/index.ts
CHANGED
|
@@ -5,11 +5,12 @@ import { tmpdir } from "node:os";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import type { Server } from "node:http";
|
|
7
7
|
import { CerebroClient } from "./client.js";
|
|
8
|
-
import {
|
|
8
|
+
import { chatMessageRecallHook, autocontinueHook, compactingHook, sessionIdleHook, showToast as hooksShowToast, sessionMessages, firstMessages } from "./hooks.js";
|
|
9
|
+
import { detectSaveKeyword, detectRecallKeyword, KEYWORD_NUDGE, RECALL_NUDGE } from "./keywords.js";
|
|
9
10
|
import { getUserTag, getProjectTag } from "./tags.js";
|
|
10
11
|
import { buildTools } from "./tools.js";
|
|
11
12
|
import { logInfo, logDebug, logError } from "./logger.js";
|
|
12
|
-
import { loadPluginConfig } from "./config.js";
|
|
13
|
+
import { loadPluginConfig, resolveAgentPolicy } from "./config.js";
|
|
13
14
|
import { startWebServer, stopWebServer } from "./web-server.js";
|
|
14
15
|
|
|
15
16
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -103,7 +104,7 @@ const OmemPlugin: Plugin = async (input) => {
|
|
|
103
104
|
let mainSessionLocked = false;
|
|
104
105
|
let cachedAgentName: string | undefined;
|
|
105
106
|
|
|
106
|
-
const
|
|
107
|
+
const chatMessageRecall = chatMessageRecallHook(cerebroClient, containerTags, tui, config, () => cachedAgentName || agentId, directory);
|
|
107
108
|
|
|
108
109
|
let webServer: Server | null = null;
|
|
109
110
|
const webEnabled = config.web?.enabled !== false;
|
|
@@ -125,13 +126,17 @@ const OmemPlugin: Plugin = async (input) => {
|
|
|
125
126
|
}
|
|
126
127
|
|
|
127
128
|
const shutdown = async () => {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
129
|
+
try {
|
|
130
|
+
if (webServer) {
|
|
131
|
+
await stopWebServer(webServer);
|
|
132
|
+
webServer = null;
|
|
133
|
+
}
|
|
134
|
+
} catch {}
|
|
135
|
+
process.exit(0); // 强制退出,确保 HTTP server 停止
|
|
132
136
|
};
|
|
133
137
|
process.on("SIGTERM", shutdown);
|
|
134
138
|
process.on("SIGINT", shutdown);
|
|
139
|
+
process.on("disconnect", shutdown); // OpenCode 窗口关闭时触发
|
|
135
140
|
|
|
136
141
|
return {
|
|
137
142
|
config: async (cfg: any) => {
|
|
@@ -141,16 +146,46 @@ const OmemPlugin: Plugin = async (input) => {
|
|
|
141
146
|
description: "Toggle Cerebro auto-store ON or OFF for current session",
|
|
142
147
|
};
|
|
143
148
|
},
|
|
144
|
-
"
|
|
145
|
-
logDebug("transform input", { sessionID: input.sessionID });
|
|
149
|
+
"chat.message": async (input: any, output: any) => {
|
|
146
150
|
if (input.sessionID && !mainSessionLocked) {
|
|
147
151
|
mainSessionId = input.sessionID;
|
|
148
152
|
mainSessionLocked = true;
|
|
149
153
|
logInfo("mainSessionId locked", { sessionId: input.sessionID });
|
|
150
154
|
}
|
|
151
|
-
|
|
155
|
+
await chatMessageRecall(input, output);
|
|
156
|
+
const textContent = output.parts
|
|
157
|
+
.filter((p: any) => p.type === "text" && !(p as any).synthetic)
|
|
158
|
+
.map((p: any) => p.text || (p as any).content || "")
|
|
159
|
+
.join(" ")
|
|
160
|
+
|| (output.message as any).content
|
|
161
|
+
|| "";
|
|
162
|
+
if (!firstMessages.has(input.sessionID)) {
|
|
163
|
+
firstMessages.set(input.sessionID, textContent);
|
|
164
|
+
}
|
|
165
|
+
if (detectSaveKeyword(textContent)) {
|
|
166
|
+
output.parts.push({
|
|
167
|
+
type: "text",
|
|
168
|
+
text: KEYWORD_NUDGE,
|
|
169
|
+
synthetic: true,
|
|
170
|
+
} as any);
|
|
171
|
+
logDebug("keyword nudge pushed via parts.push", { sessionId: input.sessionID });
|
|
172
|
+
}
|
|
173
|
+
if (detectRecallKeyword(textContent)) {
|
|
174
|
+
output.parts.push({
|
|
175
|
+
type: "text",
|
|
176
|
+
text: RECALL_NUDGE,
|
|
177
|
+
synthetic: true,
|
|
178
|
+
} as any);
|
|
179
|
+
logDebug("recall nudge pushed via parts.push", { sessionId: input.sessionID });
|
|
180
|
+
}
|
|
181
|
+
const policy = resolveAgentPolicy(agentId, config);
|
|
182
|
+
if (policy !== "none") {
|
|
183
|
+
if (!sessionMessages.has(input.sessionID)) {
|
|
184
|
+
sessionMessages.set(input.sessionID, []);
|
|
185
|
+
}
|
|
186
|
+
sessionMessages.get(input.sessionID)!.push({ role: "user", content: textContent });
|
|
187
|
+
}
|
|
152
188
|
},
|
|
153
|
-
"chat.message": keywordDetectionHook(cerebroClient, containerTags, config.ingest.autoCaptureThreshold, tui, config.ingest.ingestMode, config, agentId),
|
|
154
189
|
"experimental.session.compacting": compactingHook(cerebroClient, containerTags, tui, config.ingest.ingestMode, isAutoStoreEnabled, () => mainSessionId, client, config, agentId, directory),
|
|
155
190
|
"experimental.compaction.autocontinue": autocontinueHook(cerebroClient, containerTags, tui, config.ingest.ingestMode, isAutoStoreEnabled, () => mainSessionId, client, config, agentId, directory),
|
|
156
191
|
tool: buildTools(cerebroClient, containerTags, { agentId, getSessionId: () => mainSessionId, getAgentName: () => cachedAgentName || agentId, getProjectPath: () => directory }),
|
package/src/keywords.ts
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
const SAVE_KEYWORDS: readonly string[] = [
|
|
2
|
-
"remember",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
|
|
2
|
+
"remember", "save this", "don't forget",
|
|
3
|
+
"记住", "记一下", "保存", "记下来", "别忘了",
|
|
4
|
+
"memory_store",
|
|
5
|
+
] as const;
|
|
6
|
+
|
|
7
|
+
const RECALL_KEYWORDS: readonly string[] = [
|
|
8
|
+
// English — explicit past-conversation references
|
|
9
|
+
"i remember", "i recall", "we discussed", "we talked about",
|
|
10
|
+
"last time", "earlier we", "previously", "before we",
|
|
11
|
+
"look up", "find that", "search for", "check what",
|
|
12
|
+
"what did we", "do you remember", "from our previous", "as discussed",
|
|
13
|
+
// Chinese — explicit memory recall cues
|
|
14
|
+
"我记得", "之前说过", "之前聊过", "上次说的",
|
|
15
|
+
"之前讨论", "我记得之前", "查一下", "搜一下", "找一下",
|
|
16
|
+
"之前提到", "记得吗", "你还记得", "回忆一下",
|
|
17
|
+
"上次那个", "之前那个", "上次讨论", "上次做的",
|
|
18
|
+
"之前记录", "之前保存", "上次决定", "之前约定",
|
|
19
|
+
// Direct tool references
|
|
20
|
+
"memory_search", "memory_get",
|
|
14
21
|
] as const;
|
|
15
22
|
|
|
16
23
|
export function detectSaveKeyword(text: string): boolean {
|
|
@@ -18,6 +25,13 @@ export function detectSaveKeyword(text: string): boolean {
|
|
|
18
25
|
return SAVE_KEYWORDS.some((kw) => lower.includes(kw));
|
|
19
26
|
}
|
|
20
27
|
|
|
28
|
+
export function detectRecallKeyword(text: string): boolean {
|
|
29
|
+
const lower = text.toLowerCase();
|
|
30
|
+
return RECALL_KEYWORDS.some((kw) => lower.includes(kw));
|
|
31
|
+
}
|
|
32
|
+
|
|
21
33
|
export const KEYWORD_NUDGE =
|
|
22
|
-
"The user
|
|
23
|
-
|
|
34
|
+
"[cerebro] The user wants you to remember this. Use the `memory_store` tool to save it now.";
|
|
35
|
+
|
|
36
|
+
export const RECALL_NUDGE =
|
|
37
|
+
"[cerebro] The user references past conversations or stored information. Use `memory_search` with keywords from their message to retrieve relevant memories before responding.";
|