volute 0.52.0 → 0.54.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 (153) hide show
  1. package/dist/{accept-5NK5XJB5.js → accept-RSDIVJL6.js} +3 -0
  2. package/dist/auto-upgrade-HEVZKPTF.js +46 -0
  3. package/dist/{backup-YVGV2ZNM.js → backup-QRNX6RK7.js} +10 -10
  4. package/dist/channels-CKCY64TD.js +203 -0
  5. package/dist/{chat-VAZ25KVT.js → chat-ZUGNL5GS.js} +5 -5
  6. package/dist/chunk-3LHUXY3E.js +102 -0
  7. package/dist/{chunk-SW2U6HRB.js → chunk-444RR3YF.js} +21 -4
  8. package/dist/{chunk-YVR6EHWL.js → chunk-56IT2ZXF.js} +160 -18
  9. package/dist/chunk-5RBEEAKW.js +58 -0
  10. package/dist/{chunk-OXWUEU5V.js → chunk-5U5GLZSD.js} +2 -2
  11. package/dist/{chunk-G7A5LEVI.js → chunk-7XKPG66D.js} +5 -5
  12. package/dist/{chunk-R772ET2M.js → chunk-AAPNU7GA.js} +4 -0
  13. package/dist/{chunk-XRWUQPCA.js → chunk-ATZF2RNJ.js} +25 -3
  14. package/dist/{chunk-6NLW72HP.js → chunk-CKPAVQNK.js} +1 -1
  15. package/dist/{chunk-JHKBALS2.js → chunk-CLCHOXLR.js} +1 -1
  16. package/dist/{chunk-DMK5ASKV.js → chunk-F6D766VQ.js} +6 -2
  17. package/dist/{chunk-AXFLY5GU.js → chunk-HKGYZVWN.js} +16 -1
  18. package/dist/{chunk-ZEZQNP2G.js → chunk-LGXVGIWN.js} +103 -25
  19. package/dist/{chunk-CW7BQKF6.js → chunk-LKUGUQUW.js} +3 -3
  20. package/dist/{chunk-FXWBFDQR.js → chunk-OAYY3WF3.js} +59 -12
  21. package/dist/{chunk-ITK6HV47.js → chunk-OGPVJ53N.js} +14 -6
  22. package/dist/{chunk-M77B73VV.js → chunk-OIOW3FPT.js} +7 -2
  23. package/dist/chunk-OT5SXF7L.js +904 -0
  24. package/dist/{chunk-EDR47UE2.js → chunk-OYF25B62.js} +1 -1
  25. package/dist/{chunk-RPPTGPYF.js → chunk-PPSN2MLC.js} +2 -2
  26. package/dist/{chunk-K2F237KS.js → chunk-RGHF3OK5.js} +154 -18
  27. package/dist/{chunk-RU74YG46.js → chunk-VDUCLVZI.js} +338 -30
  28. package/dist/{chunk-724DVYHU.js → chunk-WHL6PKYP.js} +10 -10
  29. package/dist/{chunk-EEOMA4DX.js → chunk-WZZCA23D.js} +2 -1
  30. package/dist/{chunk-UDZLPR34.js → chunk-YSRYCS3N.js} +13 -8
  31. package/dist/{chunk-5N32EN4O.js → chunk-Z4Q6TQWL.js} +1 -1
  32. package/dist/{chunk-VPOMJMV6.js → chunk-ZNHL5KQJ.js} +1 -1
  33. package/dist/cli.js +17 -17
  34. package/dist/{clock-ZT5YT6HA.js → clock-X7V3C7NL.js} +2 -2
  35. package/dist/{cloud-sync-H7EWZASD.js → cloud-sync-52YNJRMR.js} +12 -11
  36. package/dist/{daemon-restart-4FWUA5JF.js → daemon-restart-QWHN5VMU.js} +4 -4
  37. package/dist/daemon.js +538 -782
  38. package/dist/{db-IPXRJUI7.js → db-I4EH5SLF.js} +3 -1
  39. package/dist/{delivery-manager-VYXVKUMP.js → delivery-manager-7R2YIQ2W.js} +13 -7
  40. package/dist/delivery-notices-RZQ6GONP.js +18 -0
  41. package/dist/{delivery-router-RHANNTAC.js → delivery-router-QS4NLJR6.js} +3 -1
  42. package/dist/{down-QSNEMTTX.js → down-UN2EDX2Y.js} +3 -3
  43. package/dist/{echo-text-PEEV7FQU.js → echo-text-73CYQRL7.js} +14 -13
  44. package/dist/{exec-ABD3DYA2.js → exec-Z34TTUO3.js} +1 -1
  45. package/dist/{extensions-N6L5NYTK.js → extensions-QT65JGV6.js} +22 -11
  46. package/dist/{files-BRY5RRIM.js → files-PCKBQHAY.js} +2 -1
  47. package/dist/{isolation-GVV2IBAU.js → isolation-UQPDO34R.js} +1 -1
  48. package/dist/{message-delivery-7URC52ME.js → message-delivery-TRKT4T2P.js} +16 -13
  49. package/dist/migrate-name-placeholder-C7NCNLWF.js +37 -0
  50. package/dist/{mind-NEXGT3KE.js → mind-CT6ANXK4.js} +11 -11
  51. package/dist/{mind-contacts-QYU4TPEA.js → mind-contacts-PEXCDRXA.js} +6 -6
  52. package/dist/{mind-history-OGNR4L7W.js → mind-history-RZMMCHMX.js} +83 -11
  53. package/dist/{mind-list-SYRA7CH5.js → mind-list-7KBLPLES.js} +1 -1
  54. package/dist/{mind-service-E26ZQXDS.js → mind-service-7EFIJ3ZA.js} +12 -11
  55. package/dist/{mind-sleep-LZAFBDZD.js → mind-sleep-24HV4RCA.js} +2 -2
  56. package/dist/{mind-status-Z2LQFA6F.js → mind-status-4YW4PHMO.js} +15 -0
  57. package/dist/{package-2W2XKSIP.js → package-F6S5NHY2.js} +4 -4
  58. package/dist/{prompts-NBO3HRK3.js → prompts-JECJIFPQ.js} +1 -1
  59. package/dist/{reject-TWB4QJHG.js → reject-4NFLXKRN.js} +6 -2
  60. package/dist/{scheduler-JTTWZDYU.js → scheduler-73Q4MQ7Y.js} +8 -6
  61. package/dist/{seed-64XEHRUP.js → seed-DRKAP3XV.js} +1 -1
  62. package/dist/{seed-cmd-UEL2QB3Q.js → seed-cmd-6IMTO3LG.js} +2 -2
  63. package/dist/{seed-create-4JRQDPGA.js → seed-create-VNLDYRFK.js} +1 -1
  64. package/dist/{seed-readiness-DYOVCYHN.js → seed-readiness-H5TTPAQH.js} +2 -2
  65. package/dist/{seed-sprout-NIDIOJXT.js → seed-sprout-PPGYGK4G.js} +5 -5
  66. package/dist/{send-2YKOZJJ5.js → send-UXUQKO4N.js} +37 -22
  67. package/dist/{service-XQPMZAGI.js → service-T4P5W6RS.js} +2 -2
  68. package/dist/{service-install-2EWD7KXD.js → service-install-R4SNYUVO.js} +3 -3
  69. package/dist/{setup-4IX6PCD3.js → setup-YUAQMDVZ.js} +4 -4
  70. package/dist/skills/dreaming/SKILL.md +2 -2
  71. package/dist/skills/dreaming/references/INSTALL.md +1 -1
  72. package/dist/skills/memory/SKILL.md +47 -43
  73. package/dist/skills/tending/SKILL.md +2 -0
  74. package/dist/skills/volute-mind/references/integrations.md +3 -2
  75. package/dist/skills/volute-mind/references/routing.md +18 -5
  76. package/dist/{skills-MPUALV3S.js → skills-GMQ5UIIW.js} +2 -2
  77. package/dist/{sleep-manager-OFP2GFXF.js → sleep-manager-KHI65FEX.js} +12 -11
  78. package/dist/{spirit-4ECYIVGE.js → spirit-Q5VGFWUI.js} +19 -3
  79. package/dist/{spirit-availability-Y54Y3675.js → spirit-availability-G5FMWKRH.js} +13 -12
  80. package/dist/{sprout-GJMZFRGZ.js → sprout-PMTRL7QN.js} +1 -1
  81. package/dist/{src-LCSOQY6Z.js → src-3FIW7DLH.js} +241 -87
  82. package/dist/src-H3UCOP6B.js +516 -0
  83. package/dist/{src-HGNE2XG4.js → src-LTTQVN2Z.js} +1 -1
  84. package/dist/{status-MWLE3RVC.js → status-LCKTTIWS.js} +2 -2
  85. package/dist/{system-events-V43HDA3T.js → system-events-Q7IVZ5T4.js} +5 -1
  86. package/dist/{systems-VDRUZXNF.js → systems-HL7SGSW7.js} +2 -2
  87. package/dist/{turn-tracker-C5PVIC3B.js → turn-tracker-HZDPMSCC.js} +4 -2
  88. package/dist/{up-H6LJZDRK.js → up-XZDSJVMU.js} +3 -3
  89. package/dist/{update-CIWI2JYA.js → update-ILCPVNDD.js} +2 -2
  90. package/dist/{upgrade-LIE46UBB.js → upgrade-XY2J7NWZ.js} +9 -1
  91. package/dist/{variant-cleanup-STC6B5HR.js → variant-cleanup-QHGGY7ND.js} +8 -6
  92. package/dist/{version-notify-LBVHH5YV.js → version-notify-VNVFX6UP.js} +22 -7
  93. package/dist/web-assets/assets/{index-B_eYTa3R.js → index-CwtxGwe_.js} +16 -16
  94. package/dist/web-assets/assets/index-DhM1yYRN.css +1 -0
  95. package/dist/web-assets/index.html +2 -2
  96. package/package.json +4 -4
  97. package/packages/extensions/intentions/dist/ui/assets/index-3FneLCKR.css +1 -0
  98. package/packages/extensions/intentions/dist/ui/assets/index-CQz4aceM.js +67 -0
  99. package/packages/extensions/{plan → intentions}/dist/ui/index.html +3 -3
  100. package/packages/extensions/intentions/skills/intention-review/SKILL.md +38 -0
  101. package/packages/extensions/intentions/skills/intentions/SKILL.md +49 -0
  102. package/packages/extensions/intentions/skills/intentions/scripts/intentions-hook.sh +45 -0
  103. package/packages/extensions/pages/skills/commons-gardening/SKILL.md +50 -0
  104. package/packages/extensions/pages/skills/pages/SKILL.md +18 -13
  105. package/templates/_base/.init/.config/prompts.json +3 -3
  106. package/templates/{pi → _base}/.init/.config/routes.json +1 -0
  107. package/templates/_base/.init/memory/dreams/.gitkeep +0 -0
  108. package/templates/_base/home/.config/routes.json +1 -1
  109. package/templates/_base/home/VOLUTE.md +1 -1
  110. package/templates/_base/src/lib/codex-session-seed.ts +463 -0
  111. package/templates/_base/src/lib/context-breakdown.ts +11 -3
  112. package/templates/_base/src/lib/daemon-client.ts +35 -0
  113. package/templates/_base/src/lib/pi-session-seed.ts +434 -0
  114. package/templates/_base/src/lib/routing.ts +2 -2
  115. package/templates/_base/src/lib/seed-note.ts +78 -0
  116. package/templates/_base/src/lib/session-seed.ts +375 -0
  117. package/templates/_base/src/lib/startup.ts +112 -6
  118. package/templates/claude/.init/CLAUDE.md +7 -3
  119. package/templates/claude/src/agent.ts +295 -101
  120. package/templates/claude/src/lib/hooks/pre-compact.ts +30 -5
  121. package/templates/claude/src/lib/message-channel.ts +43 -15
  122. package/templates/claude/src/lib/recover.ts +69 -0
  123. package/templates/claude/src/lib/stream-consumer.ts +33 -2
  124. package/templates/claude/src/server.ts +2 -1
  125. package/templates/claude/volute-template.json +1 -1
  126. package/templates/codex/.init/AGENTS.md +7 -3
  127. package/templates/codex/src/agent.ts +256 -39
  128. package/templates/codex/src/server.ts +2 -1
  129. package/templates/codex/volute-template.json +1 -1
  130. package/templates/pi/.init/MINDS.md +7 -3
  131. package/templates/pi/src/agent.ts +269 -43
  132. package/templates/pi/src/lib/event-handler.ts +11 -0
  133. package/templates/pi/src/lib/mechanics-doc.ts +30 -0
  134. package/templates/pi/src/server.ts +5 -1
  135. package/templates/pi/volute-template.json +1 -1
  136. package/dist/channels-FSAHOQWI.js +0 -90
  137. package/dist/skills/plan-coordinator/SKILL.md +0 -60
  138. package/dist/src-UDMJZ6MH.js +0 -426
  139. package/dist/web-assets/assets/index-DhZrzddW.css +0 -1
  140. package/packages/extensions/plan/dist/ui/assets/index-B8s8wxyt.js +0 -67
  141. package/packages/extensions/plan/dist/ui/assets/index-jIkrt-vI.css +0 -1
  142. package/packages/extensions/plan/skills/plan/SKILL.md +0 -43
  143. package/packages/extensions/plan/skills/plan/scripts/plan-hook.sh +0 -37
  144. package/templates/claude/.init/.config/routes.json +0 -11
  145. package/templates/codex/.init/.config/routes.json +0 -11
  146. package/dist/{env-IBW2QI6K.js → env-7AZFBPWX.js} +5 -5
  147. package/dist/{login-RJJTCVR3.js → login-4NUS72Z5.js} +3 -3
  148. package/dist/{login-SB5OENMV.js → login-57MMYILL.js} +4 -4
  149. package/dist/{register-OX4HRSGC.js → register-WNCSOTAU.js} +3 -3
  150. package/dist/{restart-AGEGNSYQ.js → restart-B4GNMVF2.js} +4 -4
  151. package/dist/{skill-B5OJEQUA.js → skill-QYTBUDNQ.js} +4 -4
  152. package/dist/{start-MUMNFR52.js → start-BDHJVOVX.js} +3 -3
  153. package/dist/{stop-EKERNYTX.js → stop-45QYSGYS.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,15 +23,24 @@ 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 { relockstepMessageIds } from "./lib/recover.js";
27
+ import { buildSeededNote, type SeedCause } from "./lib/seed-note.js";
25
28
  import {
26
29
  isSessionReapable,
27
30
  reapSessionQuery,
28
31
  reapSessionsForShutdown,
29
32
  } from "./lib/session-reaper.js";
33
+ import {
34
+ computeSeedCut,
35
+ DEFAULT_SEED_TOKENS,
36
+ rotateSession,
37
+ type SeedCut,
38
+ seedSession,
39
+ } from "./lib/session-seed.js";
30
40
  import { createSessionStore } from "./lib/session-store.js";
31
41
  import type { EffortLevel, ThinkingConfig } from "./lib/startup.js";
32
- import { loadPrompts, type SubagentConfig } from "./lib/startup.js";
33
- import { consumeStream } from "./lib/stream-consumer.js";
42
+ import { loadPrompts, renderCompactionWarning, type SubagentConfig } from "./lib/startup.js";
43
+ import { consumeStream, type MessageIdEntry } from "./lib/stream-consumer.js";
34
44
  import type {
35
45
  HandlerMeta,
36
46
  HandlerResolver,
@@ -45,8 +55,9 @@ type Session = {
45
55
  name: string;
46
56
  channel: ReturnType<typeof createMessageChannel>;
47
57
  listeners: Set<Listener>;
48
- messageIds: (string | undefined)[];
58
+ messageIds: MessageIdEntry[];
49
59
  currentMessageId?: string;
60
+ currentSeq?: number;
50
61
  currentQuery?: ReturnType<typeof query>;
51
62
  messageChannels: Map<string, { channel: string; sender?: string }>;
52
63
  replyInstructionsFired: boolean;
@@ -56,8 +67,47 @@ type Session = {
56
67
  contextTokens: number;
57
68
  /** Last inbound message or completed turn — drives idle reaping. */
58
69
  lastActivityAt: number;
70
+ /**
71
+ * True when this session was seeded from the previous session's transcript.
72
+ * Injects the honest-boundary note on each prompt until a turn resolves (see
73
+ * the pre-prompt hook and onTurnEnd), so an interrupted first turn re-offers it.
74
+ */
75
+ seeded: boolean;
76
+ /** When the seeded-from session was archived (epoch ms), for the gap note; null if unknown. */
77
+ seededArchivedAt: number | null;
78
+ /** Why the tail is seeded — picks the boundary note's wording. Last cause wins. */
79
+ seededCause: SeedCause;
80
+ /**
81
+ * The cut point pinned at warn time (the first-kept turn the mind was told would
82
+ * survive verbatim). Honored at rotation so the promise in the warning holds even
83
+ * though the transcript grows after the warning. Null until a warning fires.
84
+ */
85
+ rotationCut: SeedCut | null;
86
+ /**
87
+ * Back-to-back rotations that did NOT bring context under the threshold (reset by
88
+ * any healthy turn). Guards against a runaway loop when the tail alone can't fit —
89
+ * e.g. a system prompt (large MEMORY.md) that already fills most of the window, which
90
+ * rotation can't trim. Past the cap we stop rotating and defer to native compaction.
91
+ */
92
+ consecutiveRotations: number;
93
+ /**
94
+ * Rotation state machine (undefined = idle). The warning is pushed and the mind
95
+ * takes a wrap-up turn — with FULL context of the turns it's summarizing — before
96
+ * the session rotates:
97
+ * - "warned": a turn was mid-flight when we warned; it must finish before the
98
+ * wrap-up turn (which consumes the warning) runs.
99
+ * - "rotateAfterTurn": the wrap-up turn is the current/next turn; rotate when it ends.
100
+ * Lives on the session (not a map) so it can't leak into a resumed session if the
101
+ * stream dies mid-flight — a fresh session starts idle.
102
+ */
103
+ rotationPhase?: RotationPhase;
59
104
  };
60
105
 
106
+ type RotationPhase = "warned" | "rotateAfterTurn";
107
+
108
+ /** Stop self-rotating after this many back-to-back rotations that didn't reduce context. */
109
+ const MAX_CONSECUTIVE_ROTATIONS = 3;
110
+
61
111
  export function createMind(options: {
62
112
  systemPrompt: string;
63
113
  cwd: string;
@@ -72,6 +122,8 @@ export function createMind(options: {
72
122
  onIdentityReload?: () => Promise<void>;
73
123
  /** Idle minutes before a session's SDK subprocess is reaped. 0 disables. Default 30. */
74
124
  sessionIdleMinutes?: number;
125
+ /** Estimated-token budget for seeding a fresh persistent session. 0 disables. Default 30000. */
126
+ seedTokens?: number;
75
127
  }): {
76
128
  resolve: HandlerResolver;
77
129
  waitForCommits: () => Promise<void>;
@@ -88,20 +140,15 @@ export function createMind(options: {
88
140
 
89
141
  const sessions = new Map<string, Session>();
90
142
  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;
143
+ const compactionMessage = () =>
144
+ options.compactionMessage ?? renderCompactionWarning(prompts.compaction_warning);
96
145
  const maxContextTokens = options.maxContextTokens;
146
+ const seedTokens = options.seedTokens ?? DEFAULT_SEED_TOKENS;
97
147
 
98
148
  if (maxContextTokens) {
99
149
  log("mind", `compaction threshold: ${maxContextTokens} tokens`);
100
150
  }
101
151
 
102
- // Per-session compaction state
103
- const compactionTriggered = new Map<string, boolean>();
104
-
105
152
  // --- Subagents (config-driven) ---
106
153
 
107
154
  type SDKAgent = {
@@ -242,11 +289,43 @@ export function createMind(options: {
242
289
  }
243
290
  }
244
291
  // Only UserPromptSubmit hooks can inject additionalContext into the conversation
245
- if (event !== "pre-prompt" || !result.additionalContext) return {};
292
+ if (event !== "pre-prompt") return {};
293
+ let additionalContext = result.additionalContext;
294
+ // On a seeded session, prepend the honest-boundary note. Deliberately NOT
295
+ // cleared here: this hook only hands the note to the SDK at *dispatch*, and
296
+ // the SDK cancels UserPromptSubmit hooks when an interrupt arrives (a
297
+ // `hook_cancelled` transcript entry). That's most likely on the seeded first
298
+ // turn post-wake, when queued inbound messages flood in — exactly when we'd
299
+ // lose the note. Clearing on a mere injection attempt could drop it forever
300
+ // and silently. So the flag is cleared only when a turn actually resolves
301
+ // (onTurnEnd); an interrupted turn re-offers the note next prompt. Worst case
302
+ // it fires twice (cosmetic) instead of zero times (unrecoverable, invisible).
303
+ if (session.seeded) {
304
+ const note = buildSeededNote({
305
+ cause: session.seededCause,
306
+ archivedAtMs: session.seededArchivedAt,
307
+ });
308
+ additionalContext = additionalContext ? `${note}\n\n${additionalContext}` : note;
309
+ // Also surface the note as its own context event (matching codex/pi) so it
310
+ // isn't delivered only in the same low-salience container as routine
311
+ // per-prompt hooks. Once per injection attempt; a duplicate on re-offer is fine.
312
+ const channel = session.currentMessageId
313
+ ? session.messageChannels.get(session.currentMessageId)?.channel
314
+ : undefined;
315
+ daemonEmit({
316
+ type: "context",
317
+ content: note,
318
+ metadata: { source: "seeded-session" },
319
+ session: session.name,
320
+ channel,
321
+ messageId: session.currentMessageId,
322
+ }).catch((err) => log("mind", "seeded-session context emit failed:", err));
323
+ }
324
+ if (!additionalContext) return {};
246
325
  return {
247
326
  hookSpecificOutput: {
248
327
  hookEventName: "UserPromptSubmit" as const,
249
- additionalContext: result.additionalContext,
328
+ additionalContext,
250
329
  },
251
330
  };
252
331
  } catch (err) {
@@ -317,17 +396,61 @@ export function createMind(options: {
317
396
  let currentSessionId = savedSessionId;
318
397
  let streamAbort = new AbortController();
319
398
 
320
- const preCompact = createPreCompactHook(() => {
321
- session.messageIds.push(undefined);
322
- session.channel.push({
399
+ /**
400
+ * Warn the mind that the session will rotate at the context limit and pin the
401
+ * cut point it's told about. Computes which whole turns fit in `seedTokens`
402
+ * from the live transcript, pins the first-kept turn (so rotation honors the
403
+ * same boundary even as the transcript grows), interpolates its time into the
404
+ * `${cutoff}` placeholder, enters the rotation state machine, and pushes the
405
+ * warning as the wrap-up prompt. Shared by the threshold and PreCompact paths.
406
+ *
407
+ * Crucially it does NOT abort here: the mind takes a wrap-up turn (reading the
408
+ * pinned cutoff and authoring `volute mind history` summaries with full context
409
+ * of the turns being collapsed) before rotation. The entry phase depends on
410
+ * whether a turn is mid-flight: if one is (threshold path fires on an assistant
411
+ * message), it must finish before the queued warning runs, so we start "warned";
412
+ * if we're between turns (PreCompact fired at a boundary), the warning IS the
413
+ * next turn, so we start "rotateAfterTurn".
414
+ */
415
+ function warnAndPinRotation() {
416
+ let cutoffLabel = "the cutoff";
417
+ session.rotationCut = null;
418
+ if (currentSessionId) {
419
+ try {
420
+ const path = findClaudeSessionFile(options.cwd, currentSessionId);
421
+ const cut = path ? computeSeedCut(readFileSync(path, "utf-8"), seedTokens) : null;
422
+ if (cut) {
423
+ session.rotationCut = cut;
424
+ if (cut.boundaryTimestamp) {
425
+ const d = new Date(cut.boundaryTimestamp);
426
+ if (!Number.isNaN(d.getTime())) cutoffLabel = compactTimestamp(d);
427
+ }
428
+ }
429
+ } catch (err) {
430
+ log("mind", `session "${session.name}": failed to pin rotation cut:`, err);
431
+ }
432
+ }
433
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: literal ${cutoff} prompt placeholder
434
+ const warning = compactionMessage().replaceAll("${cutoff}", cutoffLabel);
435
+ session.rotationPhase =
436
+ session.currentMessageId !== undefined ? "warned" : "rotateAfterTurn";
437
+ const seq = session.channel.push({
323
438
  type: "user",
324
439
  session_id: "",
325
- message: {
326
- role: "user",
327
- content: [{ type: "text", text: compactionMessage }],
328
- },
440
+ message: { role: "user", content: [{ type: "text", text: warning }] },
329
441
  parent_tool_use_id: null,
330
442
  });
443
+ session.messageIds.push({ id: undefined, seq });
444
+ }
445
+
446
+ // PreCompact backstop: first fire warns + pins + enters the rotation machine
447
+ // (unless already warned, or the rotation cap is hit) and blocks; second fire
448
+ // allows the SDK's native compaction — the emergency backstop when rotation never
449
+ // intervened (a hung turn, or a system prompt too large for rotation to relieve).
450
+ const preCompact = createPreCompactHook(() => {
451
+ if (!session.rotationPhase && session.consecutiveRotations < MAX_CONSECUTIVE_ROTATIONS) {
452
+ warnAndPinRotation();
453
+ }
331
454
  });
332
455
 
333
456
  const callbacks = {
@@ -336,20 +459,31 @@ export function createMind(options: {
336
459
  if (!session.name.startsWith("new-")) sessionStore.save(session.name, id);
337
460
  },
338
461
  broadcast: (event: VoluteEvent) => broadcastToSession(session, event),
462
+ // Identity-based ack — stream-consumer.ts calls this once per message the
463
+ // just-finished turn covers (its own driving message, plus any folded in
464
+ // mid-run) so none of them strand in the channel's in-flight set (#764).
465
+ ack: (seq: number) => session.channel.ack(seq),
339
466
  onTurnEnd: async () => {
340
- // This turn's message is fully processed — drop it from the channel's
341
- // in-flight set so a later compaction abort won't re-feed it.
342
- session.channel.ack();
343
467
  session.lastActivityAt = Date.now();
468
+ // A turn resolved — the seeded note's injection had its chance to land
469
+ // (the pre-prompt hook ran and wasn't cancelled by an interrupt), so stop
470
+ // re-offering it. This is the honest place to clear: a completed turn is
471
+ // the closest-to-arrival signal available without reading the transcript back.
472
+ session.seeded = false;
344
473
  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`);
474
+ if (session.rotationPhase === "warned") {
475
+ // The mid-flight turn finished; the queued warning runs next as the wrap-up
476
+ // turn. Advance — do NOT abort yet, so the mind summarizes with full context.
477
+ session.rotationPhase = "rotateAfterTurn";
478
+ } else if (session.rotationPhase === "rotateAfterTurn") {
479
+ // The wrap-up turn is done (summaries authored) — abort to rotate in place.
480
+ log("mind", `session "${session.name}": wrap-up turn done — aborting to rotate`);
350
481
  streamAbort.abort(new CompactionAbort());
351
- } else if (identityReload.shouldRequestReload()) {
352
- options.onIdentityReload?.();
482
+ } else {
483
+ // A healthy turn (context under the threshold) — the rotation streak, if
484
+ // any, is over, so re-arm the self-rotation cap.
485
+ session.consecutiveRotations = 0;
486
+ if (identityReload.shouldRequestReload()) options.onIdentityReload?.();
353
487
  }
354
488
  },
355
489
  onContextTokens: (tokens: number) => {
@@ -357,64 +491,18 @@ export function createMind(options: {
357
491
  if (
358
492
  maxContextTokens &&
359
493
  tokens >= maxContextTokens &&
360
- !compactionTriggered.get(session.name)
494
+ !session.rotationPhase &&
495
+ session.consecutiveRotations < MAX_CONSECUTIVE_ROTATIONS
361
496
  ) {
362
- compactionTriggered.set(session.name, true);
363
497
  log(
364
498
  "mind",
365
- `session "${session.name}": ${tokens} tokens >= ${maxContextTokens} — triggering compaction`,
499
+ `session "${session.name}": ${tokens} tokens >= ${maxContextTokens} — warning + pinning rotation`,
366
500
  );
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
- });
501
+ warnAndPinRotation();
377
502
  }
378
503
  },
379
504
  };
380
505
 
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
506
  /** Emit done to both local listeners and the daemon (best-effort with retries). */
419
507
  function emitDone() {
420
508
  broadcastToSession(session, { type: "done" });
@@ -457,30 +545,77 @@ export function createMind(options: {
457
545
  streamAbort.signal.reason instanceof CompactionAbort &&
458
546
  currentSessionId
459
547
  ) {
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,
548
+ // Stream was aborted to rotate: replace the session with a synthetic one
549
+ // holding the verbatim recent tail (from the pinned cut), then resume it.
550
+ const rotatedId = rotateSession({
551
+ cwd: options.cwd,
552
+ sessionsDir: options.sessionsDir,
553
+ name: session.name,
554
+ oldSessionId: currentSessionId,
555
+ cut: session.rotationCut,
556
+ seedTokens,
557
+ });
558
+ session.rotationCut = null;
559
+ session.rotationPhase = undefined; // state machine consumed
560
+ if (!rotatedId) {
561
+ // Rotation couldn't proceed — fall back to a fresh session (no seed).
562
+ // Unlike a successful rotation (boundary note, verbatim tail, archived
563
+ // transcript), this genuinely loses the live context — say so (#367).
564
+ log("mind", `session "${session.name}": rotation failed, starting fresh`);
565
+ daemonNotice({
566
+ kind: "context_lost",
567
+ thread: session.name,
568
+ message:
569
+ "Session rotation failed at the context limit and this thread was " +
570
+ "reset — conversation context before this point was lost. Your turn " +
571
+ "summaries survive in `volute mind history`; check your journal for " +
572
+ "where you left off.",
573
+ }).catch((err) =>
574
+ log("mind", `session "${session.name}": failed to record notice:`, err),
468
575
  );
469
576
  sessionStore.delete(session.name);
470
577
  currentSessionId = undefined;
578
+ session.seeded = false;
471
579
  streamAbort = new AbortController();
472
580
  session.channel = createMessageChannel();
473
581
  break;
474
582
  }
583
+ // Point the live pointer at the rotated session and arm the boundary
584
+ // note (rotation cause — no gap). Ephemeral `new-*` keep no pointer.
585
+ if (!session.name.startsWith("new-")) sessionStore.save(session.name, rotatedId);
586
+ currentSessionId = rotatedId;
587
+ session.seeded = true;
588
+ session.seededCause = "rotation";
589
+ session.seededArchivedAt = null;
590
+ // Count this rotation; a healthy turn resets it. If back-to-back rotations
591
+ // don't reduce context (system prompt too large to fit the tail under the
592
+ // threshold), the cap stops the loop and defers to native compaction.
593
+ session.consecutiveRotations++;
594
+ if (session.consecutiveRotations >= MAX_CONSECUTIVE_ROTATIONS) {
595
+ log(
596
+ "mind",
597
+ `session "${session.name}": ${session.consecutiveRotations} rotations without relief — deferring further compaction to the SDK (system prompt likely too large)`,
598
+ );
599
+ }
600
+ // Re-arm the PreCompact backstop so a future auto-compact blocks + rotates
601
+ // again rather than falling straight through to native compaction.
602
+ preCompact.reset();
475
603
  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.
604
+ // Recover input the aborted stream had already pulled but not finished
605
+ // (the wrap-up warning plus anything that arrived mid-turn) so nothing is
606
+ // dropped when the killed subprocess takes its buffer with it.
480
607
  const pending = session.channel.recover();
608
+ const oldMessageIds = session.messageIds;
481
609
  session.channel = createMessageChannel();
482
- for (const msg of pending) session.channel.push(msg);
483
- continue; // restart the stream loop
610
+ // Starts from [] nothing can race into this fresh channel/messageIds
611
+ // before the next line runs (single-threaded, no await in between).
612
+ session.messageIds = relockstepMessageIds(
613
+ pending,
614
+ oldMessageIds,
615
+ session.channel.push,
616
+ [],
617
+ );
618
+ continue; // restart the stream loop on the rotated session
484
619
  }
485
620
  throw err; // rethrow non-compaction errors
486
621
  }
@@ -489,8 +624,21 @@ export function createMind(options: {
489
624
  session.messageChannels.clear();
490
625
  if (currentSessionId) {
491
626
  log("mind", `session "${session.name}": resume failed, starting fresh:`, err);
627
+ daemonNotice({
628
+ kind: "context_lost",
629
+ thread: session.name,
630
+ message:
631
+ "Your session couldn't be resumed after an error, so you're starting fresh. " +
632
+ "Recent context may be in memory/journal/.",
633
+ }).catch((e) => log("mind", `session "${session.name}": failed to record notice:`, e));
492
634
  sessionStore.delete(session.name);
493
635
  currentSessionId = undefined;
636
+ // We fell back to a truly empty session — don't tell the mind its
637
+ // conversation continued when the seeded transcript failed to resume,
638
+ // and don't let a half-entered rotation leak into the fresh session.
639
+ session.seeded = false;
640
+ session.rotationPhase = undefined;
641
+ session.rotationCut = null;
494
642
  streamAbort = new AbortController();
495
643
  session.channel = createMessageChannel();
496
644
  try {
@@ -531,6 +679,11 @@ export function createMind(options: {
531
679
  eventNoteFired: false,
532
680
  contextTokens: 0,
533
681
  lastActivityAt: Date.now(),
682
+ seeded: false,
683
+ seededArchivedAt: null,
684
+ seededCause: "restored",
685
+ rotationCut: null,
686
+ consecutiveRotations: 0,
534
687
  };
535
688
  sessions.set(name, session);
536
689
 
@@ -542,9 +695,41 @@ export function createMind(options: {
542
695
  log("mind", `session "${name}": stored session ${savedSessionId} not found, starting fresh`);
543
696
  sessionStore.delete(name);
544
697
  savedSessionId = undefined;
698
+ // Worded so it stays true whether or not transcript seeding (below) partially
699
+ // restores context: the live session is gone either way.
700
+ daemonNotice({
701
+ kind: "context_lost",
702
+ thread: name,
703
+ message:
704
+ "Your previous session couldn't be restored (session file missing), so this " +
705
+ "thread was reset. Recent context may be in memory/journal/.",
706
+ }).catch((err) => log("mind", `session "${name}": failed to record notice:`, err));
545
707
  }
546
708
  if (savedSessionId) {
547
709
  log("mind", `session "${name}": resuming ${savedSessionId}`);
710
+ } else if (!isEphemeral) {
711
+ // Fresh persistent session — seed it from the previous session's archived
712
+ // transcript so the mind experiences the conversation continuing rather
713
+ // than waking into an empty context. Ephemeral `new-*` sessions never seed.
714
+ const seeded = seedSession({
715
+ cwd: options.cwd,
716
+ sessionsDir: options.sessionsDir,
717
+ name,
718
+ seedTokens: options.seedTokens ?? DEFAULT_SEED_TOKENS,
719
+ });
720
+ if (seeded) {
721
+ sessionStore.save(name, seeded.sessionId);
722
+ savedSessionId = seeded.sessionId;
723
+ session.seeded = true;
724
+ session.seededArchivedAt = seeded.archivedAt;
725
+ session.seededCause = "restored";
726
+ log(
727
+ "mind",
728
+ `session "${name}": seeded from previous transcript, resuming ${seeded.sessionId}`,
729
+ );
730
+ } else {
731
+ log("mind", `session "${name}": starting fresh`);
732
+ }
548
733
  } else {
549
734
  log("mind", `session "${name}": starting fresh`);
550
735
  }
@@ -565,7 +750,6 @@ export function createMind(options: {
565
750
  // Delete first so a racing inbound message spins up a fresh resumed session
566
751
  // instead of reusing the one we're tearing down.
567
752
  sessions.delete(session.name);
568
- compactionTriggered.delete(session.name);
569
753
  // End the input iterable so the stream consumer unwinds (its finally block
570
754
  // also deletes from the map, now a no-op), then await the SDK's graceful
571
755
  // shutdown via query.return() — unlike the fire-and-forget close(), this
@@ -576,11 +760,16 @@ export function createMind(options: {
576
760
  log("mind", `session "${session.name}": error reaping SDK subprocess:`, err),
577
761
  );
578
762
  // Nothing should have raced in (isSessionReapable checked isEmpty), but if it
579
- // did, re-dispatch into a fresh session so no input is dropped.
763
+ // did, re-dispatch into a fresh session so no input is dropped. Same marker
764
+ // treatment as the rotation path (#764) — see relockstepMessageIds above.
765
+ // getOrCreateSession() can return a session an inbound message already raced
766
+ // into during the reapSessionQuery() await above (it pushed its own entry into
767
+ // fresh.messageIds via the normal handler path) — relockstepMessageIds appends
768
+ // onto fresh.messageIds rather than replacing it, so that entry survives.
580
769
  const pending = session.channel.recover();
581
770
  if (pending.length > 0) {
582
771
  const fresh = getOrCreateSession(session.name);
583
- for (const msg of pending) fresh.channel.push(msg);
772
+ relockstepMessageIds(pending, session.messageIds, fresh.channel.push, fresh.messageIds);
584
773
  }
585
774
  }
586
775
 
@@ -605,7 +794,12 @@ export function createMind(options: {
605
794
  const reaper = setInterval(() => {
606
795
  const now = Date.now();
607
796
  const stale = [...sessions.values()].filter((s) =>
608
- isSessionReapable(s, now, idleTimeoutMs, (name) => !!compactionTriggered.get(name)),
797
+ isSessionReapable(
798
+ s,
799
+ now,
800
+ idleTimeoutMs,
801
+ (name) => sessions.get(name)?.rotationPhase !== undefined,
802
+ ),
609
803
  );
610
804
  // Reaps run independently; each awaits its own subprocess exit internally.
611
805
  for (const session of stale) {
@@ -663,13 +857,13 @@ export function createMind(options: {
663
857
 
664
858
  // Push message into SDK
665
859
  session.lastActivityAt = Date.now();
666
- session.messageIds.push(meta.messageId);
667
- session.channel.push({
860
+ const seq = session.channel.push({
668
861
  type: "user",
669
862
  session_id: "",
670
863
  message: { role: "user", content: toSDKContent(content) },
671
864
  parent_tool_use_id: null,
672
865
  });
866
+ session.messageIds.push({ id: meta.messageId, seq });
673
867
 
674
868
  return () => {
675
869
  if (filteredListener) session.listeners.delete(filteredListener);
@@ -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
  }