@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.
- package/INJECTION_FLOW.md +434 -0
- package/package.json +1 -1
- package/src/config.ts +36 -4
- package/src/hooks.ts +82 -23
- package/src/index.ts +13 -6
- package/src/tools.ts +5 -1
|
@@ -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
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
|
-
|
|
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
|
-
|
|
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,
|
|
148
|
-
if (text.length <=
|
|
149
|
-
|
|
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.
|
|
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
|
-
|
|
293
|
+
profileBlock = [
|
|
271
294
|
"<cerebro-profile>",
|
|
272
|
-
JSON.stringify(profile
|
|
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,
|
|
307
|
-
: buildContextBlock(newResults,
|
|
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)
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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, () =>
|
|
136
|
-
tool: buildTools(cerebroClient, containerTags, { agentId, getSessionId: () =>
|
|
137
|
-
event: sessionIdleHook(cerebroClient, containerTags, tui, client, config.ingest.ingestMode, config.ingest.autoCaptureThreshold, () =>
|
|
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
|
-
|
|
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) {
|