@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.
@@ -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
- const targetMsg = response.data.find(
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: no summary text found", { sessionId: input.sessionID, messageId: input.message.id });
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" || !info.summary || !info.finish) return;
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
- const summaryMsg = (messages as Array<{ info: any; parts?: Array<{ type: string; text?: string }> }>).find((m) =>
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 parts found", { sessionID });
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 < 100) {
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;