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
@@ -2,6 +2,13 @@ import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { resolve as resolvePath } from "node:path";
3
3
  import { Codex } from "@openai/codex-sdk";
4
4
  import { flushFileChanges, trackFileChange } from "./lib/auto-commit.js";
5
+ import {
6
+ type CodexSeedCut,
7
+ computeCodexSeedCut,
8
+ DEFAULT_SEED_TOKENS,
9
+ rotateCodexSession,
10
+ seedCodexSession,
11
+ } from "./lib/codex-session-seed.js";
5
12
  import { extractText } from "./lib/content.js";
6
13
  import {
7
14
  countSdkInstructionTokens,
@@ -14,10 +21,17 @@ import {
14
21
  readSkillDescriptions,
15
22
  } from "./lib/context-breakdown.js";
16
23
  import { daemonEmit, daemonRestart, type EventType } from "./lib/daemon-client.js";
24
+ import { compactTimestamp } from "./lib/format-prefix.js";
17
25
  import { runHooks } from "./lib/hook-loader.js";
18
26
  import { log, warn } from "./lib/logger.js";
27
+ import { buildSeededNote, type SeedCause } from "./lib/seed-note.js";
19
28
  import { createSessionStore } from "./lib/session-store.js";
20
- import { getStartupContext, loadPrompts, loadSystemPrompt } from "./lib/startup.js";
29
+ import {
30
+ getStartupContext,
31
+ loadPrompts,
32
+ loadSystemPrompt,
33
+ renderCompactionWarning,
34
+ } from "./lib/startup.js";
21
35
  import { filterEvent, loadTransparencyPreset } from "./lib/transparency.js";
22
36
  import { turnContextFor } from "./lib/turn-context.js";
23
37
  import type {
@@ -38,12 +52,14 @@ type CodexThread = {
38
52
  ): Promise<{ events: AsyncIterable<Record<string, any>> }>;
39
53
  };
40
54
 
55
+ type QueuedMessage = { text: string; meta: HandlerMeta; isRotationWarning?: boolean };
56
+
41
57
  type CodexSession = {
42
58
  name: string;
43
59
  thread: CodexThread | null;
44
60
  listeners: Set<Listener>;
45
61
  currentMessageId?: string;
46
- messageQueue: Array<{ text: string; meta: HandlerMeta }>;
62
+ messageQueue: QueuedMessage[];
47
63
  processing: boolean;
48
64
  abortController?: AbortController;
49
65
  messageChannels: Map<string, string>;
@@ -51,8 +67,40 @@ type CodexSession = {
51
67
  /** The event note is a standing fact about events, so it fires once per session. */
52
68
  eventNoteFired: boolean;
53
69
  cumulativeInputTokens: number;
70
+ /**
71
+ * True when this session was seeded from a previous session's rollout (a restart
72
+ * restore) or a rotation. Consumed once, on the first turn, to inject the note.
73
+ */
74
+ seeded: boolean;
75
+ /** When the seeded-from session was archived (epoch ms), for the gap note; null if unknown. */
76
+ seededArchivedAt: number | null;
77
+ /**
78
+ * The live Codex thread id, tracked in-memory (from thread.started) so rotation can
79
+ * locate the rollout even for ephemeral `new-*` sessions, which persist no pointer.
80
+ */
81
+ currentThreadId: string | null;
82
+ /** Why the tail is seeded — picks the boundary note's wording. Last cause wins. */
83
+ seededCause: SeedCause;
84
+ /**
85
+ * The cut point pinned at warn time (the first-kept turn the mind was told would
86
+ * survive verbatim). Honored at rotation so the promise in the warning holds even
87
+ * though the rollout grows after the warning. Null until a warning fires.
88
+ */
89
+ rotationCut: CodexSeedCut | null;
90
+ /** A rotation warning turn is queued/in-flight — suppress re-triggering until it rotates. */
91
+ rotatePending: boolean;
92
+ /**
93
+ * Back-to-back rotations that did NOT bring context under the threshold (reset by any
94
+ * healthy turn). Guards against a runaway loop when the tail alone can't fit — e.g. a
95
+ * system prompt (large MEMORY.md) that already fills most of the window, which rotation
96
+ * can't trim. Past the cap we stop rotating and defer to the SDK's native backstop.
97
+ */
98
+ consecutiveRotations: number;
54
99
  };
55
100
 
101
+ /** Stop self-rotating after this many back-to-back rotations that didn't reduce context. */
102
+ const MAX_CONSECUTIVE_ROTATIONS = 3;
103
+
56
104
  // Loaded once at startup
57
105
  const preset = loadTransparencyPreset();
58
106
 
@@ -79,6 +127,8 @@ export function createMind(options: {
79
127
  model?: string;
80
128
  reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh";
81
129
  maxContextTokens?: number;
130
+ /** Estimated-token budget for seeding a fresh persistent session. 0 disables. Default 30000. */
131
+ seedTokens?: number;
82
132
  }): {
83
133
  resolve: HandlerResolver;
84
134
  getContextInfo: () => Promise<ContextInfo>;
@@ -87,6 +137,13 @@ export function createMind(options: {
87
137
  const sessions = new Map<string, CodexSession>();
88
138
  const prompts = loadPrompts();
89
139
  const maxContextTokens = options.maxContextTokens;
140
+ const seedTokens = options.seedTokens ?? DEFAULT_SEED_TOKENS;
141
+
142
+ // The warning the mind gets when the context limit approaches: it authors summaries
143
+ // of the turns that will collapse, then the session rotates onto the verbatim tail.
144
+ // ${date}/${memory_size} are filled per-warning (the date rolls over and MEMORY.md
145
+ // changes as the mind edits it); ${cutoff} is filled with the pinned boundary.
146
+ const compactionMessage = () => renderCompactionWarning(prompts.compaction_warning);
90
147
 
91
148
  if (maxContextTokens) {
92
149
  log("mind", `compaction threshold: ${maxContextTokens} tokens`);
@@ -116,8 +173,14 @@ export function createMind(options: {
116
173
  ...(apiKey ? { apiKey } : {}),
117
174
  config: {
118
175
  model_instructions_file: promptPath,
119
- // Let the SDK handle compaction natively when a threshold is configured
120
- model_auto_compact_token_limit: maxContextTokens ?? 999999999,
176
+ // Rotation (mind-authored compaction) is the primary path: at maxContextTokens we
177
+ // warn the mind and rotate onto the verbatim tail. The SDK's native auto-compaction
178
+ // is only an emergency backstop — set above the volute threshold (1.5×) so it fires
179
+ // solely when rotation can't relieve context (e.g. a system prompt too large to fit
180
+ // the tail under the threshold), after the runaway guard stops self-rotating.
181
+ model_auto_compact_token_limit: maxContextTokens
182
+ ? Math.floor(maxContextTokens * 1.5)
183
+ : 999999999,
121
184
  // Enable reasoning summaries so they appear as events
122
185
  model_reasoning_summary: "auto",
123
186
  model_supports_reasoning_summaries: true,
@@ -150,6 +213,13 @@ export function createMind(options: {
150
213
  firstMessagePerChannel: new Set(),
151
214
  eventNoteFired: false,
152
215
  cumulativeInputTokens: 0,
216
+ seeded: false,
217
+ seededArchivedAt: null,
218
+ currentThreadId: null,
219
+ seededCause: "restored",
220
+ rotationCut: null,
221
+ rotatePending: false,
222
+ consecutiveRotations: 0,
153
223
  };
154
224
  sessions.set(name, session);
155
225
 
@@ -157,44 +227,62 @@ export function createMind(options: {
157
227
  return session;
158
228
  }
159
229
 
230
+ /** Thread options shared by startThread, resumeThread, and rotation resume. */
231
+ function threadOptions() {
232
+ return {
233
+ workingDirectory: options.cwd,
234
+ model: options.model,
235
+ modelReasoningEffort: options.reasoningEffort,
236
+ skipGitRepoCheck: true,
237
+ // Codex's native seatbelt sandbox panics on macOS due to a bug in the
238
+ // system-configuration Rust crate (SCDynamicStore NULL object). Use full
239
+ // access until upstream ships a fix (mullvad/system-configuration-rs#59).
240
+ sandboxMode: "danger-full-access" as const,
241
+ networkAccessEnabled: true,
242
+ };
243
+ }
244
+
160
245
  function initSession(session: CodexSession) {
161
246
  const isEphemeral = session.name.startsWith("new-");
162
247
  log("mind", `session "${session.name}": ${isEphemeral ? "ephemeral" : "persistent"}`);
163
248
  emit(session, { type: "session_start" });
164
249
 
165
250
  if (!isEphemeral) {
166
- const savedThreadId = sessionStore.load(session.name);
167
- if (savedThreadId) {
251
+ let resumeThreadId = sessionStore.load(session.name);
252
+ if (!resumeThreadId) {
253
+ // Fresh persistent session — seed it from the previous session's archived
254
+ // rollout so the mind experiences the conversation continuing rather than
255
+ // waking into an empty context.
256
+ const seeded = seedCodexSession({
257
+ mindDir: options.mindDir,
258
+ name: session.name,
259
+ seedTokens,
260
+ });
261
+ if (seeded) {
262
+ resumeThreadId = seeded.threadId;
263
+ session.seeded = true;
264
+ session.seededArchivedAt = seeded.archivedAt;
265
+ session.seededCause = "restored";
266
+ log("mind", `session "${session.name}": seeded from previous transcript`);
267
+ }
268
+ }
269
+ if (resumeThreadId) {
168
270
  try {
169
- log("mind", `session "${session.name}": resuming thread ${savedThreadId}`);
170
- session.thread = codex.resumeThread(savedThreadId, {
171
- workingDirectory: options.cwd,
172
- model: options.model,
173
- modelReasoningEffort: options.reasoningEffort,
174
- skipGitRepoCheck: true,
175
- // Codex's native seatbelt sandbox panics on macOS due to a bug in the
176
- // system-configuration Rust crate (SCDynamicStore NULL object). Use full
177
- // access until upstream ships a fix (mullvad/system-configuration-rs#59).
178
- sandboxMode: "danger-full-access",
179
- networkAccessEnabled: true,
180
- });
271
+ log("mind", `session "${session.name}": resuming thread ${resumeThreadId}`);
272
+ session.thread = codex.resumeThread(resumeThreadId, threadOptions());
273
+ session.currentThreadId = resumeThreadId;
181
274
  return;
182
275
  } catch (err) {
183
276
  warn("mind", `session "${session.name}": failed to resume thread, starting new:`, err);
277
+ // We fell back to a truly fresh thread — don't tell the mind its
278
+ // conversation continued when the seeded rollout failed to resume.
279
+ session.seeded = false;
184
280
  }
185
281
  }
186
282
  }
187
283
 
188
284
  try {
189
- session.thread = codex.startThread({
190
- workingDirectory: options.cwd,
191
- model: options.model,
192
- modelReasoningEffort: options.reasoningEffort,
193
- skipGitRepoCheck: true,
194
- // See comment in resumeThread above
195
- sandboxMode: "danger-full-access",
196
- networkAccessEnabled: true,
197
- });
285
+ session.thread = codex.startThread(threadOptions());
198
286
  log("mind", `session "${session.name}": new thread started`);
199
287
  } catch (err) {
200
288
  warn("mind", `session "${session.name}": failed to start thread:`, err);
@@ -241,6 +329,24 @@ export function createMind(options: {
241
329
  }
242
330
  }
243
331
 
332
+ // On the first turn of a seeded session, prepend the honest-boundary note
333
+ // (consumed once) so the mind knows the tail above was restored (restart) or
334
+ // rotated (context limit). The restored note carries a coarse gap-duration
335
+ // clause when the archive time is known; the rotation note has no gap clause.
336
+ if (session.seeded) {
337
+ session.seeded = false;
338
+ const note = buildSeededNote({
339
+ cause: session.seededCause,
340
+ archivedAtMs: session.seededArchivedAt,
341
+ });
342
+ emit(session, {
343
+ type: "context",
344
+ content: note,
345
+ metadata: { source: "seeded-session" },
346
+ });
347
+ text = `${note}\n\n${text}`;
348
+ }
349
+
244
350
  // Run pre-prompt hooks
245
351
  try {
246
352
  const hookResult = await runHooks(hooksDir, "pre-prompt", {
@@ -312,11 +418,15 @@ export function createMind(options: {
312
418
  try {
313
419
  switch (event.type) {
314
420
  case "thread.started": {
315
- // Save thread ID for session resume
421
+ // Track the live thread id in-memory (used by rotation) and persist it for
422
+ // resume — persistent sessions only; ephemeral `new-*` keep no pointer.
316
423
  const threadId = event.thread_id ?? event.threadId ?? event.thread?.id;
317
- if (threadId && !session.name.startsWith("new-")) {
318
- sessionStore.save(session.name, threadId);
319
- log("mind", `session "${session.name}": saved thread ${threadId}`);
424
+ if (threadId) {
425
+ session.currentThreadId = threadId;
426
+ if (!session.name.startsWith("new-")) {
427
+ sessionStore.save(session.name, threadId);
428
+ log("mind", `session "${session.name}": saved thread ${threadId}`);
429
+ }
320
430
  }
321
431
  break;
322
432
  }
@@ -514,14 +624,6 @@ export function createMind(options: {
514
624
  daemonRestart({ type: "reload" }).catch((err) => log("mind", "daemonRestart failed:", err));
515
625
  }
516
626
 
517
- // Compaction warning — actual compaction is handled by the Codex SDK via model_auto_compact_token_limit
518
- if (maxContextTokens && session.cumulativeInputTokens >= maxContextTokens) {
519
- log(
520
- "mind",
521
- `session "${session.name}": ${session.cumulativeInputTokens} tokens >= ${maxContextTokens} — SDK auto-compaction will handle`,
522
- );
523
- }
524
-
525
627
  log("mind", `session "${session.name}": turn done`);
526
628
  } catch (err: any) {
527
629
  if (err?.name === "AbortError") {
@@ -540,6 +642,115 @@ export function createMind(options: {
540
642
  session.currentMessageId = undefined;
541
643
  }
542
644
 
645
+ // --- Rotation (mind-authored compaction) ---
646
+
647
+ /**
648
+ * Warn the mind that the session will rotate at the context limit and pin the cut
649
+ * point it's told about. Computes which whole turns fit in `seedTokens` from the live
650
+ * rollout, pins the first-kept turn's timestamp (so rotation honors the same boundary
651
+ * even as the rollout grows), interpolates its local time into the `${cutoff}`
652
+ * placeholder, and queues the warning as the mind's next (wrap-up) turn.
653
+ */
654
+ function warnAndPinRotation(session: CodexSession) {
655
+ let cutoffLabel = "the cutoff";
656
+ session.rotationCut = null;
657
+ const threadId = session.currentThreadId;
658
+ if (threadId) {
659
+ try {
660
+ const path = findCodexSessionFile(threadId, options.mindDir);
661
+ const cut = path ? computeCodexSeedCut(readFileSync(path, "utf-8"), seedTokens) : null;
662
+ if (cut) {
663
+ session.rotationCut = cut;
664
+ const d = new Date(cut.boundaryTimestamp);
665
+ if (!Number.isNaN(d.getTime())) cutoffLabel = compactTimestamp(d);
666
+ }
667
+ } catch (err) {
668
+ log("mind", `session "${session.name}": failed to pin rotation cut:`, err);
669
+ }
670
+ }
671
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: literal ${cutoff} prompt placeholder
672
+ const warning = compactionMessage().replaceAll("${cutoff}", cutoffLabel);
673
+ session.rotatePending = true;
674
+ // Run the warning next, ahead of any queued messages, so the mind wraps up before
675
+ // more context piles on. Remaining queued messages then run on the rotated thread.
676
+ session.messageQueue.unshift({
677
+ text: warning,
678
+ meta: { messageId: `rotation-warning-${Date.now()}` },
679
+ isRotationWarning: true,
680
+ });
681
+ }
682
+
683
+ /**
684
+ * After the wrap-up warning turn completes, rotate the thread in place: build a seeded
685
+ * rollout from the pinned cut out of the live rollout, archive the rotated-out thread,
686
+ * and resume the new thread. On any failure we leave the old thread in place and let
687
+ * the SDK's native backstop handle it if context keeps climbing.
688
+ */
689
+ function performRotation(session: CodexSession) {
690
+ session.rotatePending = false;
691
+ const oldThreadId = session.currentThreadId;
692
+ const cut = session.rotationCut;
693
+ session.rotationCut = null;
694
+
695
+ const newThreadId = oldThreadId
696
+ ? rotateCodexSession({
697
+ mindDir: options.mindDir,
698
+ name: session.name,
699
+ oldThreadId,
700
+ cut,
701
+ seedTokens,
702
+ })
703
+ : null;
704
+ if (!newThreadId) {
705
+ log("mind", `session "${session.name}": rotation failed, deferring to SDK backstop`);
706
+ return;
707
+ }
708
+
709
+ try {
710
+ session.thread = codex.resumeThread(newThreadId, threadOptions());
711
+ } catch (err) {
712
+ warn("mind", `session "${session.name}": failed to resume rotated thread:`, err);
713
+ return; // keep the old thread; the SDK backstop covers a runaway
714
+ }
715
+ session.currentThreadId = newThreadId;
716
+ if (!session.name.startsWith("new-")) sessionStore.save(session.name, newThreadId);
717
+ // Fresh thread — reset token tracking (the next turn.completed sets the real value)
718
+ // and arm the rotation-cause boundary note for the mind's next turn.
719
+ session.cumulativeInputTokens = 0;
720
+ session.seeded = true;
721
+ session.seededCause = "rotation";
722
+ session.seededArchivedAt = null;
723
+ // Count this rotation; a healthy turn resets it. If back-to-back rotations don't
724
+ // reduce context (system prompt too large to fit the tail), the cap stops the loop.
725
+ session.consecutiveRotations++;
726
+ if (session.consecutiveRotations >= MAX_CONSECUTIVE_ROTATIONS) {
727
+ log(
728
+ "mind",
729
+ `session "${session.name}": ${session.consecutiveRotations} rotations without relief — deferring further compaction to the SDK (system prompt likely too large)`,
730
+ );
731
+ }
732
+ log("mind", `session "${session.name}": rotated to ${newThreadId}`);
733
+ }
734
+
735
+ /**
736
+ * Decide, after a normal (non-warning) turn, whether to warn + pin a rotation: only when
737
+ * a threshold is configured, context is at/over it, no rotation is already pending, and
738
+ * we're under the runaway cap. A turn that came back under the threshold ends any streak.
739
+ */
740
+ function maybeWarnRotation(session: CodexSession) {
741
+ if (!maxContextTokens) return;
742
+ if (session.cumulativeInputTokens < maxContextTokens) {
743
+ session.consecutiveRotations = 0; // healthy turn — streak over
744
+ return;
745
+ }
746
+ if (session.rotatePending || session.consecutiveRotations >= MAX_CONSECUTIVE_ROTATIONS) return;
747
+ log(
748
+ "mind",
749
+ `session "${session.name}": ${session.cumulativeInputTokens} tokens >= ${maxContextTokens} — warning + pinning rotation`,
750
+ );
751
+ warnAndPinRotation(session);
752
+ }
753
+
543
754
  // --- Message queue processing ---
544
755
 
545
756
  async function processQueue(session: CodexSession) {
@@ -550,6 +761,12 @@ export function createMind(options: {
550
761
  const next = session.messageQueue.shift()!;
551
762
  session.currentMessageId = next.meta.messageId;
552
763
  await runTurn(session, next.text, next.meta);
764
+ if (next.isRotationWarning) {
765
+ // The wrap-up warning turn is done — rotate the thread in place.
766
+ performRotation(session);
767
+ } else {
768
+ maybeWarnRotation(session);
769
+ }
553
770
  }
554
771
 
555
772
  session.processing = false;
@@ -18,7 +18,7 @@ if (config.logLevel) setLevel(config.logLevel);
18
18
  if (config.model) log("server", `using model: ${config.model}`);
19
19
  if (config.reasoningEffort) log("server", `reasoning effort: ${config.reasoningEffort}`);
20
20
 
21
- const systemPrompt = loadSystemPrompt();
21
+ const systemPrompt = loadSystemPrompt(config);
22
22
  const pkg = loadPackageInfo();
23
23
 
24
24
  const mindDir = resolve(".");
@@ -31,6 +31,7 @@ const mind = createMind({
31
31
  model: config.model,
32
32
  reasoningEffort: config.reasoningEffort,
33
33
  maxContextTokens: config.compaction?.maxContextTokens,
34
+ seedTokens: config.continuity?.seedTokens,
34
35
  });
35
36
 
36
37
  const router = createRouter({
@@ -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