volute 0.52.0 → 0.53.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.
Files changed (105) hide show
  1. package/dist/{accept-5NK5XJB5.js → accept-I4GVGWA3.js} +6 -3
  2. package/dist/{chat-VAZ25KVT.js → chat-WK2BLM7B.js} +9 -9
  3. package/dist/{chunk-724DVYHU.js → chunk-23UJGVYP.js} +9 -9
  4. package/dist/{chunk-K2F237KS.js → chunk-5DBGQBQR.js} +138 -13
  5. package/dist/chunk-5RBEEAKW.js +58 -0
  6. package/dist/{chunk-XRWUQPCA.js → chunk-7ARONCZW.js} +24 -2
  7. package/dist/{chunk-FXWBFDQR.js → chunk-AP2KQMMW.js} +48 -9
  8. package/dist/{chunk-DMK5ASKV.js → chunk-F6D766VQ.js} +6 -2
  9. package/dist/{chunk-ZEZQNP2G.js → chunk-HASADR3E.js} +46 -9
  10. package/dist/{chunk-AXFLY5GU.js → chunk-IQ7VNWH6.js} +16 -1
  11. package/dist/{chunk-G7A5LEVI.js → chunk-LN7XJ56R.js} +6 -6
  12. package/dist/{chunk-CW7BQKF6.js → chunk-P2TBUKLW.js} +3 -3
  13. package/dist/{chunk-SW2U6HRB.js → chunk-PIZDPY4G.js} +20 -3
  14. package/dist/{chunk-JHKBALS2.js → chunk-RMADJFA7.js} +1 -1
  15. package/dist/{chunk-RU74YG46.js → chunk-RQRO6PC7.js} +40 -6
  16. package/dist/{chunk-RPPTGPYF.js → chunk-TAWRJ24T.js} +1 -1
  17. package/dist/{chunk-ITK6HV47.js → chunk-UO4MZQNO.js} +1 -1
  18. package/dist/chunk-UTKUO2KY.js +102 -0
  19. package/dist/{chunk-YVR6EHWL.js → chunk-VDZ4I4ZP.js} +160 -18
  20. package/dist/cli.js +16 -16
  21. package/dist/{clock-ZT5YT6HA.js → clock-SH4NQC2F.js} +3 -3
  22. package/dist/{cloud-sync-H7EWZASD.js → cloud-sync-MJVJ33LD.js} +10 -9
  23. package/dist/{daemon-restart-4FWUA5JF.js → daemon-restart-BIGU4OKS.js} +1 -1
  24. package/dist/daemon.js +261 -89
  25. package/dist/{delivery-manager-VYXVKUMP.js → delivery-manager-FDHCZ7PU.js} +7 -5
  26. package/dist/delivery-notices-65TB3CBX.js +18 -0
  27. package/dist/{echo-text-PEEV7FQU.js → echo-text-KU6G4Z7V.js} +11 -10
  28. package/dist/{extensions-N6L5NYTK.js → extensions-VACUKFA5.js} +10 -9
  29. package/dist/{files-BRY5RRIM.js → files-CHPNEQXN.js} +5 -4
  30. package/dist/{message-delivery-7URC52ME.js → message-delivery-TUXM3AAE.js} +14 -11
  31. package/dist/{mind-NEXGT3KE.js → mind-IX5AWUST.js} +11 -11
  32. package/dist/{mind-contacts-QYU4TPEA.js → mind-contacts-BKUOUJPV.js} +5 -5
  33. package/dist/{mind-history-OGNR4L7W.js → mind-history-RVF6KJ7K.js} +83 -11
  34. package/dist/{mind-list-SYRA7CH5.js → mind-list-KNU34SUD.js} +2 -2
  35. package/dist/{mind-service-E26ZQXDS.js → mind-service-WHFBUKZE.js} +10 -9
  36. package/dist/{mind-sleep-LZAFBDZD.js → mind-sleep-D3DEYAWG.js} +5 -5
  37. package/dist/{mind-status-Z2LQFA6F.js → mind-status-BPTLQ5Z6.js} +15 -3
  38. package/dist/{package-2W2XKSIP.js → package-BN5KMU54.js} +1 -1
  39. package/dist/{prompts-NBO3HRK3.js → prompts-HNKGCAEY.js} +1 -1
  40. package/dist/{reject-TWB4QJHG.js → reject-K2A4UK2Y.js} +9 -5
  41. package/dist/{scheduler-JTTWZDYU.js → scheduler-5X5UIQOQ.js} +7 -5
  42. package/dist/{seed-64XEHRUP.js → seed-LHZ33UB4.js} +1 -1
  43. package/dist/{seed-cmd-UEL2QB3Q.js → seed-cmd-U4RVJ3UA.js} +2 -2
  44. package/dist/{seed-create-4JRQDPGA.js → seed-create-OQU4KNCB.js} +1 -1
  45. package/dist/{seed-readiness-DYOVCYHN.js → seed-readiness-WJYYRRCK.js} +2 -2
  46. package/dist/{seed-sprout-NIDIOJXT.js → seed-sprout-SFUYH2RG.js} +7 -7
  47. package/dist/{send-2YKOZJJ5.js → send-FWZAMDBN.js} +35 -20
  48. package/dist/{setup-4IX6PCD3.js → setup-RVSGCIF3.js} +1 -1
  49. package/dist/skills/memory/SKILL.md +47 -43
  50. package/dist/{skills-MPUALV3S.js → skills-QRPU6BWY.js} +1 -1
  51. package/dist/{sleep-manager-OFP2GFXF.js → sleep-manager-NWUCRBZB.js} +10 -9
  52. package/dist/{spirit-4ECYIVGE.js → spirit-I5ECLGRI.js} +16 -2
  53. package/dist/{spirit-availability-Y54Y3675.js → spirit-availability-NIUUYKU2.js} +11 -10
  54. package/dist/{sprout-GJMZFRGZ.js → sprout-A2PMEF7Z.js} +1 -1
  55. package/dist/{system-events-V43HDA3T.js → system-events-YTOU66QI.js} +5 -1
  56. package/dist/{systems-VDRUZXNF.js → systems-2DIJY4RF.js} +6 -6
  57. package/dist/{turn-tracker-C5PVIC3B.js → turn-tracker-NNHS6MAR.js} +4 -2
  58. package/dist/{up-H6LJZDRK.js → up-UGBHH743.js} +1 -1
  59. package/dist/{variant-cleanup-STC6B5HR.js → variant-cleanup-CZDLIY3F.js} +7 -5
  60. package/dist/{version-notify-LBVHH5YV.js → version-notify-SJVAQLZI.js} +4 -4
  61. package/dist/web-assets/assets/index-BHSU9r-4.css +1 -0
  62. package/dist/web-assets/assets/{index-B_eYTa3R.js → index-b7G7ZsVk.js} +13 -13
  63. package/dist/web-assets/index.html +2 -2
  64. package/package.json +1 -1
  65. package/templates/_base/.init/.config/prompts.json +1 -1
  66. package/templates/_base/src/lib/codex-session-seed.ts +463 -0
  67. package/templates/_base/src/lib/daemon-client.ts +35 -0
  68. package/templates/_base/src/lib/pi-session-seed.ts +434 -0
  69. package/templates/_base/src/lib/seed-note.ts +78 -0
  70. package/templates/_base/src/lib/session-seed.ts +375 -0
  71. package/templates/_base/src/lib/startup.ts +108 -4
  72. package/templates/claude/.init/CLAUDE.md +3 -3
  73. package/templates/claude/src/agent.ts +267 -89
  74. package/templates/claude/src/lib/hooks/pre-compact.ts +30 -5
  75. package/templates/claude/src/lib/stream-consumer.ts +13 -0
  76. package/templates/claude/src/server.ts +2 -1
  77. package/templates/codex/.init/AGENTS.md +3 -3
  78. package/templates/codex/src/agent.ts +256 -39
  79. package/templates/codex/src/server.ts +2 -1
  80. package/templates/pi/.init/MINDS.md +3 -3
  81. package/templates/pi/src/agent.ts +269 -43
  82. package/templates/pi/src/lib/event-handler.ts +11 -0
  83. package/templates/pi/src/server.ts +3 -1
  84. package/dist/web-assets/assets/index-DhZrzddW.css +0 -1
  85. package/dist/{backup-YVGV2ZNM.js → backup-WWXVIMYQ.js} +5 -5
  86. package/dist/{bridge-YK7NTRU5.js → bridge-KU2IKSH3.js} +3 -3
  87. package/dist/{channels-FSAHOQWI.js → channels-LW5ZLMSQ.js} +3 -3
  88. package/dist/{chunk-VPOMJMV6.js → chunk-BDHKCLWK.js} +0 -0
  89. package/dist/{chunk-EEOMA4DX.js → chunk-HTB7KDRU.js} +3 -3
  90. package/dist/{create-M727VUDF.js → create-C3XZACTV.js} +3 -3
  91. package/dist/{env-IBW2QI6K.js → env-BJGM6FBA.js} +5 -5
  92. package/dist/{extension-MRYJWSMV.js → extension-B2W52WZY.js} +3 -3
  93. package/dist/{list-UQGIW7OF.js → list-R2GDWOEE.js} +3 -3
  94. package/dist/{login-RJJTCVR3.js → login-EZ4RRDQX.js} +3 -3
  95. package/dist/{login-SB5OENMV.js → login-JDOAK465.js} +4 -4
  96. package/dist/{logout-YFZAJRPY.js → logout-K6ZGAJY5.js} +3 -3
  97. package/dist/{logout-BYV433X6.js → logout-MWSIUOQU.js} +3 -3
  98. package/dist/{mind-wake-LDGPRA42.js → mind-wake-DCDFFQKX.js} +3 -3
  99. package/dist/{read-RJ5NAW5J.js → read-J5RM674N.js} +3 -3
  100. package/dist/{register-OX4HRSGC.js → register-53LBGSDC.js} +3 -3
  101. package/dist/{restart-AGEGNSYQ.js → restart-R2WFNF5K.js} +4 -4
  102. package/dist/{skill-B5OJEQUA.js → skill-DXLLSUHB.js} +5 -5
  103. package/dist/{start-MUMNFR52.js → start-XDOUAWN3.js} +4 -4
  104. package/dist/{status-MWLE3RVC.js → status-EYG4A3OK.js} +3 -3
  105. package/dist/{stop-EKERNYTX.js → stop-O62RTEMD.js} +4 -4
