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