@mingxy/cerebro 1.20.4 → 1.20.6
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 +7 -3
- package/src/client.test.ts +373 -0
- package/src/config.test.ts +405 -0
- package/src/hooks-tier1.test.ts +220 -0
- package/src/hooks-tier2.test.ts +275 -0
- package/src/hooks-tier3.test.ts +461 -0
- package/src/hooks.ts +48 -12
- package/src/index.test.ts +190 -0
- package/src/index.ts +12 -2
- package/src/keywords.test.ts +283 -0
- package/src/logger.test.ts +640 -0
- package/src/privacy.test.ts +128 -0
- package/src/tags.test.ts +86 -0
- package/src/tools.test.ts +508 -0
- package/src/tools.ts +34 -0
- package/src/updater.test.ts +380 -0
- package/src/web-server.test.ts +740 -0
- package/src/web-server.ts +8 -2
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// --- Mocks ---
|
|
4
|
+
vi.mock("./logger.js", () => ({
|
|
5
|
+
logInfo: vi.fn(),
|
|
6
|
+
logDebug: vi.fn(),
|
|
7
|
+
logError: vi.fn(),
|
|
8
|
+
setOpencodeClient: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock("./config.js", () => ({
|
|
12
|
+
DEFAULTS: {
|
|
13
|
+
ui: { toastDelayMs: 7000 },
|
|
14
|
+
logging: { logEnabled: false },
|
|
15
|
+
content: { maxContentChars: 6000 },
|
|
16
|
+
},
|
|
17
|
+
resolveAgentPolicy: vi.fn(() => "readwrite"),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock("node:fs/promises", () => ({
|
|
21
|
+
readFile: vi.fn().mockRejectedValue("not found"),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
import { compactingHook, autocontinueHook, sessionIdleHook } from "./hooks.js";
|
|
25
|
+
import { resolveAgentPolicy } from "./config.js";
|
|
26
|
+
|
|
27
|
+
const mockCerebroClient = {
|
|
28
|
+
searchMemories: vi.fn().mockResolvedValue([]),
|
|
29
|
+
ingestMessages: vi.fn().mockResolvedValue({ id: "mem1" }),
|
|
30
|
+
sessionIngest: vi.fn().mockResolvedValue(undefined),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const mockTui = { showToast: vi.fn() };
|
|
34
|
+
const containerTags = ["test-project"];
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
vi.clearAllMocks();
|
|
38
|
+
mockCerebroClient.searchMemories.mockResolvedValue([]);
|
|
39
|
+
mockCerebroClient.ingestMessages.mockResolvedValue({ id: "mem1" });
|
|
40
|
+
mockCerebroClient.sessionIngest.mockResolvedValue(undefined);
|
|
41
|
+
(resolveAgentPolicy as ReturnType<typeof vi.fn>).mockReturnValue("readwrite");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// ========================================
|
|
45
|
+
// compactingHook
|
|
46
|
+
// ========================================
|
|
47
|
+
describe("compactingHook", () => {
|
|
48
|
+
it("returns an async function", () => {
|
|
49
|
+
const hook = compactingHook(mockCerebroClient as any, containerTags, mockTui);
|
|
50
|
+
expect(typeof hook).toBe("function");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("searches memories and injects compaction prompt into output", async () => {
|
|
54
|
+
const hook = compactingHook(mockCerebroClient as any, containerTags, mockTui);
|
|
55
|
+
const input = { sessionID: "sess1" };
|
|
56
|
+
const output = { context: ["existing"], prompt: "old" };
|
|
57
|
+
await hook(input, output);
|
|
58
|
+
expect(mockCerebroClient.searchMemories).toHaveBeenCalledWith("*", 20, undefined, containerTags);
|
|
59
|
+
// output.prompt should be replaced with compaction prompt
|
|
60
|
+
expect(output.prompt).toContain("[Cerebro Compaction Context]");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("appends to context when output.prompt is undefined", async () => {
|
|
64
|
+
const hook = compactingHook(mockCerebroClient as any, containerTags, mockTui);
|
|
65
|
+
const input = { sessionID: "sess1" };
|
|
66
|
+
const output = { context: ["existing"] };
|
|
67
|
+
await hook(input, output);
|
|
68
|
+
expect(output.context.some((c: string) => c.includes("[Cerebro Compaction Context]"))).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("skips ingest when sessionMessages is empty for session", async () => {
|
|
72
|
+
const hook = compactingHook(mockCerebroClient as any, containerTags, mockTui);
|
|
73
|
+
await hook({ sessionID: "no-messages-session" }, { context: [] });
|
|
74
|
+
expect(mockCerebroClient.searchMemories).toHaveBeenCalled();
|
|
75
|
+
expect(mockCerebroClient.ingestMessages).not.toHaveBeenCalled();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("skips write when policy is readonly", async () => {
|
|
79
|
+
(resolveAgentPolicy as ReturnType<typeof vi.fn>).mockReturnValue("readonly");
|
|
80
|
+
const hook = compactingHook(
|
|
81
|
+
mockCerebroClient as any, containerTags, mockTui, "smart",
|
|
82
|
+
undefined, undefined, undefined, {},
|
|
83
|
+
);
|
|
84
|
+
await hook({ sessionID: "readonly-sess" }, { context: [] });
|
|
85
|
+
expect(mockCerebroClient.searchMemories).toHaveBeenCalled();
|
|
86
|
+
expect(mockCerebroClient.ingestMessages).not.toHaveBeenCalled();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("skips non-main session when getMainSessionId mismatches", async () => {
|
|
90
|
+
const getMainSessionId = vi.fn(() => "main-sess");
|
|
91
|
+
const hook = compactingHook(
|
|
92
|
+
mockCerebroClient as any, containerTags, mockTui, "smart",
|
|
93
|
+
undefined, getMainSessionId,
|
|
94
|
+
);
|
|
95
|
+
await hook({ sessionID: "sub-sess" }, { context: [] });
|
|
96
|
+
// Should return early after search phase
|
|
97
|
+
expect(mockCerebroClient.ingestMessages).not.toHaveBeenCalled();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("gracefully handles searchMemories failure", async () => {
|
|
101
|
+
mockCerebroClient.searchMemories.mockRejectedValue(new Error("network"));
|
|
102
|
+
const hook = compactingHook(mockCerebroClient as any, containerTags, mockTui);
|
|
103
|
+
const output = { context: [] };
|
|
104
|
+
await expect(hook({ sessionID: "s1" }, output)).resolves.toBeUndefined();
|
|
105
|
+
expect(mockCerebroClient.ingestMessages).not.toHaveBeenCalled();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ========================================
|
|
110
|
+
// autocontinueHook
|
|
111
|
+
// ========================================
|
|
112
|
+
describe("autocontinueHook", () => {
|
|
113
|
+
const makeInput = (overrides = {}) => ({
|
|
114
|
+
sessionID: "sess1",
|
|
115
|
+
agent: "opencode",
|
|
116
|
+
model: { id: "test" } as any,
|
|
117
|
+
message: { id: "msg1" } as any,
|
|
118
|
+
overflow: false,
|
|
119
|
+
...overrides,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("returns an async function", () => {
|
|
123
|
+
const hook = autocontinueHook(mockCerebroClient as any, containerTags, mockTui);
|
|
124
|
+
expect(typeof hook).toBe("function");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("stores summary when sdkClient returns messages with matching id (level 1 fallback)", async () => {
|
|
128
|
+
const sdkClient = {
|
|
129
|
+
session: {
|
|
130
|
+
messages: vi.fn().mockResolvedValue({
|
|
131
|
+
data: [{
|
|
132
|
+
info: { id: "msg1", role: "assistant" },
|
|
133
|
+
parts: [{ type: "text", text: "This is a summary of the session." }],
|
|
134
|
+
}],
|
|
135
|
+
}),
|
|
136
|
+
get: vi.fn().mockResolvedValue({ data: { directory: "/tmp/project" } }),
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
const hook = autocontinueHook(
|
|
140
|
+
mockCerebroClient as any, containerTags, mockTui, "smart",
|
|
141
|
+
undefined, undefined, sdkClient,
|
|
142
|
+
);
|
|
143
|
+
await hook(makeInput(), { enabled: true });
|
|
144
|
+
expect(mockCerebroClient.ingestMessages).toHaveBeenCalledTimes(1);
|
|
145
|
+
expect(mockCerebroClient.ingestMessages.mock.calls[0][0][0].content).toBe("This is a summary of the session.");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("falls back to summary-flagged message (level 2)", async () => {
|
|
149
|
+
const sdkClient = {
|
|
150
|
+
session: {
|
|
151
|
+
messages: vi.fn().mockResolvedValue({
|
|
152
|
+
data: [
|
|
153
|
+
{ info: { id: "msg-other", role: "user" } },
|
|
154
|
+
{
|
|
155
|
+
info: { id: "msg2", role: "assistant", summary: true },
|
|
156
|
+
parts: [{ type: "text", text: "Summary from flag that is long enough to pass." }],
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
}),
|
|
160
|
+
get: vi.fn().mockResolvedValue({ data: { directory: "/tmp" } }),
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
const hook = autocontinueHook(
|
|
164
|
+
mockCerebroClient as any, containerTags, mockTui, "smart",
|
|
165
|
+
undefined, undefined, sdkClient,
|
|
166
|
+
);
|
|
167
|
+
await hook(makeInput({ message: { id: "not-found" } as any }), { enabled: true });
|
|
168
|
+
expect(mockCerebroClient.ingestMessages).toHaveBeenCalledTimes(1);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("falls back to last assistant message (level 3)", async () => {
|
|
172
|
+
const sdkClient = {
|
|
173
|
+
session: {
|
|
174
|
+
messages: vi.fn().mockResolvedValue({
|
|
175
|
+
data: [
|
|
176
|
+
{
|
|
177
|
+
info: { id: "a1", role: "assistant" },
|
|
178
|
+
parts: [{ type: "text", text: "Last assistant response here with enough content." }],
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
}),
|
|
182
|
+
get: vi.fn().mockResolvedValue({ data: {} }),
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
const hook = autocontinueHook(
|
|
186
|
+
mockCerebroClient as any, containerTags, mockTui, "smart",
|
|
187
|
+
undefined, undefined, sdkClient,
|
|
188
|
+
);
|
|
189
|
+
await hook(makeInput({ message: { id: "none" } as any }), { enabled: true });
|
|
190
|
+
expect(mockCerebroClient.ingestMessages).toHaveBeenCalledTimes(1);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("skips when no sdkClient", async () => {
|
|
194
|
+
const hook = autocontinueHook(mockCerebroClient as any, containerTags, mockTui);
|
|
195
|
+
await hook(makeInput(), { enabled: true });
|
|
196
|
+
expect(mockCerebroClient.ingestMessages).not.toHaveBeenCalled();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("skips when policy is readonly", async () => {
|
|
200
|
+
(resolveAgentPolicy as ReturnType<typeof vi.fn>).mockReturnValue("readonly");
|
|
201
|
+
const sdkClient = {
|
|
202
|
+
session: { messages: vi.fn(), get: vi.fn() },
|
|
203
|
+
};
|
|
204
|
+
const hook = autocontinueHook(
|
|
205
|
+
mockCerebroClient as any, containerTags, mockTui, "smart",
|
|
206
|
+
undefined, undefined, sdkClient,
|
|
207
|
+
);
|
|
208
|
+
await hook(makeInput(), { enabled: true });
|
|
209
|
+
expect(mockCerebroClient.ingestMessages).not.toHaveBeenCalled();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("skips when autoStore is disabled", async () => {
|
|
213
|
+
const isAutoStoreEnabled = vi.fn(() => false);
|
|
214
|
+
const hook = autocontinueHook(
|
|
215
|
+
mockCerebroClient as any, containerTags, mockTui, "smart",
|
|
216
|
+
isAutoStoreEnabled,
|
|
217
|
+
);
|
|
218
|
+
await hook(makeInput(), { enabled: true });
|
|
219
|
+
expect(mockCerebroClient.ingestMessages).not.toHaveBeenCalled();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("skips when summaryText is empty", async () => {
|
|
223
|
+
const sdkClient = {
|
|
224
|
+
session: {
|
|
225
|
+
messages: vi.fn().mockResolvedValue({ data: [] }),
|
|
226
|
+
get: vi.fn(),
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
const hook = autocontinueHook(
|
|
230
|
+
mockCerebroClient as any, containerTags, mockTui, "smart",
|
|
231
|
+
undefined, undefined, sdkClient,
|
|
232
|
+
);
|
|
233
|
+
await hook(makeInput(), { enabled: true });
|
|
234
|
+
expect(mockCerebroClient.ingestMessages).not.toHaveBeenCalled();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("shows error toast when ingestMessages throws", async () => {
|
|
238
|
+
mockCerebroClient.ingestMessages.mockRejectedValue(new Error("fail"));
|
|
239
|
+
const sdkClient = {
|
|
240
|
+
session: {
|
|
241
|
+
messages: vi.fn().mockResolvedValue({
|
|
242
|
+
data: [{
|
|
243
|
+
info: { id: "msg1", role: "assistant" },
|
|
244
|
+
parts: [{ type: "text", text: "Summary text here that is definitely long enough to pass." }],
|
|
245
|
+
}],
|
|
246
|
+
}),
|
|
247
|
+
get: vi.fn().mockResolvedValue({ data: {} }),
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
const hook = autocontinueHook(
|
|
251
|
+
mockCerebroClient as any, containerTags, mockTui, "smart",
|
|
252
|
+
undefined, undefined, sdkClient,
|
|
253
|
+
);
|
|
254
|
+
await hook(makeInput(), { enabled: true });
|
|
255
|
+
// Should not throw
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// ========================================
|
|
260
|
+
// sessionIdleHook
|
|
261
|
+
// ========================================
|
|
262
|
+
describe("sessionIdleHook", () => {
|
|
263
|
+
const makeEvent = (type: string, properties: any = {}) => ({
|
|
264
|
+
event: { type, properties },
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("returns an async function", () => {
|
|
268
|
+
const hook = sessionIdleHook(mockCerebroClient as any, containerTags, mockTui, {});
|
|
269
|
+
expect(typeof hook).toBe("function");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("handles message.updated event", async () => {
|
|
273
|
+
const sdkClient = {
|
|
274
|
+
session: {
|
|
275
|
+
messages: vi.fn().mockResolvedValue({
|
|
276
|
+
data: [{
|
|
277
|
+
info: { role: "assistant", summary: true },
|
|
278
|
+
parts: [{ type: "text", text: "This is a decent summary of the session content." }],
|
|
279
|
+
}],
|
|
280
|
+
}),
|
|
281
|
+
get: vi.fn().mockResolvedValue({ data: { directory: "/tmp/project" } }),
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
const hook = sessionIdleHook(
|
|
285
|
+
mockCerebroClient as any, containerTags, mockTui, sdkClient,
|
|
286
|
+
);
|
|
287
|
+
await hook(makeEvent("message.updated", {
|
|
288
|
+
info: { role: "assistant", sessionID: "s1", finish: true },
|
|
289
|
+
}));
|
|
290
|
+
// Should trigger handleSummaryCapture → ingestMessages
|
|
291
|
+
expect(mockCerebroClient.ingestMessages).toHaveBeenCalledTimes(1);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("skips message.updated for non-assistant role", async () => {
|
|
295
|
+
const hook = sessionIdleHook(mockCerebroClient as any, containerTags, mockTui, {});
|
|
296
|
+
await hook(makeEvent("message.updated", {
|
|
297
|
+
info: { role: "user", sessionID: "s1" },
|
|
298
|
+
}));
|
|
299
|
+
expect(mockCerebroClient.ingestMessages).not.toHaveBeenCalled();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("skips message.updated when finish is false", async () => {
|
|
303
|
+
const hook = sessionIdleHook(mockCerebroClient as any, containerTags, mockTui, {});
|
|
304
|
+
await hook(makeEvent("message.updated", {
|
|
305
|
+
info: { role: "assistant", sessionID: "s1", finish: false },
|
|
306
|
+
}));
|
|
307
|
+
expect(mockCerebroClient.ingestMessages).not.toHaveBeenCalled();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("handles session.deleted event (cleanup)", async () => {
|
|
311
|
+
const hook = sessionIdleHook(mockCerebroClient as any, containerTags, mockTui, {});
|
|
312
|
+
// Should not throw
|
|
313
|
+
await hook(makeEvent("session.deleted", {
|
|
314
|
+
info: { id: "s1" },
|
|
315
|
+
}));
|
|
316
|
+
expect(mockCerebroClient.ingestMessages).not.toHaveBeenCalled();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("ignores unknown event types", async () => {
|
|
320
|
+
const hook = sessionIdleHook(mockCerebroClient as any, containerTags, mockTui, {});
|
|
321
|
+
await hook(makeEvent("unknown.event", {}));
|
|
322
|
+
expect(mockCerebroClient.ingestMessages).not.toHaveBeenCalled();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("handles session.idle event with setTimeout", async () => {
|
|
326
|
+
vi.useFakeTimers();
|
|
327
|
+
const sdkClient = {
|
|
328
|
+
session: {
|
|
329
|
+
messages: vi.fn().mockResolvedValue({
|
|
330
|
+
data: [{
|
|
331
|
+
info: { id: "m1", role: "user", createdAt: new Date().toISOString() },
|
|
332
|
+
parts: [{ type: "text", text: "hello world" }],
|
|
333
|
+
}],
|
|
334
|
+
}),
|
|
335
|
+
get: vi.fn().mockResolvedValue({ data: { directory: "/tmp", agent: "opencode" } }),
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
const onAgentResolved = vi.fn();
|
|
339
|
+
const hook = sessionIdleHook(
|
|
340
|
+
mockCerebroClient as any, containerTags, mockTui, sdkClient,
|
|
341
|
+
"smart", 0, undefined, undefined, "opencode", {}, onAgentResolved,
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
await hook(makeEvent("session.idle", { sessionID: "s1" }));
|
|
345
|
+
|
|
346
|
+
// Not yet called (10s timeout)
|
|
347
|
+
expect(mockCerebroClient.sessionIngest).not.toHaveBeenCalled();
|
|
348
|
+
|
|
349
|
+
// Advance past 10s
|
|
350
|
+
await vi.advanceTimersByTimeAsync(11000);
|
|
351
|
+
|
|
352
|
+
expect(mockCerebroClient.sessionIngest).toHaveBeenCalledTimes(1);
|
|
353
|
+
expect(onAgentResolved).toHaveBeenCalledWith("opencode");
|
|
354
|
+
|
|
355
|
+
vi.useRealTimers();
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("skips session.idle when autoStore is disabled", async () => {
|
|
359
|
+
vi.useFakeTimers();
|
|
360
|
+
const isAutoStoreEnabled = vi.fn(() => false);
|
|
361
|
+
const hook = sessionIdleHook(
|
|
362
|
+
mockCerebroClient as any, containerTags, mockTui, {},
|
|
363
|
+
"smart", 0, undefined, isAutoStoreEnabled,
|
|
364
|
+
);
|
|
365
|
+
await hook(makeEvent("session.idle", { sessionID: "s1" }));
|
|
366
|
+
await vi.advanceTimersByTimeAsync(11000);
|
|
367
|
+
expect(mockCerebroClient.sessionIngest).not.toHaveBeenCalled();
|
|
368
|
+
vi.useRealTimers();
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("skips session.idle for non-main session", async () => {
|
|
372
|
+
vi.useFakeTimers();
|
|
373
|
+
const getMainSessionId = vi.fn(() => "main-sess");
|
|
374
|
+
const hook = sessionIdleHook(
|
|
375
|
+
mockCerebroClient as any, containerTags, mockTui, {},
|
|
376
|
+
"smart", 0, getMainSessionId,
|
|
377
|
+
);
|
|
378
|
+
await hook(makeEvent("session.idle", { sessionID: "sub-sess" }));
|
|
379
|
+
await vi.advanceTimersByTimeAsync(11000);
|
|
380
|
+
expect(mockCerebroClient.sessionIngest).not.toHaveBeenCalled();
|
|
381
|
+
vi.useRealTimers();
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("skips session.idle when policy is readonly", async () => {
|
|
385
|
+
vi.useFakeTimers();
|
|
386
|
+
(resolveAgentPolicy as ReturnType<typeof vi.fn>).mockReturnValue("readonly");
|
|
387
|
+
const sdkClient = {
|
|
388
|
+
session: {
|
|
389
|
+
messages: vi.fn().mockResolvedValue({
|
|
390
|
+
data: [{
|
|
391
|
+
info: { id: "policy-m1", role: "user", createdAt: new Date().toISOString() },
|
|
392
|
+
parts: [{ type: "text", text: "hello" }],
|
|
393
|
+
}],
|
|
394
|
+
}),
|
|
395
|
+
get: vi.fn().mockResolvedValue({ data: { directory: "/tmp" } }),
|
|
396
|
+
},
|
|
397
|
+
};
|
|
398
|
+
const hook = sessionIdleHook(
|
|
399
|
+
mockCerebroClient as any, containerTags, mockTui, sdkClient,
|
|
400
|
+
"smart", 0, undefined, undefined, "opencode", {},
|
|
401
|
+
);
|
|
402
|
+
await hook(makeEvent("session.idle", { sessionID: "policy-sess" }));
|
|
403
|
+
await vi.advanceTimersByTimeAsync(11000);
|
|
404
|
+
expect(mockCerebroClient.sessionIngest).not.toHaveBeenCalled();
|
|
405
|
+
vi.useRealTimers();
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("skips session.idle when no sessionID", async () => {
|
|
409
|
+
const hook = sessionIdleHook(mockCerebroClient as any, containerTags, mockTui, {});
|
|
410
|
+
await hook(makeEvent("session.idle", {}));
|
|
411
|
+
expect(mockCerebroClient.sessionIngest).not.toHaveBeenCalled();
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("skips messages below threshold", async () => {
|
|
415
|
+
vi.useFakeTimers();
|
|
416
|
+
const sdkClient = {
|
|
417
|
+
session: {
|
|
418
|
+
messages: vi.fn().mockResolvedValue({
|
|
419
|
+
data: [{
|
|
420
|
+
info: { id: "thresh-m1", role: "user", createdAt: new Date().toISOString() },
|
|
421
|
+
parts: [{ type: "text", text: "just one msg" }],
|
|
422
|
+
}],
|
|
423
|
+
}),
|
|
424
|
+
get: vi.fn().mockResolvedValue({ data: { directory: "/tmp", agent: "opencode" } }),
|
|
425
|
+
},
|
|
426
|
+
};
|
|
427
|
+
const hook = sessionIdleHook(
|
|
428
|
+
mockCerebroClient as any, containerTags, mockTui, sdkClient,
|
|
429
|
+
"smart", 5,
|
|
430
|
+
undefined, undefined, "opencode", {},
|
|
431
|
+
);
|
|
432
|
+
await hook(makeEvent("session.idle", { sessionID: "thresh-sess" }));
|
|
433
|
+
await vi.advanceTimersByTimeAsync(11000);
|
|
434
|
+
expect(mockCerebroClient.sessionIngest).not.toHaveBeenCalled();
|
|
435
|
+
vi.useRealTimers();
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("shows error toast on sessionIngest failure", async () => {
|
|
439
|
+
vi.useFakeTimers();
|
|
440
|
+
mockCerebroClient.sessionIngest.mockRejectedValue(new Error("server error"));
|
|
441
|
+
const sdkClient = {
|
|
442
|
+
session: {
|
|
443
|
+
messages: vi.fn().mockResolvedValue({
|
|
444
|
+
data: [{
|
|
445
|
+
info: { id: "err-m1", role: "user", createdAt: new Date().toISOString() },
|
|
446
|
+
parts: [{ type: "text", text: "hello world test" }],
|
|
447
|
+
}],
|
|
448
|
+
}),
|
|
449
|
+
get: vi.fn().mockResolvedValue({ data: { directory: "/tmp", agent: "opencode" } }),
|
|
450
|
+
},
|
|
451
|
+
};
|
|
452
|
+
const hook = sessionIdleHook(
|
|
453
|
+
mockCerebroClient as any, containerTags, mockTui, sdkClient,
|
|
454
|
+
"smart", 0, undefined, undefined, "opencode", {},
|
|
455
|
+
);
|
|
456
|
+
await hook(makeEvent("session.idle", { sessionID: "err-sess" }));
|
|
457
|
+
await vi.advanceTimersByTimeAsync(11000);
|
|
458
|
+
expect(mockCerebroClient.sessionIngest).toHaveBeenCalled();
|
|
459
|
+
vi.useRealTimers();
|
|
460
|
+
});
|
|
461
|
+
});
|
package/src/hooks.ts
CHANGED
|
@@ -4,6 +4,13 @@ import { type CerebroPluginConfig, DEFAULTS, resolveAgentPolicy } from "./config
|
|
|
4
4
|
import { logDebug, logInfo, logError as logErr } from "./logger.js";
|
|
5
5
|
import { readFile } from "node:fs/promises";
|
|
6
6
|
|
|
7
|
+
/** Sanitize session ID to prevent path traversal */
|
|
8
|
+
function sanitizeSessionId(id: string | undefined): string | undefined {
|
|
9
|
+
if (!id) return id;
|
|
10
|
+
// Remove any path separators or traversal attempts
|
|
11
|
+
return id.replace(/[/\\]/g, "_").replace(/\.\./g, "");
|
|
12
|
+
}
|
|
13
|
+
|
|
7
14
|
const BOUNDARY_SEARCH_RATIO = 0.6;
|
|
8
15
|
|
|
9
16
|
const projectNameCache = new Map<string, string>();
|
|
@@ -504,7 +511,7 @@ export function compactingHook(client: CerebroClient, containerTags: string[], t
|
|
|
504
511
|
return;
|
|
505
512
|
}
|
|
506
513
|
|
|
507
|
-
const effectiveSessionId = (getMainSessionId?.() || input.sessionID);
|
|
514
|
+
const effectiveSessionId = sanitizeSessionId(getMainSessionId?.() || input.sessionID);
|
|
508
515
|
|
|
509
516
|
// Resolve project name (shared by ingest + poll)
|
|
510
517
|
let projectName: string | undefined;
|
|
@@ -615,7 +622,7 @@ export function autocontinueHook(
|
|
|
615
622
|
return;
|
|
616
623
|
}
|
|
617
624
|
|
|
618
|
-
const effectiveSessionId = getMainSessionId?.() || input.sessionID;
|
|
625
|
+
const effectiveSessionId = sanitizeSessionId(getMainSessionId?.() || input.sessionID);
|
|
619
626
|
|
|
620
627
|
if (!sdkClient) {
|
|
621
628
|
logInfo("autocontinueHook skipped: no sdkClient", { sessionId: input.sessionID });
|
|
@@ -626,9 +633,21 @@ export function autocontinueHook(
|
|
|
626
633
|
try {
|
|
627
634
|
const response = await sdkClient.session.messages({ path: { id: input.sessionID } });
|
|
628
635
|
if (response?.data) {
|
|
629
|
-
|
|
636
|
+
let targetMsg = response.data.find(
|
|
630
637
|
(msg: any) => msg.info?.id === input.message.id,
|
|
631
638
|
);
|
|
639
|
+
|
|
640
|
+
if (!targetMsg?.parts) {
|
|
641
|
+
targetMsg = response.data.find(
|
|
642
|
+
(msg: any) => msg.info?.role === "assistant" && msg.info?.summary === true,
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (!targetMsg?.parts) {
|
|
647
|
+
const assistants = response.data.filter((msg: any) => msg.info?.role === "assistant");
|
|
648
|
+
if (assistants.length > 0) targetMsg = assistants[assistants.length - 1];
|
|
649
|
+
}
|
|
650
|
+
|
|
632
651
|
if (targetMsg?.parts) {
|
|
633
652
|
const textParts = (targetMsg.parts as any[])
|
|
634
653
|
.filter((p: any) => p.type === "text" && p.text)
|
|
@@ -640,8 +659,8 @@ export function autocontinueHook(
|
|
|
640
659
|
logErr("autocontinueHook failed to fetch message parts", { error: String(e) });
|
|
641
660
|
}
|
|
642
661
|
|
|
643
|
-
if (!summaryText) {
|
|
644
|
-
logInfo("autocontinueHook skipped:
|
|
662
|
+
if (!summaryText || summaryText.length < 30) {
|
|
663
|
+
logInfo("autocontinueHook skipped: summary too short", { sessionId: input.sessionID, messageId: input.message.id, summaryLen: summaryText?.length ?? 0 });
|
|
645
664
|
return;
|
|
646
665
|
}
|
|
647
666
|
|
|
@@ -713,11 +732,21 @@ export function sessionIdleHook(
|
|
|
713
732
|
async function handleSummaryCapture(props: any) {
|
|
714
733
|
const info = props?.info;
|
|
715
734
|
if (!info) return;
|
|
716
|
-
if (info.role !== "assistant"
|
|
735
|
+
if (info.role !== "assistant") return;
|
|
736
|
+
// info.summary may be missing in some SDK versions — handle below
|
|
737
|
+
// info.finish check: only process on finish, but allow missing field
|
|
738
|
+
if (info.finish === false) return;
|
|
717
739
|
|
|
718
|
-
const sessionID = info.sessionID;
|
|
740
|
+
const sessionID = sanitizeSessionId(info.sessionID);
|
|
719
741
|
if (!sessionID) return;
|
|
720
742
|
|
|
743
|
+
logInfo("handleSummaryCapture checking", {
|
|
744
|
+
sessionID,
|
|
745
|
+
role: info?.role,
|
|
746
|
+
hasSummary: !!info?.summary,
|
|
747
|
+
finish: info?.finish,
|
|
748
|
+
});
|
|
749
|
+
|
|
721
750
|
if (summarizedSessions.has(sessionID)) return;
|
|
722
751
|
summarizedSessions.add(sessionID);
|
|
723
752
|
|
|
@@ -749,24 +778,31 @@ export function sessionIdleHook(
|
|
|
749
778
|
const resp = await sdkClient.session.messages({ path: { id: sessionID } });
|
|
750
779
|
const messages = resp?.data ?? resp;
|
|
751
780
|
|
|
752
|
-
|
|
781
|
+
let summaryMsg = (messages as Array<{ info: any; parts?: Array<{ type: string; text?: string }> }>).find((m) =>
|
|
753
782
|
m.info?.role === "assistant" && m.info?.summary === true
|
|
754
783
|
);
|
|
755
784
|
|
|
756
785
|
if (!summaryMsg?.parts) {
|
|
757
|
-
logInfo("handleSummaryCapture: no summary
|
|
786
|
+
logInfo("handleSummaryCapture: no summary-flagged message, trying last assistant message", { sessionID });
|
|
787
|
+
const assistantMsgs = (messages as Array<{ info: any; parts?: Array<{ type: string; text?: string }> }>)
|
|
788
|
+
.filter(m => m.info?.role === "assistant");
|
|
789
|
+
summaryMsg = assistantMsgs.length > 0 ? assistantMsgs[assistantMsgs.length - 1] : undefined;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
if (!summaryMsg?.parts) {
|
|
793
|
+
logInfo("handleSummaryCapture: no assistant message parts found", { sessionID });
|
|
758
794
|
return;
|
|
759
795
|
}
|
|
760
796
|
|
|
761
797
|
const textParts = summaryMsg.parts.filter((p) => p.type === "text" && p.text).map((p) => p.text);
|
|
762
798
|
const summaryContent = textParts.join("\n").trim();
|
|
763
799
|
|
|
764
|
-
if (!summaryContent || summaryContent.length <
|
|
800
|
+
if (!summaryContent || summaryContent.length < 30) {
|
|
765
801
|
logInfo("handleSummaryCapture: summary too short", { sessionID, length: summaryContent?.length ?? 0 });
|
|
766
802
|
return;
|
|
767
803
|
}
|
|
768
804
|
|
|
769
|
-
const effectiveSessionId = getMainSessionId?.() || sessionID;
|
|
805
|
+
const effectiveSessionId = sanitizeSessionId(getMainSessionId?.() || sessionID);
|
|
770
806
|
|
|
771
807
|
let projectName: string | undefined;
|
|
772
808
|
let projectPath: string | undefined;
|
|
@@ -829,7 +865,7 @@ export function sessionIdleHook(
|
|
|
829
865
|
|
|
830
866
|
logDebug("sessionIdleHook event.properties dump", { keys: Object.keys(input.event.properties || {}), raw: JSON.stringify(input.event.properties).substring(0, 2000) });
|
|
831
867
|
|
|
832
|
-
const sessionID = input.event.properties?.sessionID;
|
|
868
|
+
const sessionID = sanitizeSessionId(input.event.properties?.sessionID);
|
|
833
869
|
if (!sessionID) return;
|
|
834
870
|
|
|
835
871
|
if (isAutoStoreEnabled && !isAutoStoreEnabled(sessionID)) return;
|