@mingxy/cerebro 1.18.15 → 1.18.17

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mingxy/cerebro",
3
- "version": "1.18.15",
3
+ "version": "1.18.17",
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?limit=${limit}&offset=0`,
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,371 +211,157 @@ const FETCH_POLICY = [
252
211
  "</cerebro-fetch-policy>",
253
212
  ].join("\n");
254
213
 
255
- /**
256
- * Score-weighted budget allocation: high-score memories get more chars.
257
- * Falls back to uniform distribution when totalScore === 0 or all scores equal.
258
- */
259
- interface ContextBlockResult {
214
+ const INJECTION_MAX_CHARS_FALLBACK = 4000;
215
+
216
+ interface InjectionResult {
260
217
  text: string;
261
- injectedMemoryIds: string[];
262
- injectedCount: number;
218
+ profileCount: number;
219
+ memoryCount: number;
220
+ projectMemoryCount: number;
263
221
  }
264
222
 
265
- function buildContextBlock(
266
- results: SearchResult[],
267
- budget: number,
268
- maxContentLength: number = 500,
269
- minItemChars: number = MIN_ITEM_CONTENT_CHARS,
270
- ): ContextBlockResult {
271
- const empty: ContextBlockResult = { text: "", injectedMemoryIds: [], injectedCount: 0 };
272
- if (results.length === 0) return empty;
273
-
274
- const totalScore = results.reduce((sum, r) => sum + r.score, 0);
275
-
276
- const grouped = categorize(results);
277
- const sections: string[] = [];
278
-
279
- for (const [label, items] of grouped) {
280
- const lines = items.map((r) => {
281
- const itemMaxLen = totalScore > 0
282
- ? Math.min(maxContentLength, Math.max(minItemChars, Math.floor((r.score / totalScore) * budget)))
283
- : Math.min(maxContentLength, Math.max(minItemChars, Math.floor(budget / results.length)));
284
- return formatMemoryLine(r, itemMaxLen);
285
- });
286
- sections.push(`[${label}]\n${lines.join("\n")}`);
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), 3000)),
235
+ ]).catch(() => null),
236
+ Promise.race([
237
+ client.listRecent(5, projectPath),
238
+ new Promise<never[]>((resolve) => setTimeout(() => resolve([]), 2000)),
239
+ ]).catch(() => []),
240
+ query
241
+ ? Promise.race([
242
+ client.searchMemories(query, 10, undefined, undefined, projectPath),
243
+ new Promise<never[]>((resolve) => setTimeout(() => resolve([]), 3000)),
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
- "<cerebro-context>",
292
- "",
293
- ...sections,
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
- export function autoRecallHook(client: CerebroClient, containerTags: string[], tui: any, config: Partial<OmemPluginConfig> = {}, getAgentName?: () => string, directory?: string) {
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?: string; model: Model },
317
- output: { system: string[] },
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") return;
325
-
326
- try {
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
- // 兜底:即使不召回记忆,只要有 profile 就注入(与 recall 路径对齐)
482
- if (profileBlock) {
483
- appendToSystem(output.system, profileBlock);
484
- logDebug("autoRecallHook profile injected (no-recall path)", { sessionId: input.sessionID, outputSystemLength: output.system.length });
485
-
486
- createEventAndReturn({
487
- keptCount: 0,
488
- discardedCount: 0,
489
- actualProfileInjected: true,
490
- actualProfileContent: profileBlock,
491
- actualInjectedCount: 0,
492
- injectedMemoryIds: [],
493
- });
494
-
495
- showToast(tui, "👨 Profile Injected", `${profileCountText} · no recall needed`, "success", toastDelayMs);
496
- }
497
- return;
498
- }
499
-
500
- const results = shouldRecallRes.memories ?? [];
501
-
502
- // --- Token Budget Calculation ---
503
- const profileChars = profileBlock ? profileBlock.length : 0;
504
- const budgetRemaining = maxContentChars - profileChars;
505
- if (budgetRemaining < 0) {
506
- logDebug("autoRecallHook budget overflow", { profileChars, maxContentChars, deficit: -budgetRemaining });
507
- }
508
- logDebug("autoRecallHook budget", {
509
- maxContentChars, profileChars, budgetRemaining,
510
- configuredMax: maxContentLength,
511
- });
512
-
513
- const ctxResult = buildContextBlock(results, budgetRemaining, maxContentLength, MIN_ITEM_CONTENT_CHARS);
514
- if (ctxResult.text) {
515
- appendToSystem(output.system, ctxResult.text);
516
- appendToSystem(output.system, FETCH_POLICY);
517
- logDebug("autoRecallHook block injected to output.system", {
518
- sessionId: input.sessionID,
519
- blockPreview: ctxResult.text.slice(0, 200),
520
- outputSystemLength: output.system.length,
521
- });
522
- } else {
523
- logDebug("autoRecallHook block was EMPTY — no injection", { sessionId: input.sessionID });
524
- }
525
-
526
- if (profileBlock) {
527
- appendToSystem(output.system, profileBlock);
528
- logDebug("autoRecallHook profile injected after context", { sessionId: input.sessionID, outputSystemLength: output.system.length });
529
- }
530
-
531
- logDebug("autoRecallHook injection complete", { sessionId: input.sessionID });
532
-
533
- const didInjectProfile = !!profileBlock;
534
- const didInjectContext = !!ctxResult.text;
535
-
536
- createEventAndReturn({
537
- keptCount: ctxResult.injectedCount,
538
- discardedCount: storedDiscardedIds.length,
539
- injectedContent: didInjectContext ? ctxResult.text : undefined,
540
- actualProfileInjected: didInjectProfile,
541
- actualProfileContent: profileBlock || undefined,
542
- actualInjectedCount: ctxResult.injectedCount,
543
- injectedMemoryIds: ctxResult.injectedMemoryIds,
544
- });
545
-
546
- // --- Toast (every branch shows toast) ---
547
- if (didInjectProfile && didInjectContext) {
548
- showToast(tui, "🧠 Context + Profile Injected", `${profileCountText} · recall active`, "success", toastDelayMs);
549
- } else if (didInjectProfile) {
550
- showToast(tui, "👨 Profile Injected", `${profileCountText} · no recall needed`, "success", toastDelayMs);
551
- } else if (didInjectContext) {
552
- showToast(tui, "🧠 Context Injected", `Recall active · profile cached`, "success", toastDelayMs);
553
- } else {
554
- showToast(tui, "🧠 Cerebro", "profile cached · no recall needed", "info", toastDelayMs);
555
- }
556
-
557
- if (saveKeywordDetectedSessions.has(input.sessionID)) {
558
- appendToSystem(output.system, KEYWORD_NUDGE);
559
- saveKeywordDetectedSessions.delete(input.sessionID);
560
- }
561
- } catch (err) {
562
- const errMsg = err instanceof Error ? err.message : String(err);
563
- if (errMsg.includes("[cerebro]")) {
564
- // Server returned error (500, etc.) with details
565
- const cleanMsg = errMsg.replace(/^\[cerebro\]\s*/, "");
566
- if (cleanMsg.startsWith("500")) {
567
- showToast(tui, "🧠 Cerebro Server Error", cleanMsg.substring(0, 200), "error");
568
- } else if (cleanMsg.includes("timed out")) {
569
- showToast(tui, "🧠 Cerebro Service Timeout", cleanMsg.substring(0, 100), "error");
570
- } else {
571
- showToast(tui, "🧠 Cerebro Error", cleanMsg.substring(0, 150), "error");
572
- }
573
- } else if (errMsg.includes("fetch") || errMsg.includes("network")) {
574
- showToast(tui, "🧠 Cerebro Service Unavailable", "Network error · check API connection", "error");
575
- } else {
576
- showToast(tui, "🧠 Memory Recall Error", errMsg.substring(0, 100), "error");
577
- }
314
+ if (policy === "none") {
315
+ injectedSessions.add(input.sessionID);
316
+ return;
578
317
  }
579
- };
580
- }
581
318
 
582
- export function keywordDetectionHook(_client: CerebroClient, _containerTags: string[], threshold: number, _tui: any, _ingestMode: "smart" | "raw" = "smart", config: Partial<OmemPluginConfig> = {}, agentId?: string) {
583
- const effectiveAgentId = agentId || process.env.OMEM_AGENT_ID || "opencode";
584
- return async (
585
- input: { sessionID: string; messageID?: string },
586
- output: { message: UserMessage; parts: Part[] },
587
- ) => {
588
319
  const textContent = output.parts
589
- .filter((p): p is any => p.type === "text")
590
- .map((p) => (p as any).text || (p as any).content || "")
320
+ .filter((p: any) => p.type === "text")
321
+ .map((p: any) => p.text || (p as any).content || "")
591
322
  .join(" ")
592
323
  || (output.message as any).content
593
324
  || "";
594
325
 
595
- if (!firstMessages.has(input.sessionID)) {
596
- firstMessages.set(input.sessionID, textContent);
597
- }
326
+ const query = extractUserRequest(textContent);
598
327
 
599
- if (detectSaveKeyword(textContent)) {
600
- saveKeywordDetectedSessions.add(input.sessionID);
601
- logDebug("keywordDetectionHook triggered", { sessionId: input.sessionID });
602
- }
603
-
604
- const policy = resolveAgentPolicy(effectiveAgentId, config);
605
- if (policy === "none") {
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 });
606
331
  return;
607
332
  }
608
333
 
609
- if (!sessionMessages.has(input.sessionID)) {
610
- sessionMessages.set(input.sessionID, []);
611
- }
612
- sessionMessages.get(input.sessionID)!.push({
613
- role: "user",
614
- content: textContent,
615
- });
616
-
617
- const messages = sessionMessages.get(input.sessionID)!;
618
- if (messages.length >= threshold) {
619
- // Threshold reached — messages will be processed on next session.idle
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
+ } else if (!hasContent) {
354
+ logDebug("chatMessageRecallHook: no content available, will retry next turn", {
355
+ sessionId: input.sessionID,
356
+ profileCount: injection.profileCount,
357
+ memoryCount: injection.memoryCount,
358
+ projectMemoryCount: injection.projectMemoryCount,
359
+ });
360
+ showToast(tui, "🧠 Memory Unavailable", "API timeout or no memories yet", "warning");
361
+ }
362
+ } catch (err) {
363
+ logErr("chatMessageRecallHook failed", { error: String(err) });
364
+ showToast(tui, "🧠 Memory Injection Failed", "Check connection", "error");
620
365
  }
621
366
  };
622
367
  }
@@ -779,6 +524,8 @@ export function compactingHook(client: CerebroClient, containerTags: string[], t
779
524
  profileInjectedSessions.delete(input.sessionID);
780
525
  lastUserMsgCount.delete(input.sessionID);
781
526
  firstMessages.delete(input.sessionID);
527
+ processedMessageIds.delete(input.sessionID);
528
+ injectedSessions.delete(input.sessionID);
782
529
  if (input.sessionID) {
783
530
  logDebug("compactingHook cleared session state", { sessionID: input.sessionID });
784
531
  }
@@ -788,6 +535,8 @@ export function compactingHook(client: CerebroClient, containerTags: string[], t
788
535
  if (input.sessionID) {
789
536
  profileInjectedSessions.delete(input.sessionID);
790
537
  lastUserMsgCount.delete(input.sessionID);
538
+ processedMessageIds.delete(input.sessionID);
539
+ injectedSessions.delete(input.sessionID);
791
540
  logDebug("compactingHook cleared profile TTL for re-injection", { sessionID: input.sessionID });
792
541
  }
793
542
  };
@@ -903,7 +652,7 @@ export function autocontinueHook(
903
652
  };
904
653
  }
905
654
 
906
- const processedMessageIds = new Set<string>();
655
+ const processedMessageIds = new Map<string, Set<string>>();
907
656
  const pluginStartTime = Date.now();
908
657
 
909
658
  export function sessionIdleHook(
@@ -1072,7 +821,11 @@ export function sessionIdleHook(
1072
821
 
1073
822
  for (const msg of messages) {
1074
823
  const msgId = msg.info?.id;
1075
- if (!msgId || processedMessageIds.has(msgId)) continue;
824
+ if (!msgId) continue;
825
+ if (!processedMessageIds.has(sessionID)) {
826
+ processedMessageIds.set(sessionID, new Set());
827
+ }
828
+ if (processedMessageIds.get(sessionID)!.has(msgId)) continue;
1076
829
 
1077
830
  const msgTime = msg.info?.createdAt ? new Date(msg.info.createdAt).getTime() : 0;
1078
831
  if (msgTime > 0 && msgTime < pluginStartTime) continue;
@@ -1132,7 +885,7 @@ export function sessionIdleHook(
1132
885
  await cerebroClient.sessionIngest(conversationMessages, sessionID, effectiveAgentId, sessionTitle, projectName, projectPath);
1133
886
  logInfo("sessionIdleHook sessionIngest ok");
1134
887
  for (const id of newMessageIds) {
1135
- processedMessageIds.add(id);
888
+ processedMessageIds.get(sessionID)!.add(id);
1136
889
  }
1137
890
  showToast(tui, "🧠 Memory Sealed", `${conversationMessages.length} dialogues captured · entrusted to the heavens for refinement`, "success");
1138
891
  } 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 { autoRecallHook, autocontinueHook, compactingHook, keywordDetectionHook, sessionIdleHook, showToast as hooksShowToast } from "./hooks.js";
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 recallHook = autoRecallHook(cerebroClient, containerTags, tui, config, () => cachedAgentName || agentId, directory);
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
- if (webServer) {
129
- await stopWebServer(webServer);
130
- webServer = null;
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
- "experimental.chat.system.transform": async (input: any, output: any) => {
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
- return recallHook(input, output);
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
- "save this",
4
- "don't forget",
5
- "keep in mind",
6
- "note that",
7
- "store this",
8
- "memorize",
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 appears to want you to remember something. " +
23
- "Consider using the `memory_store` tool to save this information for future reference.";
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.";