@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 +1 -1
- package/src/client.ts +4 -2
- package/src/hooks.ts +139 -386
- 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.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
|
|
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
|
-
|
|
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), 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
|
-
|
|
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
|
-
// 兜底:即使不召回记忆,只要有 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
|
|
590
|
-
.map((p) =>
|
|
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
|
-
|
|
596
|
-
firstMessages.set(input.sessionID, textContent);
|
|
597
|
-
}
|
|
326
|
+
const query = extractUserRequest(textContent);
|
|
598
327
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
logDebug("
|
|
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
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|
|
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 {
|
|
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.";
|