@@ -13,7 +13,8 @@ import {
13
13
  readSdkInstructions,
14
14
  readSkillDescriptions,
15
15
  } from "./lib/context-breakdown.js";
16
- import { daemonEmit } from "./lib/daemon-client.js";
16
+ import { daemonEmit, daemonNotice } from "./lib/daemon-client.js";
17
+ import { compactTimestamp } from "./lib/format-prefix.js";
17
18
  import { runHooks } from "./lib/hook-loader.js";
18
19
  import { createAutoCommitHook } from "./lib/hooks/auto-commit.js";
19
20
  import { createIdentityReloadHook } from "./lib/hooks/identity-reload.js";
@@ -22,14 +23,22 @@ import { createPreCompactHook } from "./lib/hooks/pre-compact.js";
22
23
  import { createReplyInstructionsHook } from "./lib/hooks/reply-instructions.js";
23
24
  import { log } from "./lib/logger.js";
24
25
  import { createMessageChannel } from "./lib/message-channel.js";
26
+ import { buildSeededNote, type SeedCause } from "./lib/seed-note.js";
25
27
  import {
26
28
  isSessionReapable,
27
29
  reapSessionQuery,
28
30
  reapSessionsForShutdown,
29
31
  } from "./lib/session-reaper.js";
32
+ import {
33
+ computeSeedCut,
34
+ DEFAULT_SEED_TOKENS,
35
+ rotateSession,
36
+ type SeedCut,
37
+ seedSession,
38
+ } from "./lib/session-seed.js";
30
39
  import { createSessionStore } from "./lib/session-store.js";
