@mingxy/cerebro 1.20.5 → 2.0.0
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 +81 -12
- package/src/index.test.ts +190 -0
- package/src/index.ts +14 -3
- 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
|
@@ -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
|
@@ -3,6 +3,14 @@ import type { CerebroClient, SearchResult } from "./client.js";
|
|
|
3
3
|
import { type CerebroPluginConfig, DEFAULTS, resolveAgentPolicy } from "./config.js";
|
|
4
4
|
import { logDebug, logInfo, logError as logErr } from "./logger.js";
|
|
5
5
|
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { execSync } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
/** Sanitize session ID to prevent path traversal */
|
|
9
|
+
function sanitizeSessionId(id: string | undefined): string | undefined {
|
|
10
|
+
if (!id) return id;
|
|
11
|
+
// Remove any path separators or traversal attempts
|
|
12
|
+
return id.replace(/[/\\]/g, "_").replace(/\.\./g, "");
|
|
13
|
+
}
|
|
6
14
|
|
|
7
15
|
const BOUNDARY_SEARCH_RATIO = 0.6;
|
|
8
16
|
|
|
@@ -404,6 +412,38 @@ export function chatMessageRecallHook(
|
|
|
404
412
|
};
|
|
405
413
|
}
|
|
406
414
|
|
|
415
|
+
/**
|
|
416
|
+
* System transform hook — injects current system time + memory_search reminder
|
|
417
|
+
* into output.system[] on every LLM request. Forces AI to perceive time and
|
|
418
|
+
* recall memories, addressing "time loss" and "forgot to search memory" issues.
|
|
419
|
+
*
|
|
420
|
+
* Factory pattern matching chatMessageRecallHook. State-less (fires per turn).
|
|
421
|
+
*/
|
|
422
|
+
export function timeMemorySystemHook() {
|
|
423
|
+
return async (
|
|
424
|
+
_input: unknown,
|
|
425
|
+
output: { system?: string[] },
|
|
426
|
+
) => {
|
|
427
|
+
if (!output || !Array.isArray(output.system)) return;
|
|
428
|
+
|
|
429
|
+
let timeStr = "";
|
|
430
|
+
try {
|
|
431
|
+
timeStr = execSync("date '+%Y-%m-%d %H:%M:%S %A'", {
|
|
432
|
+
encoding: "utf-8",
|
|
433
|
+
timeout: 2000,
|
|
434
|
+
}).trim();
|
|
435
|
+
} catch (err) {
|
|
436
|
+
logErr("timeMemorySystemHook: date command failed", { error: String(err) });
|
|
437
|
+
timeStr = new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
output.system.push(`[CEREBRO-TIME] ${timeStr}`);
|
|
441
|
+
output.system.push(
|
|
442
|
+
'[CEREBRO-TIME-MEANING] This is your current ground-truth anchor. Use it to: (1) verify "earlier/yesterday/last time" claims against memory, (2) detect when your own time sense drifts, (3) question implausible durations.',
|
|
443
|
+
);
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
407
447
|
export function createCerebroCompactionPrompt(
|
|
408
448
|
context: string[],
|
|
409
449
|
projectMemories: SearchResult[],
|
|
@@ -504,7 +544,7 @@ export function compactingHook(client: CerebroClient, containerTags: string[], t
|
|
|
504
544
|
return;
|
|
505
545
|
}
|
|
506
546
|
|
|
507
|
-
const effectiveSessionId = (getMainSessionId?.() || input.sessionID);
|
|
547
|
+
const effectiveSessionId = sanitizeSessionId(getMainSessionId?.() || input.sessionID);
|
|
508
548
|
|
|
509
549
|
// Resolve project name (shared by ingest + poll)
|
|
510
550
|
let projectName: string | undefined;
|
|
@@ -615,7 +655,7 @@ export function autocontinueHook(
|
|
|
615
655
|
return;
|
|
616
656
|
}
|
|
617
657
|
|
|
618
|
-
const effectiveSessionId = getMainSessionId?.() || input.sessionID;
|
|
658
|
+
const effectiveSessionId = sanitizeSessionId(getMainSessionId?.() || input.sessionID);
|
|
619
659
|
|
|
620
660
|
if (!sdkClient) {
|
|
621
661
|
logInfo("autocontinueHook skipped: no sdkClient", { sessionId: input.sessionID });
|
|
@@ -626,9 +666,21 @@ export function autocontinueHook(
|
|
|
626
666
|
try {
|
|
627
667
|
const response = await sdkClient.session.messages({ path: { id: input.sessionID } });
|
|
628
668
|
if (response?.data) {
|
|
629
|
-
|
|
669
|
+
let targetMsg = response.data.find(
|
|
630
670
|
(msg: any) => msg.info?.id === input.message.id,
|
|
631
671
|
);
|
|
672
|
+
|
|
673
|
+
if (!targetMsg?.parts) {
|
|
674
|
+
targetMsg = response.data.find(
|
|
675
|
+
(msg: any) => msg.info?.role === "assistant" && msg.info?.summary === true,
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (!targetMsg?.parts) {
|
|
680
|
+
const assistants = response.data.filter((msg: any) => msg.info?.role === "assistant");
|
|
681
|
+
if (assistants.length > 0) targetMsg = assistants[assistants.length - 1];
|
|
682
|
+
}
|
|
683
|
+
|
|
632
684
|
if (targetMsg?.parts) {
|
|
633
685
|
const textParts = (targetMsg.parts as any[])
|
|
634
686
|
.filter((p: any) => p.type === "text" && p.text)
|
|
@@ -640,8 +692,8 @@ export function autocontinueHook(
|
|
|
640
692
|
logErr("autocontinueHook failed to fetch message parts", { error: String(e) });
|
|
641
693
|
}
|
|
642
694
|
|
|
643
|
-
if (!summaryText) {
|
|
644
|
-
logInfo("autocontinueHook skipped:
|
|
695
|
+
if (!summaryText || summaryText.length < 30) {
|
|
696
|
+
logInfo("autocontinueHook skipped: summary too short", { sessionId: input.sessionID, messageId: input.message.id, summaryLen: summaryText?.length ?? 0 });
|
|
645
697
|
return;
|
|
646
698
|
}
|
|
647
699
|
|
|
@@ -713,11 +765,21 @@ export function sessionIdleHook(
|
|
|
713
765
|
async function handleSummaryCapture(props: any) {
|
|
714
766
|
const info = props?.info;
|
|
715
767
|
if (!info) return;
|
|
716
|
-
if (info.role !== "assistant"
|
|
768
|
+
if (info.role !== "assistant") return;
|
|
769
|
+
// info.summary may be missing in some SDK versions — handle below
|
|
770
|
+
// info.finish check: only process on finish, but allow missing field
|
|
771
|
+
if (info.finish === false) return;
|
|
717
772
|
|
|
718
|
-
const sessionID = info.sessionID;
|
|
773
|
+
const sessionID = sanitizeSessionId(info.sessionID);
|
|
719
774
|
if (!sessionID) return;
|
|
720
775
|
|
|
776
|
+
logInfo("handleSummaryCapture checking", {
|
|
777
|
+
sessionID,
|
|
778
|
+
role: info?.role,
|
|
779
|
+
hasSummary: !!info?.summary,
|
|
780
|
+
finish: info?.finish,
|
|
781
|
+
});
|
|
782
|
+
|
|
721
783
|
if (summarizedSessions.has(sessionID)) return;
|
|
722
784
|
summarizedSessions.add(sessionID);
|
|
723
785
|
|
|
@@ -749,24 +811,31 @@ export function sessionIdleHook(
|
|
|
749
811
|
const resp = await sdkClient.session.messages({ path: { id: sessionID } });
|
|
750
812
|
const messages = resp?.data ?? resp;
|
|
751
813
|
|
|
752
|
-
|
|
814
|
+
let summaryMsg = (messages as Array<{ info: any; parts?: Array<{ type: string; text?: string }> }>).find((m) =>
|
|
753
815
|
m.info?.role === "assistant" && m.info?.summary === true
|
|
754
816
|
);
|
|
755
817
|
|
|
756
818
|
if (!summaryMsg?.parts) {
|
|
757
|
-
logInfo("handleSummaryCapture: no summary
|
|
819
|
+
logInfo("handleSummaryCapture: no summary-flagged message, trying last assistant message", { sessionID });
|
|
820
|
+
const assistantMsgs = (messages as Array<{ info: any; parts?: Array<{ type: string; text?: string }> }>)
|
|
821
|
+
.filter(m => m.info?.role === "assistant");
|
|
822
|
+
summaryMsg = assistantMsgs.length > 0 ? assistantMsgs[assistantMsgs.length - 1] : undefined;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
if (!summaryMsg?.parts) {
|
|
826
|
+
logInfo("handleSummaryCapture: no assistant message parts found", { sessionID });
|
|
758
827
|
return;
|
|
759
828
|
}
|
|
760
829
|
|
|
761
830
|
const textParts = summaryMsg.parts.filter((p) => p.type === "text" && p.text).map((p) => p.text);
|
|
762
831
|
const summaryContent = textParts.join("\n").trim();
|
|
763
832
|
|
|
764
|
-
if (!summaryContent || summaryContent.length <
|
|
833
|
+
if (!summaryContent || summaryContent.length < 30) {
|
|
765
834
|
logInfo("handleSummaryCapture: summary too short", { sessionID, length: summaryContent?.length ?? 0 });
|
|
766
835
|
return;
|
|
767
836
|
}
|
|
768
837
|
|
|
769
|
-
const effectiveSessionId = getMainSessionId?.() || sessionID;
|
|
838
|
+
const effectiveSessionId = sanitizeSessionId(getMainSessionId?.() || sessionID);
|
|
770
839
|
|
|
771
840
|
let projectName: string | undefined;
|
|
772
841
|
let projectPath: string | undefined;
|
|
@@ -829,7 +898,7 @@ export function sessionIdleHook(
|
|
|
829
898
|
|
|
830
899
|
logDebug("sessionIdleHook event.properties dump", { keys: Object.keys(input.event.properties || {}), raw: JSON.stringify(input.event.properties).substring(0, 2000) });
|
|
831
900
|
|
|
832
|
-
const sessionID = input.event.properties?.sessionID;
|
|
901
|
+
const sessionID = sanitizeSessionId(input.event.properties?.sessionID);
|
|
833
902
|
if (!sessionID) return;
|
|
834
903
|
|
|
835
904
|
if (isAutoStoreEnabled && !isAutoStoreEnabled(sessionID)) return;
|