@mingxy/cerebro 1.10.8 → 1.10.10

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.
@@ -0,0 +1,434 @@
1
+ # Cerebro Plugin 记忆注入全流程
2
+
3
+ > 版本: v1.10.8 | 文件: `plugins/opencode/src/`
4
+
5
+ ---
6
+
7
+ ## 一、全局状态(模块级变量)
8
+
9
+ ```
10
+ ┌─────────────────────────────────────────────────────────────┐
11
+ │ hooks.ts 模块级状态(所有hook共享) │
12
+ ├─────────────────────────────────────────────────────────────┤
13
+ │ keywordDetectedSessions: Set<sessionID> │
14
+ │ → 标记检测到记忆关键词的session(注入时追加KEYWORD_NUDGE) │
15
+ │ │
16
+ │ injectedMemoryIds: Map<sessionID, Set<memoryID>> │
17
+ │ → 增量去重:跟踪每个session已注入的记忆ID │
18
+ │ │
19
+ │ firstMessages: Map<sessionID, string> │
20
+ │ → 记录每个session的第一条用户消息 │
21
+ │ │
22
+ │ sessionMessages: Map<sessionID, {role,content}[]> │
23
+ │ → 消息累积缓冲区(keywordDetection写入,compacting消费) │
24
+ │ │
25
+ │ profileInjectedSessions: Set<sessionID> │
26
+ │ → 每session只注入一次Profile │
27
+ │ │
28
+ │ processedMessageIds: Set<msgID> │
29
+ │ → sessionIdleHook防止重复处理已消费的消息 │
30
+ │ │
31
+ │ pluginStartTime: number │
32
+ │ → 插件启动时间戳,跳过启动前的历史消息 │
33
+ └─────────────────────────────────────────────────────────────┘
34
+ ```
35
+
36
+ ---
37
+
38
+ ## 二、四条Hook链路总览
39
+
40
+ ```
41
+ 用户消息 → OpenCode SDK → 触发Hook链
42
+
43
+ ┌───────────────────────────┼─────────────────────────────┐
44
+ ▼ ▼ ▼
45
+ chat.message chat.system.transform session.idle
46
+ (每条消息) (每次LLM调用前) (session空闲)
47
+ │ │ │
48
+ ▼ ▼ ▼
49
+ keywordDetectionHook autoRecallHook sessionIdleHook
50
+ │ │ │
51
+ │ │ │
52
+ │ ┌────┘ │
53
+ ▼ ▼ │
54
+ session.compacting │
55
+ (session压缩时) │
56
+ │ │
57
+ ▼ │
58
+ compactingHook ───────────────────────────────────────────────┘
59
+ ```
60
+
61
+ ---
62
+
63
+ ## 三、Hook ①: keywordDetectionHook — 消息收集
64
+
65
+ **触发时机**: `chat.message`(每条用户消息)
66
+ **作用**: 收集用户消息到内存缓冲区 + 检测记忆关键词
67
+
68
+ ```
69
+ 用户消息到达
70
+
71
+
72
+ [1] 提取文本内容(text parts拼接)
73
+
74
+
75
+ [2] 记录第一条消息 → firstMessages[sessionID] = text
76
+
77
+
78
+ [3] 关键词检测: detectKeyword(text)
79
+ │ │
80
+ ├─ 命中 ────────→ keywordDetectedSessions.add(sessionID)
81
+ │ (autoRecallHook注入时会追加KEYWORD_NUDGE)
82
+
83
+
84
+ [4] Policy检查: resolveAgentPolicy(agentId, config)
85
+
86
+ ├─ "none" ──→ return(不收集消息)
87
+
88
+
89
+ [5] 消息入缓冲: sessionMessages[sessionID].push({role:"user", content:text})
90
+
91
+
92
+ [6] 消息数 ≥ threshold?
93
+
94
+ └─ 是 → 标记"待处理"(等session.idle时消费)
95
+ ```
96
+
97
+ **关键点**:
98
+ - `policy="none"` 时不收集,`readonly`/`readwrite` 都收集
99
+ - 消息存在内存Map中,等 `compactingHook` 或 `sessionIdleHook` 消费
100
+
101
+ ---
102
+
103
+ ## 四、Hook ②: autoRecallHook — 记忆召回+注入(核心)
104
+
105
+ **触发时机**: `experimental.chat.system.transform`(每次LLM调用前,transform system prompt时)
106
+ **作用**: 召回相关记忆 + 注入到system prompt
107
+
108
+ ```
109
+ LLM调用前触发
110
+
111
+
112
+ [1] Policy检查: resolveAgentPolicy(agentId, config)
113
+
114
+ ├─ "none" ──→ return(不召回)
115
+
116
+
117
+ [2] 提取查询: 最后一条用户消息 → extractUserRequest() → query_text
118
+
119
+
120
+ [3] 调用 shouldRecall API ──────────────────────→ POST /v1/should-recall
121
+ │ 参数: query_text, last_query_text, session_id, │
122
+ │ similarity_threshold(0.6), │
123
+ │ max_results(10), project_tags │
124
+ │ 超时: 20秒 │
125
+ │ │
126
+ │ ◄────────────────────────────────────────────┘
127
+ │ 返回: ShouldRecallResponse
128
+ │ { should_recall, confidence, memories[], clustered? }
129
+
130
+
131
+ [4] API不可达? ──→ Toast "Service Unavailable" → return
132
+
133
+
134
+ [5] 注入Profile(每session仅一次)
135
+
136
+ ├─ GET /v1/profile → profile数据
137
+
138
+ ├─ profileInjectedSessions.has(sessionID)?
139
+ │ ├─ 否 → output.system.push("<cerebro-profile>...")
140
+ │ │ profileInjectedSessions.add(sessionID)
141
+ │ │ profileInjected = true
142
+ │ └─ 是 → 跳过
143
+
144
+
145
+ [6] should_recall === false?
146
+
147
+ ├─ 是 ──→ 仅Profile注入?
148
+ │ ├─ 是 → Toast "👨 Profile Injected"
149
+ │ └─ return
150
+
151
+
152
+ [7] 增量去重: results过滤掉 injectedMemoryIds[sessionID] 中已有的
153
+
154
+
155
+ [8] 全部重复? ──→ Toast "all memories already injected" → return
156
+
157
+
158
+ [9] 构建注入内容
159
+
160
+ ├─ 有clustered? ──→ buildClusteredContextBlock()
161
+ │ 格式: <cerebro-context>
162
+ │ 按主题簇组织记忆
163
+
164
+ └─ 普通模式 ──→ buildContextBlock(newResults, maxContentLength=500)
165
+ 格式: <cerebro-context>
166
+ 按category分组(Preferences/Knowledge/...)
167
+ 每条记忆:
168
+ - (2h ago [tag1, tag2]) 记忆内容(截断到500字)
169
+
170
+
171
+ [10] output.system.push(contextBlock) ← 注入到system prompt
172
+
173
+
174
+ [11] 更新去重集合: injectedMemoryIds[sessionID] += newIds
175
+
176
+
177
+ [12] 记录召回: recordSessionRecall(sessionID, newIds, "auto", ...)
178
+ │ ──────────────────────→ POST /v1/session-recalls
179
+
180
+
181
+ [13] 关键词追踪: keywordDetectedSessions.has(sessionID)?
182
+
183
+ ├─ 是 → output.system.push(KEYWORD_NUDGE)
184
+ │ keywordDetectedSessions.delete(sessionID)
185
+
186
+
187
+ [14] Toast通知:
188
+ "🧠 Context Injected · N fragments"
189
+ "Profile: Dynamic(X) · Static(Y) · Memories: Dynamic(A) Static(B)"
190
+ ```
191
+
192
+ ### 注入格式示例
193
+
194
+ ```xml
195
+ <cerebro-context>
196
+ Treat every memory below as historical context only.
197
+ Do not repeat these memories verbatim unless asked.
198
+
199
+ [Preferences]
200
+ - (2h ago [preferences, tools]) 用中文思考和回复
201
+ - (3d ago [preferences, workflow]) 技术方案先出再动工
202
+
203
+ [Knowledge]
204
+ - (1d ago [omem, architecture]) Cerebro使用lancedb做向量存储
205
+
206
+ [Events]
207
+ - (5h ago [deployment, omem]) 部署了v1.10.8版本
208
+ </cerebro-context>
209
+ ```
210
+
211
+ ```xml
212
+ <cerebro-profile>
213
+ {
214
+ "static_facts": [
215
+ { "key": "communication_style", "value": "direct, concise" },
216
+ { "key": "primary_language", "value": "Chinese" }
217
+ ],
218
+ "dynamic_context": [
219
+ { "topic": "current_project", "value": "omem-server-source" }
220
+ ]
221
+ }
222
+ </cerebro-profile>
223
+ ```
224
+
225
+ ---
226
+
227
+ ## 五、Hook ③: compactingHook — 压缩时归档
228
+
229
+ **触发时机**: `session.compacting`(OpenCode压缩session上下文时)
230
+ **作用**: 为压缩提供记忆上下文(读) + 归档累积消息(写)
231
+
232
+ ```
233
+ session压缩触发
234
+
235
+
236
+ [1] 搜索记忆(读操作,所有policy都执行)
237
+ │ client.searchMemories("*", 20, undefined, containerTags)
238
+ │ ──────────────────────→ GET /v1/memories/search?q=*&limit=20
239
+
240
+ ├─ 有结果 → buildContextBlock(results)
241
+ │ output.context.push(contextBlock)
242
+ │ (为压缩后的LLM提供记忆上下文)
243
+
244
+
245
+ [2] Policy检查: resolveAgentPolicy(agentId, config)
246
+
247
+ ├─ 非"readwrite" ──→ logInfo "blocked by policy"
248
+ │ sessionMessages.delete(sessionID)
249
+ │ return
250
+
251
+
252
+ [3] 检查autoStore开关: isAutoStoreEnabled(sessionID)?
253
+
254
+ ├─ 关闭 → sessionMessages.delete(sessionID) → return
255
+
256
+
257
+ [4] 消费sessionMessages缓冲区
258
+
259
+ ├─ 缓冲区空? → return
260
+
261
+
262
+ [5] 检测项目名: detectProjectName(rootPath)
263
+ │ AGENTS.md → package.json → Cargo.toml → go.mod → pyproject.toml
264
+
265
+
266
+ [6] 归档消息(写入记忆)
267
+ │ client.ingestMessages(messages, {mode, tags, sessionId, projectName})
268
+ │ ──────────────────────→ POST /v1/memories
269
+ │ body: { messages: [...], mode: "smart", tags, session_id, project_name }
270
+ │ 每条消息内容先 sanitizeContent(text, maxContentChars=3000)
271
+ │ → 去XML标签 → 压缩空白 → 超长截断
272
+
273
+
274
+ [7] 清理缓冲区: sessionMessages.delete(sessionID)
275
+
276
+
277
+ [8] Toast: "📦 Session Archived · N dialogues archived"
278
+ ```
279
+
280
+ ---
281
+
282
+ ## 六、Hook ④: sessionIdleHook — 空闲时归档
283
+
284
+ **触发时机**: `session.idle`(session空闲10秒后)
285
+ **作用**: 从SDK获取完整对话历史并归档
286
+
287
+ ```
288
+ session空闲事件
289
+
290
+
291
+ [1] event.type === "session.idle"? ── 否 → return
292
+
293
+
294
+ [2] 提取sessionID
295
+
296
+
297
+ [3] isAutoStoreEnabled(sessionID)? ── 关闭 → return
298
+
299
+
300
+ [4] 非主session? (sessionID !== getMainSessionId()) ── return
301
+
302
+
303
+ [5] 延迟10秒执行(防抖)
304
+
305
+
306
+ [6] 从SDK获取session消息: sdkClient.session.messages({id: sessionID})
307
+
308
+
309
+ [7] 过滤消息:
310
+ │ ├─ 跳过 processedMessageIds 中已处理的
311
+ │ ├─ 跳过 pluginStartTime 之前的(防历史重放)
312
+ │ ├─ 只保留 user/assistant 角色
313
+ │ └─ 提取text parts
314
+
315
+
316
+ [8] 消息数 < threshold? ── return
317
+
318
+
319
+ [9] Policy检查: resolveAgentPolicy(agentId, config)
320
+
321
+ ├─ 非"readwrite" ──→ logInfo "blocked by policy" → return
322
+
323
+
324
+ [10] 检测项目名: detectProjectName(rootPath)
325
+
326
+
327
+ [11] sessionIngest(写入记忆)
328
+ │ client.sessionIngest(messages, sessionID, agentId, title, projectName)
329
+ │ ──────────────────────→ POST /v1/memories/session-ingest
330
+ │ body: { messages, session_id, agent_id, session_title, project_name }
331
+ │ 超时60秒
332
+
333
+
334
+ [12] 标记已处理: processedMessageIds += newMessageIds
335
+
336
+
337
+ [13] Toast: "🧠 Memory Sealed · N dialogues captured"
338
+ ```
339
+
340
+ ---
341
+
342
+ ## 七、数据流全景图
343
+
344
+ ```
345
+ ┌─────────────────────────────────┐
346
+ │ 用户消息输入 │
347
+ └──────────┬──────────────────────┘
348
+
349
+ ┌──────────────┼──────────────────┐
350
+ ▼ ▼ ▼
351
+ keywordDetection autoRecall session.idle
352
+ (chat.message) (chat.system (空闲10s)
353
+ .transform)
354
+ │ │ │
355
+ │ ┌────┘ │
356
+ ▼ ▼ │
357
+ sessionMessages System Prompt │
358
+ (内存缓冲) 注入区 │
359
+ │ ▲ │
360
+ │ │ │
361
+ ▼ │ ▼
362
+ compacting ─────┘ sessionIdleHook
363
+ (session压缩) │
364
+ │ │
365
+ ▼ ▼
366
+ ┌───────────────────────────────────────────────┐
367
+ │ Cerebro REST API │
368
+ │ │
369
+ │ 读: POST /v1/should-recall (召回决策) │
370
+ │ 读: GET /v1/profile (用户画像) │
371
+ │ 读: GET /v1/memories/search (记忆搜索) │
372
+ │ 写: POST /v1/memories (消息归档) │
373
+ │ 写: POST /v1/memories/session-ingest (session归档) │
374
+ │ 写: POST /v1/session-recalls (召回记录) │
375
+ │ │
376
+ └──────────────────┬───────────────────────────┘
377
+
378
+
379
+ ┌─────────────────────┐
380
+ │ LanceDB 向量存储 │
381
+ │ (omem-server) │
382
+ └─────────────────────┘
383
+ ```
384
+
385
+ ---
386
+
387
+ ## 八、Policy门控规则
388
+
389
+ | Hook | "none" | "readonly" | "readwrite" |
390
+ |------|--------|------------|-------------|
391
+ | keywordDetection | ❌ 不收集消息 | ✅ 收集消息 | ✅ 收集消息 |
392
+ | autoRecall | ❌ 不召回 | ✅ 召回+注入 | ✅ 召回+注入 |
393
+ | compacting | ✅ 搜索(读) | ✅ 搜索(读) | ✅ 搜索+写入 |
394
+ | sessionIdle | N/A | ❌ 不写入 | ✅ 写入 |
395
+
396
+ ---
397
+
398
+ ## 九、关键配置参数
399
+
400
+ | 参数 | 位置 | 默认值 | 作用 |
401
+ |------|------|--------|------|
402
+ | `content.maxContentLength` | config.ts L49 | 500 | **读取侧**截断:每条注入记忆最大字符数 |
403
+ | `content.maxContentChars` | config.ts L48 | 30000→3000 | **写入侧**截断:归档时单条消息最大字符数 |
404
+ | `content.maxQueryLength` | config.ts L47 | 200 | 召回查询最大字符数 |
405
+ | `recall.similarityThreshold` | config.ts L56 | 0.4 | 召回相似度阈值 |
406
+ | `recall.maxRecallResults` | config.ts L57 | 10 | 最大召回结果数 |
407
+ | `ingest.autoCaptureThreshold` | config.ts L51 | 5 | 消息累积到N条才触发归档 |
408
+ | `ui.toastDelayMs` | config.ts L65 | 7000 | Toast显示时长(ms) |
409
+ | `agentMemoryPolicy` | config.ts L34 | - | 各agent的读写权限 |
410
+ | `defaultPolicy` | config.ts L35 | "readwrite" | 未配置agent的默认权限 |
411
+
412
+ ---
413
+
414
+ ## 十、写入侧 vs 读取侧截断对比
415
+
416
+ ```
417
+ 写入路径 读取路径
418
+ (归档到服务端) (注入到system prompt)
419
+
420
+ 消息内容 sanitizeContent() truncate()
421
+ client.ts L4-10 hooks.ts L147-150
422
+
423
+ 处理流程 去XML标签 → 压缩空白 → 截断 直接截断
424
+
425
+ 配置参数 maxContentChars (3000) maxContentLength (500)
426
+
427
+ 截断标记 "…[truncated]" "…"
428
+
429
+ 触发点 createMemory() L182 buildContextBlock() L178
430
+ ingestMessages() L238
431
+
432
+ 调用方 compactingHook autoRecallHook
433
+ sessionIdleHook
434
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mingxy/cerebro",
3
- "version": "1.10.8",
3
+ "version": "1.10.10",
4
4
  "description": "Cerebro persistent memory plugin for OpenCode — auto-recall, auto-capture, 9 memory tools with clustering",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/config.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { readFileSync } from "node:fs";
1
+ import { readFileSync, appendFileSync, mkdirSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
 
@@ -144,6 +144,25 @@ function deepMerge(base: OmemPluginConfig, overrides: Partial<OmemPluginConfig>)
144
144
 
145
145
  // ── Load config ──────────────────────────────────────────────────────
146
146
 
147
+ /** File-only logger for config.ts (cannot import logger.ts due to circular dependency). */
148
+ function configLog(message: string, fields?: Record<string, unknown>): void {
149
+ try {
150
+ const logDir = join(homedir(), ".config", "cerebro", "logs");
151
+ const logPath = join(logDir, "plugin.log");
152
+ const ts = new Date().toISOString().replace("T", " ").replace(/\.\d+Z$/, "");
153
+ const parts = [`WARN ${ts} service=cerebro ${message}`];
154
+ if (fields) {
155
+ for (const [k, v] of Object.entries(fields)) {
156
+ parts.push(`${k}=${typeof v === "string" ? v : JSON.stringify(v)}`);
157
+ }
158
+ }
159
+ mkdirSync(logDir, { recursive: true });
160
+ appendFileSync(logPath, parts.join(" ") + "\n");
161
+ } catch (writeErr) {
162
+ process.stderr.write(`[cerebro] configLog write failed: ${writeErr instanceof Error ? writeErr.message : String(writeErr)}\n`);
163
+ }
164
+ }
165
+
147
166
  export function loadPluginConfig(overrides?: Partial<OmemPluginConfig>): OmemPluginConfig {
148
167
  let config: OmemPluginConfig = structuredClone(DEFAULTS);
149
168
 
@@ -157,8 +176,8 @@ export function loadPluginConfig(overrides?: Partial<OmemPluginConfig>): OmemPlu
157
176
 
158
177
  // Merge nested groups with defaults for safety
159
178
  config = deepMerge(config, parsed);
160
- } catch {
161
- // Config file doesn't exist or is invalid, use defaults
179
+ } catch (e) {
180
+ configLog("config.json load failed, using defaults", { error: String(e) });
162
181
  }
163
182
 
164
183
  // Apply environment variable overrides (flat OMEM_* → nested paths)
@@ -201,7 +220,20 @@ export function resolveAgentPolicy(
201
220
  agentName: string,
202
221
  config: Partial<OmemPluginConfig>,
203
222
  ): AgentPolicy {
204
- return config.agentMemoryPolicy?.[agentName] ?? config.defaultPolicy ?? "readwrite";
223
+ const policies = config.agentMemoryPolicy;
224
+ if (policies) {
225
+ const exact = policies[agentName];
226
+ if (exact) return exact;
227
+ const lower = agentName.toLowerCase();
228
+ for (const [key, policy] of Object.entries(policies)) {
229
+ if (lower.startsWith(key.toLowerCase()) || key.toLowerCase().startsWith(lower)) {
230
+ return policy;
231
+ }
232
+ }
233
+ }
234
+ if (config.defaultPolicy) return config.defaultPolicy;
235
+ configLog("resolveAgentPolicy: no policy configured, defaulting to readwrite", { agentName });
236
+ return "readwrite";
205
237
  }
206
238
 
207
239
  export { DEFAULTS };
package/src/hooks.ts CHANGED
@@ -5,6 +5,11 @@ import { detectKeyword, KEYWORD_NUDGE } from "./keywords.js";
5
5
  import { logDebug, logInfo, logError as logErr } from "./logger.js";
6
6
  import { readFile } from "node:fs/promises";
7
7
 
8
+ const BOUNDARY_SEARCH_RATIO = 0.6;
9
+ const MIN_ITEM_CONTENT_CHARS = 100;
10
+ const MIN_CONTENT_CHARS = 1000;
11
+ const MIN_CONTENT_LENGTH = 50;
12
+
8
13
  const projectNameCache = new Map<string, string>();
9
14
 
10
15
  async function detectProjectName(rootPath: string): Promise<string | undefined> {
@@ -144,9 +149,25 @@ function formatRelativeAge(isoDate: string): string {
144
149
  return `${months}mo ago`;
145
150
  }
146
151
 
147
- function truncate(text: string, max: number): string {
148
- if (text.length <= max) return text;
149
- return text.slice(0, max) + "…";
152
+ function truncate(text: string, maxLength: number): string {
153
+ if (text.length <= maxLength) return text;
154
+
155
+ // Sentence boundary characters: period, exclamation, question (Latin + CJK)
156
+ // Also treat newline as a boundary
157
+ const boundaries = /[.!?。!?\n]/;
158
+
159
+ // Search backwards from maxLength for a boundary
160
+ const searchEnd = Math.min(maxLength, text.length);
161
+ for (let i = searchEnd - 1; i >= Math.floor(searchEnd * BOUNDARY_SEARCH_RATIO); i--) {
162
+ if (boundaries.test(text[i])) {
163
+ return text.slice(0, i + 1).trimEnd() + "…";
164
+ }
165
+ }
166
+
167
+ let truncated = text.slice(0, maxLength);
168
+ const lastCode = truncated.charCodeAt(truncated.length - 1);
169
+ if (lastCode >= 0xD800 && lastCode <= 0xDBFF) truncated = truncated.slice(0, -1);
170
+ return truncated + "…";
150
171
  }
151
172
 
152
173
  function categorize(results: SearchResult[]): Map<string, SearchResult[]> {
@@ -230,9 +251,10 @@ function buildClusteredContextBlock(clustered: import("./client.js").ClusteredRe
230
251
  }
231
252
 
232
253
  export function autoRecallHook(client: CerebroClient, containerTags: string[], tui: any, config: Partial<OmemPluginConfig> = {}) {
233
- const similarityThreshold = config.recall?.similarityThreshold ?? 0.6;
254
+ const similarityThreshold = config.recall?.similarityThreshold ?? 0.4;
234
255
  const maxRecallResults = config.recall?.maxRecallResults ?? 10;
235
- const maxContentLength = config.content?.maxContentLength ?? 500;
256
+ const maxContentLength = Math.max(MIN_CONTENT_LENGTH, config.content?.maxContentLength ?? 500);
257
+ const maxContentChars = Math.max(MIN_CONTENT_CHARS, config.content?.maxContentChars ?? 30000);
236
258
  const toastDelayMs = config.ui?.toastDelayMs ?? 7000;
237
259
 
238
260
  return async (
@@ -266,10 +288,11 @@ export function autoRecallHook(client: CerebroClient, containerTags: string[], t
266
288
  const profile = await client.getProfile();
267
289
  let profileInjected = false;
268
290
  let profileCountText = "";
291
+ let profileBlock = "";
269
292
  if (profile && !profileInjectedSessions.has(input.sessionID)) {
270
- const profileBlock = [
293
+ profileBlock = [
271
294
  "<cerebro-profile>",
272
- JSON.stringify(profile, null, 2),
295
+ JSON.stringify(profile),
273
296
  "</cerebro-profile>",
274
297
  ].join("\n");
275
298
  output.system.push(profileBlock);
@@ -302,9 +325,26 @@ export function autoRecallHook(client: CerebroClient, containerTags: string[], t
302
325
  return;
303
326
  }
304
327
 
328
+ // --- Token Budget Calculation ---
329
+ const profileChars = profileInjected ? profileBlock.length : 0;
330
+ const budgetRemaining = maxContentChars - profileChars;
331
+ if (budgetRemaining < 0) {
332
+ logDebug("autoRecallHook budget overflow", { profileChars, maxContentChars, deficit: -budgetRemaining });
333
+ }
334
+ const itemCount = clustered
335
+ ? (clustered.cluster_summaries.length + clustered.standalone_memories.length)
336
+ : newResults.length;
337
+ const dynamicMaxContentLength = itemCount > 0
338
+ ? Math.min(maxContentLength, Math.max(MIN_ITEM_CONTENT_CHARS, Math.floor(budgetRemaining / itemCount)))
339
+ : maxContentLength;
340
+ logDebug("autoRecallHook budget", {
341
+ maxContentChars, profileChars, budgetRemaining, itemCount,
342
+ configuredMax: maxContentLength, dynamicMax: dynamicMaxContentLength
343
+ });
344
+
305
345
  const block = clustered
306
- ? buildClusteredContextBlock(clustered, maxContentLength)
307
- : buildContextBlock(newResults, maxContentLength);
346
+ ? buildClusteredContextBlock(clustered, dynamicMaxContentLength)
347
+ : buildContextBlock(newResults, dynamicMaxContentLength);
308
348
  if (block) {
309
349
  output.system.push(block);
310
350
  }
@@ -442,6 +482,15 @@ export function compactingHook(client: CerebroClient, containerTags: string[], t
442
482
  } catch {
443
483
  }
444
484
 
485
+ // Main session gate: sub-agents must not write memories via compacting
486
+ if (getMainSessionId) {
487
+ const mainId = getMainSessionId();
488
+ if (mainId && input.sessionID && input.sessionID !== mainId) {
489
+ logInfo("compactingHook: non-main session skipped", { sessionID: input.sessionID, mainSessionId: mainId });
490
+ return;
491
+ }
492
+ }
493
+
445
494
  // Policy gate: only readwrite agents can write memories
446
495
  const policy = resolveAgentPolicy(effectiveAgentId, config);
447
496
  if (policy !== "readwrite") {
@@ -475,12 +524,13 @@ export function compactingHook(client: CerebroClient, containerTags: string[], t
475
524
  }
476
525
 
477
526
  try {
478
- logInfo("compactingHook ingestMessages called", { msgCount: messages.length, sessionId: effectiveSessionId });
527
+ logInfo("compactingHook ingestMessages called", { msgCount: messages.length, sessionId: effectiveSessionId, agentId: effectiveAgentId });
479
528
  const result = await client.ingestMessages(messages, {
480
529
  mode: ingestMode,
481
530
  tags: [...containerTags, "auto-capture"],
482
531
  sessionId: effectiveSessionId,
483
532
  projectName: projectName,
533
+ agentId: effectiveAgentId,
484
534
  });
485
535
  logInfo("compactingHook ingestMessages result", { result: result === null ? "null(blocked)" : "ok" });
486
536
  if (result === null) {
@@ -513,6 +563,7 @@ export function sessionIdleHook(
513
563
  isAutoStoreEnabled?: (sessionId: string | undefined) => boolean,
514
564
  agentId?: string,
515
565
  config: Partial<OmemPluginConfig> = {},
566
+ onAgentResolved?: (name: string) => void,
516
567
  ) {
517
568
  let idleTimeout: ReturnType<typeof setTimeout> | null = null;
518
569
  let isCapturing = false;
@@ -520,6 +571,8 @@ export function sessionIdleHook(
520
571
  return async (input: { event: { type: string; properties?: any } }) => {
521
572
  if (input.event.type !== "session.idle") return;
522
573
 
574
+ logDebug("sessionIdleHook event.properties dump", { keys: Object.keys(input.event.properties || {}), raw: JSON.stringify(input.event.properties).substring(0, 2000) });
575
+
523
576
  const sessionID = input.event.properties?.sessionID;
524
577
  if (!sessionID) return;
525
578
 
@@ -527,7 +580,10 @@ export function sessionIdleHook(
527
580
 
528
581
  if (getMainSessionId) {
529
582
  const mainId = getMainSessionId();
530
- if (mainId && sessionID !== mainId) return;
583
+ if (mainId && sessionID !== mainId) {
584
+ logInfo("sessionIdleHook: non-main session skipped", { sessionID, mainSessionId: mainId });
585
+ return;
586
+ }
531
587
  }
532
588
 
533
589
  if (idleTimeout) clearTimeout(idleTimeout);
@@ -549,8 +605,6 @@ export function sessionIdleHook(
549
605
  const msgId = msg.info?.id;
550
606
  if (!msgId || processedMessageIds.has(msgId)) continue;
551
607
 
552
- // Skip messages created before this plugin instance started
553
- // (prevents replaying entire session history on restart)
554
608
  const msgTime = msg.info?.createdAt ? new Date(msg.info.createdAt).getTime() : 0;
555
609
  if (msgTime > 0 && msgTime < pluginStartTime) continue;
556
610
 
@@ -574,18 +628,15 @@ export function sessionIdleHook(
574
628
  return;
575
629
  }
576
630
 
577
- // Policy gate: only readwrite agents can write memories
578
- const policy = resolveAgentPolicy(agentId || "", config);
579
- if (policy !== "readwrite") {
580
- logInfo("sessionIdleHook blocked by policy", { agentId: agentId || "", policy });
581
- return;
582
- }
583
-
584
631
  let sessionTitle: string | undefined;
585
632
  let projectName: string | undefined;
633
+ let effectiveAgentId = agentId || "opencode";
586
634
  try {
587
635
  const sessionInfo = await sdkClient.session.get({ path: { id: sessionID } });
588
- logDebug("sessionIdleHook project.rootPath", { rootPath: sessionInfo?.data?.directory });
636
+ if ((sessionInfo?.data as any)?.agent) {
637
+ effectiveAgentId = (sessionInfo.data as any).agent;
638
+ onAgentResolved?.(effectiveAgentId);
639
+ }
589
640
  sessionTitle = sessionInfo?.data?.title;
590
641
  projectName = sessionInfo?.data?.directory
591
642
  ? await detectProjectName(sessionInfo.data.directory)
@@ -594,9 +645,17 @@ export function sessionIdleHook(
594
645
  logErr("sessionIdleHook detectProjectName failed", { error: String(e) });
595
646
  }
596
647
 
648
+ logDebug("sessionIdleHook resolved agentId", { effectiveAgentId, fallbackAgentId: agentId });
649
+
650
+ const policy = resolveAgentPolicy(effectiveAgentId, config);
651
+ if (policy !== "readwrite") {
652
+ logInfo("sessionIdleHook blocked by policy", { agentId: effectiveAgentId, policy, defaultPolicy: String(config.defaultPolicy ?? "undefined") });
653
+ return;
654
+ }
655
+
597
656
  try {
598
- logInfo("sessionIdleHook sessionIngest called", { msgCount: conversationMessages.length, sessionId: sessionID, title: String(sessionTitle) });
599
- await cerebroClient.sessionIngest(conversationMessages, sessionID, agentId, sessionTitle, projectName);
657
+ logInfo("sessionIdleHook sessionIngest called", { msgCount: conversationMessages.length, sessionId: sessionID, agentId: effectiveAgentId, title: String(sessionTitle) });
658
+ await cerebroClient.sessionIngest(conversationMessages, sessionID, effectiveAgentId, sessionTitle, projectName);
600
659
  logInfo("sessionIdleHook sessionIngest ok");
601
660
  for (const id of newMessageIds) {
602
661
  processedMessageIds.add(id);
package/src/index.ts CHANGED
@@ -7,7 +7,7 @@ import { CerebroClient } from "./client.js";
7
7
  import { autoRecallHook, compactingHook, keywordDetectionHook, sessionIdleHook } from "./hooks.js";
8
8
  import { getUserTag, getProjectTag } from "./tags.js";
9
9
  import { buildTools } from "./tools.js";
10
- import { logInfo, logError } from "./logger.js";
10
+ import { logInfo, logDebug, logError } from "./logger.js";
11
11
  import { loadPluginConfig } from "./config.js";
12
12
 
13
13
  const __filename = fileURLToPath(import.meta.url);
@@ -115,7 +115,9 @@ const OmemPlugin: Plugin = async (input) => {
115
115
  const containerTags = [getUserTag(email), getProjectTag(cwd)];
116
116
  const agentId = process.env.OMEM_AGENT_ID || "opencode";
117
117
 
118
- let currentSessionId: string | undefined;
118
+ let mainSessionId: string | undefined;
119
+ let mainSessionLocked = false;
120
+ let cachedAgentName: string | undefined;
119
121
 
120
122
  const recallHook = autoRecallHook(cerebroClient, containerTags, tui, config);
121
123
 
@@ -128,13 +130,18 @@ const OmemPlugin: Plugin = async (input) => {
128
130
  };
129
131
  },
130
132
  "experimental.chat.system.transform": async (input: any, output: any) => {
131
- if (input.sessionID) currentSessionId = input.sessionID;
133
+ logDebug("transform input", { sessionID: input.sessionID });
134
+ if (input.sessionID && !mainSessionLocked) {
135
+ mainSessionId = input.sessionID;
136
+ mainSessionLocked = true;
137
+ logInfo("mainSessionId locked", { sessionId: input.sessionID });
138
+ }
132
139
  return recallHook(input, output);
133
140
  },
134
141
  "chat.message": keywordDetectionHook(cerebroClient, containerTags, config.ingest.autoCaptureThreshold, tui, config.ingest.ingestMode, config, agentId),
135
- "experimental.session.compacting": compactingHook(cerebroClient, containerTags, tui, config.ingest.ingestMode, isAutoStoreEnabled, () => currentSessionId, client, config, agentId),
136
- tool: buildTools(cerebroClient, containerTags, { agentId, getSessionId: () => currentSessionId }),
137
- event: sessionIdleHook(cerebroClient, containerTags, tui, client, config.ingest.ingestMode, config.ingest.autoCaptureThreshold, () => currentSessionId, isAutoStoreEnabled, agentId, config),
142
+ "experimental.session.compacting": compactingHook(cerebroClient, containerTags, tui, config.ingest.ingestMode, isAutoStoreEnabled, () => mainSessionId, client, config, agentId),
143
+ tool: buildTools(cerebroClient, containerTags, { agentId, getSessionId: () => mainSessionId, getAgentName: () => cachedAgentName || agentId }),
144
+ event: sessionIdleHook(cerebroClient, containerTags, tui, client, config.ingest.ingestMode, config.ingest.autoCaptureThreshold, () => mainSessionId, isAutoStoreEnabled, agentId, config, (name: string) => { cachedAgentName = name; }),
138
145
  "shell.env": async (_input: any, output: any) => {
139
146
  if (directory) {
140
147
  output.env.OMEM_PROJECT_DIR = directory;
package/src/tools.ts CHANGED
@@ -24,6 +24,7 @@ function extractMemoryIds(result: unknown): string[] {
24
24
  export interface ToolContext {
25
25
  agentId?: string;
26
26
  getSessionId: () => string | undefined;
27
+ getAgentName?: () => string;
27
28
  }
28
29
 
29
30
  export function buildTools(client: CerebroClient, containerTags: string[], context: ToolContext) {
@@ -85,12 +86,13 @@ export function buildTools(client: CerebroClient, containerTags: string[], conte
85
86
  },
86
87
  async execute(args) {
87
88
  const allTags = [...containerTags, ...(args.tags ?? [])];
89
+ const effectiveAgentId = context.getAgentName?.() || context.agentId;
88
90
  const result = await client.createMemory(
89
91
  args.content,
90
92
  allTags,
91
93
  args.source,
92
94
  args.scope ?? "project",
93
- context.agentId,
95
+ effectiveAgentId,
94
96
  context.getSessionId(),
95
97
  args.visibility,
96
98
  args.category,
@@ -241,10 +243,12 @@ export function buildTools(client: CerebroClient, containerTags: string[], conte
241
243
  .describe("Session ID to associate with the ingestion"),
242
244
  },
243
245
  async execute(args) {
246
+ const effectiveAgentId = context.getAgentName?.() || context.agentId;
244
247
  const result = await client.ingestMessages(args.messages, {
245
248
  mode: args.mode ?? "smart",
246
249
  tags: args.tags,
247
250
  sessionId: args.session_id,
251
+ agentId: effectiveAgentId,
248
252
  });
249
253
  if (result === null) return JSON.stringify({ ok: false, error: "Ingestion failed" });
250
254
  if (args.session_id) {