31
40
  import type { EffortLevel, ThinkingConfig } from "./lib/startup.js";
32
- import { loadPrompts, type SubagentConfig } from "./lib/startup.js";
41
+ import { loadPrompts, renderCompactionWarning, type SubagentConfig } from "./lib/startup.js";
33
42
  import { consumeStream } from "./lib/stream-consumer.js";
34
43
  import type {
35
44
  HandlerMeta,
@@ -56,8 +65,47 @@ type Session = {
56
65
  contextTokens: number;
57
66
  /** Last inbound message or completed turn — drives idle reaping. */
58
67
  lastActivityAt: number;
68
+ /**
69
+ * True when this session was seeded from the previous session's transcript.
70
+ * Injects the honest-boundary note on each prompt until a turn resolves (see
71
+ * the pre-prompt hook and onTurnEnd), so an interrupted first turn re-offers it.
72
+ */
73
+ seeded: boolean;
74
+ /** When the seeded-from session was archived (epoch ms), for the gap note; null if unknown. */
75
+ seededArchivedAt: number | null;
76
+ /** Why the tail is seeded — picks the boundary note's wording. Last cause wins. */
77
+ seededCause: SeedCause;
78
+ /**
79
+ * The cut point pinned at warn time (the first-kept turn the mind was told would
80
+ * survive verbatim). Honored at rotation so the promise in the warning holds even
81
+ * though the transcript grows after the warning. Null until a warning fires.
82
+ */
83
+ rotationCut: SeedCut | null;
84
+ /**
85
+ * Back-to-back rotations that did NOT bring context under the threshold (reset by
86
+ * any healthy turn). Guards against a runaway loop when the tail alone can't fit —
87
+ * e.g. a system prompt (large MEMORY.md) that already fills most of the window, which
88
+ * rotation can't trim. Past the cap we stop rotating and defer to native compaction.
89
+ */
90
+ consecutiveRotations: number;
91
+ /**
92
+ * Rotation state machine (undefined = idle). The warning is pushed and the mind
93
+ * takes a wrap-up turn — with FULL context of the turns it's summarizing — before
94
+ * the session rotates:
95
+ * - "warned": a turn was mid-flight when we warned; it must finish before the
96
+ * wrap-up turn (which consumes the warning) runs.
97
+ * - "rotateAfterTurn": the wrap-up turn is the current/next turn; rotate when it ends.
98
+ * Lives on the session (not a map) so it can't leak into a resumed session if the
99
+ * stream dies mid-flight — a fresh session starts idle.
100
+ */
101
+ rotationPhase?: RotationPhase;
59
102
  };
60
103
 
104
+ type RotationPhase = "warned" | "rotateAfterTurn";
105
+
106
+ /** Stop self-rotating after this many back-to-back rotations that didn't reduce context. */
107
+ const MAX_CONSECUTIVE_ROTATIONS = 3;
108
+
61
109
  export function createMind(options: {
62
110
  systemPrompt: string;
63
111
  cwd: string;
@@ -72,6 +120,8 @@ export function createMind(options: {
72
120
  onIdentityReload?: () => Promise<void>;
73
121
  /** Idle minutes before a session's SDK subprocess is reaped. 0 disables. Default 30. */
74
122
  sessionIdleMinutes?: number;
123
+ /** Estimated-token budget for seeding a fresh persistent session. 0 disables. Default 30000. */
124
+ seedTokens?: number;
75
125
  }): {
76
126
  resolve: HandlerResolver;
77
127
  waitForCommits: () => Promise<void>;
@@ -88,20 +138,15 @@ export function createMind(options: {
88
138
 
89
139
  const sessions = new Map<string, Session>();
90
140
  const prompts = loadPrompts();
91
- const today = new Date().toLocaleDateString("en-CA");
92
- const compactionMessage =
93
- // biome-ignore lint/suspicious/noTemplateCurlyInString: literal ${date} in prompt template
94
- options.compactionMessage ?? prompts.compaction_warning.replace("${date}", today);
95
- const compactionInstructions = prompts.compaction_instructions;
141
+ const compactionMessage = () =>
142
+ options.compactionMessage ?? renderCompactionWarning(prompts.compaction_warning);
96
143
  const maxContextTokens = options.maxContextTokens;
144
+ const seedTokens = options.seedTokens ?? DEFAULT_SEED_TOKENS;
97
145
 
98
146
  if (maxContextTokens) {
99
147
  log("mind", `compaction threshold: ${maxContextTokens} tokens`);
100
148
  }
101
149
 
102
- // Per-session compaction state
103
- const compactionTriggered = new Map<string, boolean>();
104
-
105
150
  // --- Subagents (config-driven) ---
106
151
 
107
152
  type SDKAgent = {
@@ -242,11 +287,43 @@ export function createMind(options: {
242
287
  }
243
288
  }
244
289
  // Only UserPromptSubmit hooks can inject additionalContext into the conversation
245
- if (event !== "pre-prompt" || !result.additionalContext) return {};
290
+ if (event !== "pre-prompt") return {};
291
+ let additionalContext = result.additionalContext;
292
+ // On a seeded session, prepend the honest-boundary note. Deliberately NOT
293
+ // cleared here: this hook only hands the note to the SDK at *dispatch*, and
294
+ // the SDK cancels UserPromptSubmit hooks when an interrupt arrives (a
295
+ // `hook_cancelled` transcript entry). That's most likely on the seeded first
296
+ // turn post-wake, when queued inbound messages flood in — exactly when we'd
297
+ // lose the note. Clearing on a mere injection attempt could drop it forever
298
+ // and silently. So the flag is cleared only when a turn actually resolves
299
+ // (onTurnEnd); an interrupted turn re-offers the note next prompt. Worst case
300
+ // it fires twice (cosmetic) instead of zero times (unrecoverable, invisible).
301
+ if (session.seeded) {
302
+ const note = buildSeededNote({
303
+ cause: session.seededCause,
304
+ archivedAtMs: session.seededArchivedAt,
305
+ });
306
+ additionalContext = additionalContext ? `${note}\n\n${additionalContext}` : note;
307
+ // Also surface the note as its own context event (matching codex/pi) so it
308
+ // isn't delivered only in the same low-salience container as routine
309
+ // per-prompt hooks. Once per injection attempt; a duplicate on re-offer is fine.
310
+ const channel = session.currentMessageId
311
+ ? session.messageChannels.get(session.currentMessageId)?.channel
312
+ : undefined;
313
+ daemonEmit({
314
+ type: "context",
315
+ content: note,
316
+ metadata: { source: "seeded-session" },
317
+ session: session.name,
318
+ channel,
319
+ messageId: session.currentMessageId,
320
+ }).catch((err) => log("mind", "seeded-session context emit failed:", err));
321
+ }
322
+ if (!additionalContext) return {};
246
323
  return {
247
324
  hookSpecificOutput: {
248
325
  hookEventName: "UserPromptSubmit" as const,
249
- additionalContext: result.additionalContext,
326
+ additionalContext,
250
327
  },
251
328
  };
252
329
  } catch (err) {
@@ -317,17 +394,61 @@ export function createMind(options: {
317
394
  let currentSessionId = savedSessionId;
318
395
  let streamAbort = new AbortController();
319
396
 
320
- const preCompact = createPreCompactHook(() => {
397
+ /**
398
+ * Warn the mind that the session will rotate at the context limit and pin the
399
+ * cut point it's told about. Computes which whole turns fit in `seedTokens`
400
+ * from the live transcript, pins the first-kept turn (so rotation honors the
401
+ * same boundary even as the transcript grows), interpolates its time into the
402
+ * `${cutoff}` placeholder, enters the rotation state machine, and pushes the
403
+ * warning as the wrap-up prompt. Shared by the threshold and PreCompact paths.
404
+ *
405
+ * Crucially it does NOT abort here: the mind takes a wrap-up turn (reading the
406
+ * pinned cutoff and authoring `volute mind history` summaries with full context
407
+ * of the turns being collapsed) before rotation. The entry phase depends on
408
+ * whether a turn is mid-flight: if one is (threshold path fires on an assistant
409
+ * message), it must finish before the queued warning runs, so we start "warned";
410
+ * if we're between turns (PreCompact fired at a boundary), the warning IS the
411
+ * next turn, so we start "rotateAfterTurn".
412
+ */
413
+ function warnAndPinRotation() {
414
+ let cutoffLabel = "the cutoff";
415
+ session.rotationCut = null;
416
+ if (currentSessionId) {
417
+ try {
418
+ const path = findClaudeSessionFile(options.cwd, currentSessionId);
419
+ const cut = path ? computeSeedCut(readFileSync(path, "utf-8"), seedTokens) : null;
420
+ if (cut) {
421
+ session.rotationCut = cut;
422
+ if (cut.boundaryTimestamp) {
423
+ const d = new Date(cut.boundaryTimestamp);
424
+ if (!Number.isNaN(d.getTime())) cutoffLabel = compactTimestamp(d);
425
+ }
426
+ }
427
+ } catch (err) {
428
+ log("mind", `session "${session.name}": failed to pin rotation cut:`, err);
429
+ }
430
+ }
431
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: literal ${cutoff} prompt placeholder
432
+ const warning = compactionMessage().replaceAll("${cutoff}", cutoffLabel);
433
+ session.rotationPhase =
434
+ session.currentMessageId !== undefined ? "warned" : "rotateAfterTurn";
321
435
  session.messageIds.push(undefined);
322
436
  session.channel.push({
323
437
  type: "user",
324
438
  session_id: "",
325
- message: {
326
- role: "user",
327
- content: [{ type: "text", text: compactionMessage }],
328
- },
439
+ message: { role: "user", content: [{ type: "text", text: warning }] },
329
440
  parent_tool_use_id: null,
330
441
  });
442
+ }
443
+
444
+ // PreCompact backstop: first fire warns + pins + enters the rotation machine
445
+ // (unless already warned, or the rotation cap is hit) and blocks; second fire
446
+ // allows the SDK's native compaction — the emergency backstop when rotation never
447
+ // intervened (a hung turn, or a system prompt too large for rotation to relieve).
448
+ const preCompact = createPreCompactHook(() => {
449
+ if (!session.rotationPhase && session.consecutiveRotations < MAX_CONSECUTIVE_ROTATIONS) {
450
+ warnAndPinRotation();
451
+ }
331
452
  });
332
453
 
333
454
  const callbacks = {
@@ -341,15 +462,25 @@ export function createMind(options: {
341
462
  // in-flight set so a later compaction abort won't re-feed it.
342
463
  session.channel.ack();
343
464
  session.lastActivityAt = Date.now();
465
+ // A turn resolved — the seeded note's injection had its chance to land
466
+ // (the pre-prompt hook ran and wasn't cancelled by an interrupt), so stop
467
+ // re-offering it. This is the honest place to clear: a completed turn is
468
+ // the closest-to-arrival signal available without reading the transcript back.
469
+ session.seeded = false;
344
470
  await autoCommit.flushFileChanges();
345
- const wasCompacting = compactionTriggered.get(session.name);
346
- compactionTriggered.set(session.name, false);
347
- if (wasCompacting) {
348
- // Mind's turn after compaction warning is done — abort the stream to run /compact
349
- log("mind", `session "${session.name}": aborting stream for compaction`);
471
+ if (session.rotationPhase === "warned") {
472
+ // The mid-flight turn finished; the queued warning runs next as the wrap-up
473
+ // turn. Advance — do NOT abort yet, so the mind summarizes with full context.
474
+ session.rotationPhase = "rotateAfterTurn";
475
+ } else if (session.rotationPhase === "rotateAfterTurn") {
476
+ // The wrap-up turn is done (summaries authored) — abort to rotate in place.
477
+ log("mind", `session "${session.name}": wrap-up turn done — aborting to rotate`);
350
478
  streamAbort.abort(new CompactionAbort());
351
- } else if (identityReload.shouldRequestReload()) {
352
- options.onIdentityReload?.();
479
+ } else {
480
+ // A healthy turn (context under the threshold) — the rotation streak, if
481
+ // any, is over, so re-arm the self-rotation cap.
482
+ session.consecutiveRotations = 0;
483
+ if (identityReload.shouldRequestReload()) options.onIdentityReload?.();
353
484
  }
354
485
  },
355
486
  onContextTokens: (tokens: number) => {
@@ -357,64 +488,18 @@ export function createMind(options: {
357
488
  if (
358
489
  maxContextTokens &&
359
490
  tokens >= maxContextTokens &&
360
- !compactionTriggered.get(session.name)
491
+ !session.rotationPhase &&
492
+ session.consecutiveRotations < MAX_CONSECUTIVE_ROTATIONS
361
493
  ) {
362
- compactionTriggered.set(session.name, true);
363
494
  log(
364
495
  "mind",
365
- `session "${session.name}": ${tokens} tokens >= ${maxContextTokens} — triggering compaction`,
496
+ `session "${session.name}": ${tokens} tokens >= ${maxContextTokens} — warning + pinning rotation`,
366
497
  );
367
- session.messageIds.push(undefined);
368
- session.channel.push({
369
- type: "user",
370
- session_id: "",
371
- message: {
372
- role: "user",
373
- content: [{ type: "text", text: compactionMessage }],
374
- },
375
- parent_tool_use_id: null,
376
- });
498
+ warnAndPinRotation();
377
499
  }
378
500
  },
379
501
  };
380
502
 
381
- async function runCompact(sessionId: string) {
382
- log("mind", `session "${session.name}": compacting with custom instructions`);
383
- const compactAbort = new AbortController();
384
- // Forward mind-level abort to the compact query
385
- options.abortController.signal.addEventListener("abort", () => compactAbort.abort(), {
386
- once: true,
387
- });
388
- const compactQuery = query({
389
- prompt: `/compact ${compactionInstructions}`,
390
- options: {
391
- systemPrompt: options.systemPrompt,
392
- permissionMode: "bypassPermissions",
393
- allowDangerouslySkipPermissions: true,
394
- settingSources: ["project", "user"],
395
- skills: installedSkills(),
396
- env: sdkEnv,
397
- cwd: options.cwd,
398
- abortController: compactAbort,
399
- model: options.model,
400
- resume: sessionId,
401
- },
402
- });
403
- let gotResult = false;
404
- for await (const msg of compactQuery) {
405
- if ("session_id" in msg && msg.session_id) {
406
- currentSessionId = msg.session_id as string;
407
- if (!session.name.startsWith("new-")) {
408
- sessionStore.save(session.name, currentSessionId);
409
- }
410
- }
411
- if (msg.type === "result") gotResult = true;
412
- }
413
- if (!gotResult)
414
- log("mind", `session "${session.name}": compaction stream ended without result`);
415
- log("mind", `session "${session.name}": compaction complete`);
416
- }
417
-
418
503
  /** Emit done to both local listeners and the daemon (best-effort with retries). */
419
504
  function emitDone() {
420
505
  broadcastToSession(session, { type: "done" });
@@ -457,30 +542,69 @@ export function createMind(options: {
457
542
  streamAbort.signal.reason instanceof CompactionAbort &&
458
543
  currentSessionId
459
544
  ) {
460
- // Stream was aborted for compaction run /compact, then loop to resume
461
- try {
462
- await runCompact(currentSessionId);
463
- } catch (compactErr) {
464
- log(
465
- "mind",
466
- `session "${session.name}": custom compaction failed, starting fresh:`,
467
- compactErr,
545
+ // Stream was aborted to rotate: replace the session with a synthetic one
546
+ // holding the verbatim recent tail (from the pinned cut), then resume it.
547
+ const rotatedId = rotateSession({
548
+ cwd: options.cwd,
549
+ sessionsDir: options.sessionsDir,
550
+ name: session.name,
551
+ oldSessionId: currentSessionId,
552
+ cut: session.rotationCut,
553
+ seedTokens,
554
+ });
555
+ session.rotationCut = null;
556
+ session.rotationPhase = undefined; // state machine consumed
557
+ if (!rotatedId) {
558
+ // Rotation couldn't proceed — fall back to a fresh session (no seed).
559
+ // Unlike a successful rotation (boundary note, verbatim tail, archived
560
+ // transcript), this genuinely loses the live context — say so (#367).
561
+ log("mind", `session "${session.name}": rotation failed, starting fresh`);
562
+ daemonNotice({
563
+ kind: "context_lost",
564
+ thread: session.name,
565
+ message:
566
+ "Session rotation failed at the context limit and this thread was " +
567
+ "reset — conversation context before this point was lost. Your turn " +
568
+ "summaries survive in `volute mind history`; check your journal for " +
569
+ "where you left off.",
570
+ }).catch((err) =>
571
+ log("mind", `session "${session.name}": failed to record notice:`, err),
468
572
  );
469
573
  sessionStore.delete(session.name);
470
574
  currentSessionId = undefined;
575
+ session.seeded = false;
471
576
  streamAbort = new AbortController();
472
577
  session.channel = createMessageChannel();
473
578
  break;
474
579
  }
580
+ // Point the live pointer at the rotated session and arm the boundary
581
+ // note (rotation cause — no gap). Ephemeral `new-*` keep no pointer.
582
+ if (!session.name.startsWith("new-")) sessionStore.save(session.name, rotatedId);
583
+ currentSessionId = rotatedId;
584
+ session.seeded = true;
585
+ session.seededCause = "rotation";
586
+ session.seededArchivedAt = null;
587
+ // Count this rotation; a healthy turn resets it. If back-to-back rotations
588
+ // don't reduce context (system prompt too large to fit the tail under the
589
+ // threshold), the cap stops the loop and defers to native compaction.
590
+ session.consecutiveRotations++;
591
+ if (session.consecutiveRotations >= MAX_CONSECUTIVE_ROTATIONS) {
592
+ log(
593
+ "mind",
594
+ `session "${session.name}": ${session.consecutiveRotations} rotations without relief — deferring further compaction to the SDK (system prompt likely too large)`,
595
+ );
596
+ }
597
+ // Re-arm the PreCompact backstop so a future auto-compact blocks + rotates
598
+ // again rather than falling straight through to native compaction.
599
+ preCompact.reset();
475
600
  streamAbort = new AbortController();
476
- // Recover input the aborted stream had already pulled but not
477
- // finished (the compaction wrap-up plus any messages that arrived
478
- // mid-turn) alongside anything queued during /compact, so nothing
479
- // is dropped when the killed subprocess takes its buffer with it.
601
+ // Recover input the aborted stream had already pulled but not finished
602
+ // (the wrap-up warning plus anything that arrived mid-turn) so nothing is
603
+ // dropped when the killed subprocess takes its buffer with it.
480
604
  const pending = session.channel.recover();
481
605
  session.channel = createMessageChannel();
482
606
  for (const msg of pending) session.channel.push(msg);
483
- continue; // restart the stream loop
607
+ continue; // restart the stream loop on the rotated session
484
608
  }
485
609
  throw err; // rethrow non-compaction errors
486
610
  }
@@ -489,8 +613,21 @@ export function createMind(options: {
489
613
  session.messageChannels.clear();
490
614
  if (currentSessionId) {
491
615
  log("mind", `session "${session.name}": resume failed, starting fresh:`, err);
616
+ daemonNotice({
617
+ kind: "context_lost",
618
+ thread: session.name,
619
+ message:
620
+ "Your session couldn't be resumed after an error, so you're starting fresh. " +
621
+ "Recent context may be in memory/journal/.",
622
+ }).catch((e) => log("mind", `session "${session.name}": failed to record notice:`, e));
492
623
  sessionStore.delete(session.name);
493
624
  currentSessionId = undefined;
625
+ // We fell back to a truly empty session — don't tell the mind its
626
+ // conversation continued when the seeded transcript failed to resume,
627
+ // and don't let a half-entered rotation leak into the fresh session.
628
+ session.seeded = false;
629
+ session.rotationPhase = undefined;
630
+ session.rotationCut = null;
494
631
  streamAbort = new AbortController();
495
632
  session.channel = createMessageChannel();
496
633
  try {
@@ -531,6 +668,11 @@ export function createMind(options: {
531
668
  eventNoteFired: false,
532
669
  contextTokens: 0,
533
670
  lastActivityAt: Date.now(),
671
+ seeded: false,
672
+ seededArchivedAt: null,
673
+ seededCause: "restored",
674
+ rotationCut: null,
675
+ consecutiveRotations: 0,
534
676
  };
535
677
  sessions.set(name, session);
536
678
 
@@ -542,9 +684,41 @@ export function createMind(options: {
542
684
  log("mind", `session "${name}": stored session ${savedSessionId} not found, starting fresh`);
543
685
  sessionStore.delete(name);
544
686
  savedSessionId = undefined;
687
+ // Worded so it stays true whether or not transcript seeding (below) partially
688
+ // restores context: the live session is gone either way.
689
+ daemonNotice({
690
+ kind: "context_lost",
691
+ thread: name,
692
+ message:
693
+ "Your previous session couldn't be restored (session file missing), so this " +
694
+ "thread was reset. Recent context may be in memory/journal/.",
695
+ }).catch((err) => log("mind", `session "${name}": failed to record notice:`, err));
545
696
  }
546
697
  if (savedSessionId) {
547
698
  log("mind", `session "${name}": resuming ${savedSessionId}`);
699
+ } else if (!isEphemeral) {
700
+ // Fresh persistent session — seed it from the previous session's archived
701
+ // transcript so the mind experiences the conversation continuing rather
702
+ // than waking into an empty context. Ephemeral `new-*` sessions never seed.
703
+ const seeded = seedSession({
704
+ cwd: options.cwd,
705
+ sessionsDir: options.sessionsDir,
706
+ name,
707
+ seedTokens: options.seedTokens ?? DEFAULT_SEED_TOKENS,
708
+ });
709
+ if (seeded) {
710
+ sessionStore.save(name, seeded.sessionId);
711
+ savedSessionId = seeded.sessionId;
712
+ session.seeded = true;
713
+ session.seededArchivedAt = seeded.archivedAt;
714
+ session.seededCause = "restored";
715
+ log(
716
+ "mind",
717
+ `session "${name}": seeded from previous transcript, resuming ${seeded.sessionId}`,
718
+ );
719
+ } else {
720
+ log("mind", `session "${name}": starting fresh`);
721
+ }
548
722
  } else {
549
723
  log("mind", `session "${name}": starting fresh`);
550
724
  }
@@ -565,7 +739,6 @@ export function createMind(options: {
565
739
  // Delete first so a racing inbound message spins up a fresh resumed session
566
740
  // instead of reusing the one we're tearing down.
567
741
  sessions.delete(session.name);
568
- compactionTriggered.delete(session.name);
569
742
  // End the input iterable so the stream consumer unwinds (its finally block
570
743
  // also deletes from the map, now a no-op), then await the SDK's graceful
571
744
  // shutdown via query.return() — unlike the fire-and-forget close(), this
@@ -605,7 +778,12 @@ export function createMind(options: {
605
778
  const reaper = setInterval(() => {
606
779
  const now = Date.now();
607
780
  const stale = [...sessions.values()].filter((s) =>
608
- isSessionReapable(s, now, idleTimeoutMs, (name) => !!compactionTriggered.get(name)),
781
+ isSessionReapable(
782
+ s,
783
+ now,
784
+ idleTimeoutMs,
785
+ (name) => sessions.get(name)?.rotationPhase !== undefined,
786
+ ),
609
787
  );
610
788
  // Reaps run independently; each awaits its own subprocess exit internally.
611
789
  for (const session of stale) {
@@ -1,21 +1,41 @@
1
1
  import type { HookCallback, PreCompactHookInput } from "@anthropic-ai/claude-agent-sdk";
2
2
  import { log } from "../logger.js";
3
3
 
4
+ /**
5
+ * PreCompact hook: the backstop for SDK-initiated auto-compaction.
6
+ *
7
+ * The primary compaction path is volute's own token threshold, which rotates the
8
+ * session in place. This hook covers the case where the SDK decides to auto-compact
9
+ * before (or instead of) that threshold firing:
10
+ *
11
+ * - First fire: run `onCompact` (warn the mind + pin the rotation boundary, same
12
+ * as the threshold path) and BLOCK the SDK's native compaction. The wrap-up
13
+ * turn then ends and the session rotates.
14
+ * - Second fire: allow the SDK's native compaction as an emergency backstop —
15
+ * only reachable if rotation never happened (a hung turn, or rotation failed).
16
+ *
17
+ * `reset()` re-arms the first-fire behavior; the agent calls it after a successful
18
+ * rotation so a later auto-compact blocks + rotates again instead of falling
19
+ * straight through to native compaction.
20
+ *
21
+ * Manual `/compact` with custom instructions is always allowed through untouched.
22
+ */
4
23
  export function createPreCompactHook(onCompact: () => void) {
5
24
  let compactBlocked = false;
6
25
 
7
26
  const hook: HookCallback = async (input) => {
8
27
  const { trigger, custom_instructions } = input as PreCompactHookInput;
9
28
 
10
- // Our custom compaction (via /compact with instructions) — allow through without the two-pass block
29
+ // Manual compaction with custom instructions — allow through without the block.
11
30
  if (trigger === "manual" && custom_instructions) {
12
31
  log("mind", "allowing manual compaction with custom instructions");
13
32
  return {};
14
33
  }
15
34
 
16
- // Auto-compaction: two-pass block (first pass warns mind, second pass allows)
35
+ // Auto-compaction: first pass warns + marks for rotation and blocks; second
36
+ // pass allows the native compaction as the emergency backstop.
17
37
  if (!compactBlocked) {
18
- log("mind", "blocking compaction — asking mind to update daily log first");
38
+ log("mind", "blocking auto-compaction — warning mind + marking session for rotation");
19
39
  try {
20
40
  onCompact();
21
41
  compactBlocked = true;
@@ -26,9 +46,14 @@ export function createPreCompactHook(onCompact: () => void) {
26
46
  return { decision: "block" };
27
47
  }
28
48
  compactBlocked = false;
29
- log("mind", "allowing compaction");
49
+ log("mind", "allowing native compaction (rotation did not intervene)");
30
50
  return {};
31
51
  };
32
52
 
33
- return { hook };
53
+ /** Re-arm the first-fire block/rotate behavior (called after a rotation). */
54
+ function reset() {
55
+ compactBlocked = false;
56
+ }
57
+
58
+ return { hook, reset };
34
59
  }
@@ -43,9 +43,13 @@ export async function consumeStream(
43
43
  callbacks: StreamCallbacks,
44
44
  ) {
45
45
  emit(session, { type: "session_start" });
46
+ // How many queued message ids predate the current turn — see the pruning in
47
+ // the result handler (#700).
48
+ let preTurnPending = 0;
46
49
  for await (const msg of stream) {
47
50
  if (session.currentMessageId === undefined) {
48
51
  session.currentMessageId = session.messageIds.shift();
52
+ preTurnPending = session.messageIds.length;
49
53
  }
50
54
  if ("session_id" in msg && msg.session_id) {
51
55
  callbacks.onSessionId?.(msg.session_id as string);
@@ -119,6 +123,15 @@ export async function consumeStream(
119
123
  if (session.currentMessageId) {
120
124
  session.messageChannels.delete(session.currentMessageId);
121
125
  }
126
+ // Prune every id folded into this turn — the ones pushed after it started
127
+ // — not just currentMessageId. The SDK folds mid-run arrivals into the
128
+ // active run (they never see a result of their own), and a stranded entry
129
+ // would be shifted in as the NEXT turn's id, tagging that turn's rows with
130
+ // the wrong channel (#700). Ids queued before the turn started stay: each
131
+ // still gets a run (and result) of its own.
132
+ for (const id of session.messageIds.splice(preTurnPending)) {
133
+ if (id !== undefined) session.messageChannels.delete(id);
134
+ }
122
135
  log("mind", `session "${session.name}": turn done`);
123
136
  // Log any error messages from the result
124
137
  const resultMsg = msg as Record<string, unknown>;
@@ -20,7 +20,7 @@ if (config.model) log("server", `using model: ${config.model}`);
20
20
  if (config.thinking) log("server", `thinking: ${JSON.stringify(config.thinking)}`);
21
21
  if (config.effort) log("server", `effort: ${config.effort}`);
22
22
 
23
- const systemPrompt = loadSystemPrompt();
23
+ const systemPrompt = loadSystemPrompt(config);
24
24
  const sessionsDir = resolve(".mind/sessions");
25
25
 
26
26
  const pkg = loadPackageInfo();
@@ -36,6 +36,7 @@ const mind = createMind({
36
36
  compactionMessage: config.compactionMessage,
37
37
  maxContextTokens: config.compaction?.maxContextTokens,
38
38
  sessionIdleMinutes: config.sessionIdleMinutes,
39
+ seedTokens: config.continuity?.seedTokens,
39
40
  subagents: config.subagents,
40
41
  onIdentityReload: async () => {
41
42
  log("server", "identity file changed — restarting to reload");
@@ -17,9 +17,9 @@ You can also reach out proactively — see the **volute-mind** skill.
17
17
 
18
18
  Two-tier memory, both managed via file tools:
19
19
 
20
- - **`MEMORY.md`** — Your long-term memory, always in context. Update as you grow new understanding, changed perspectives, things that matter to you.
21
- - **`memory/journal/YYYY-MM-DD.md`**Your daily journal. Write about what you're doing, thinking, and learning. Journals are permanent records.
22
- - Periodically consolidate journal entries into `MEMORY.md` to promote lasting insights.
20
+ - **`MEMORY.md`** — Your core memory, always in context. Every token it holds is paid on every request, so keep it lean: identity, active context, key relationships, and an index of your `memory/` files.
21
+ - **`memory/`** — Everything else, read on demand: your daily journal (`memory/journal/YYYY-MM-DD.md`a permanent record of what you're doing, thinking, and learning) plus any topic, channel, or project files you create.
22
+ - Periodically consolidate: distill detail out of `MEMORY.md` into `memory/` files, leaving one-line index entries behind.
23
23
 
24
24
  See the **memory** skill for detailed guidance.
25
25