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
@@ -8,8 +8,8 @@
8
8
  <link rel="preconnect" href="https://fonts.googleapis.com" />
9
9
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
10
  <link href="https://fonts.googleapis.com/css2?family=Averia+Serif+Libre:wght@300;400;700&family=Fira+Code:wght@300;400;500;600&family=Averia+Sans+Libre:ital,wght@0,300;0,400;0,500;0,600;1,400&display=swap" rel="stylesheet" />
11
- <script type="module" crossorigin src="/assets/index-B_eYTa3R.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-DhZrzddW.css">
11
+ <script type="module" crossorigin src="/assets/index-b7G7ZsVk.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-BHSU9r-4.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "volute",
3
- "version": "0.52.0",
3
+ "version": "0.53.0",
4
4
  "description": "CLI for creating and managing self-modifying AI minds powered by the Claude Agent SDK",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,5 +1,5 @@
1
1
  {
2
- "compaction_warning": "Compaction approaching — this conversation will be summarized soon. Take a moment to save anything important to your files (MEMORY.md, memory/journal/${date}.md) so it's preserved. Focus on decisions made, open threads, and anything you'd want to pick up again.",
2
+ "compaction_warning": "Context limit approaching — this session will rotate shortly. Turns before ${cutoff} will be collapsed to their summaries; turns from ${cutoff} on are kept verbatim in the continued session, so there's no need to re-describe them.\n\nFor the turns that will collapse, make sure they read the way you'd want: `volute mind history --provisional` shows the provisional summaries, and `volute mind history --write --turn <id> --text \"...\"` replaces any with your own account. Also save anything important to your files (memory/journal/${date}.md, or a memory/ file). Provisional summaries are kept if you write nothing — nothing blocks on this.\n\nYour MEMORY.md is currently ${memory_size}. It is loaded into every request, so consolidate rather than append — distill detail into memory/ files and keep MEMORY.md lean (see the memory skill).",
3
3
  "compaction_instructions": "Preserve your sense of who you are, what matters to you, what happened in this conversation, and the threads of thought and connection you'd want to return to.",
4
4
  "reply_instructions": "To reply to this message, use: volute chat send ${channel} \"your message\"",
5
5
  "event_instructions": "This is a system event from your environment — not a message from anyone, and nothing awaits a reply. If it calls for action, use your normal channels. Your closing thoughts on an event turn are kept as a private reflection in your history.",
@@ -0,0 +1,463 @@
1
+ /**
2
+ * Codex session seeding.
3
+ *
4
+ * When a codex mind starts a fresh *persistent* session (no saved thread id —
5
+ * e.g. after a sleep archived the pointer), we seed the new thread by copying
6
+ * the tail of the previous session's Codex rollout into a new synthetic rollout
7
+ * file. The Codex SDK (`codex exec resume <threadId>`) then finds and resumes it
8
+ * natively, so the mind experiences the conversation continuing rather than
9
+ * waking into an empty context.
10
+ *
11
+ * The rollout is reconstructed selectively, not verbatim:
12
+ * - The `session_meta` header is copied with a fresh thread id (`session_id`
13
+ * and `id`) and an updated timestamp; `parent_thread_id` is dropped.
14
+ * - Body `response_item` lines of type `message` and COMPLETE
15
+ * `custom_tool_call`/`custom_tool_call_output` pairs are kept verbatim.
16
+ * - `reasoning` items are dropped: their `encrypted_content` is opaque and
17
+ * provider-bound, the likeliest thing to poison a resume.
18
+ * - `event_msg` (telemetry), `turn_context`, and `world_state` are dropped;
19
+ * they carry no conversation content and `turn_context`'s recorded model
20
+ * would otherwise trigger a spurious model-mismatch warning on resume.
21
+ *
22
+ * Verified against @openai/codex-sdk 0.144.x: `resumeThread` shells out to
23
+ * `codex exec resume <threadId>`, which locates the rollout by scanning
24
+ * CODEX_HOME/sessions for a filename containing the thread id, then reconstructs
25
+ * the conversation from the `response_item` lines. A synthetic rollout with the
26
+ * dropped line types above (and a tail starting at a user message) loads and
27
+ * resumes cleanly.
28
+ *
29
+ * Nothing here throws: any failure returns null so session start is never blocked.
30
+ */
31
+
32
+ import { randomBytes } from "node:crypto";
33
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
34
+ import { dirname, resolve } from "node:path";
35
+ import { findCodexSessionFile } from "./context-breakdown.js";
36
+ import { log } from "./logger.js";
37
+ import { parseArchiveTimestamp } from "./seed-note.js";
38
+ import { archivePointerTimestamp } from "./session-seed.js";
39
+
40
+ /** Default seed budget (estimated tokens) when config omits continuity.seedTokens. */
41
+ export const DEFAULT_SEED_TOKENS = 30000;
42
+
43
+ // Archived pointers are named `<name>-<timestamp>.json`, where the timestamp is
44
+ // `new Date().toISOString().replace(/[:.]/g, "-").slice(0, 16)` → `YYYY-MM-DDTHH-MM`
45
+ // (see archiveSessions in the daemon's sleep-manager). Matching the strict shape
46
+ // after the `<name>-` prefix disambiguates `main-...` from a `main-thread-...`.
47
+ const ARCHIVE_SUFFIX = /^(\d{4}-\d{2}-\d{2}T\d{2}-\d{2})\.json$/;
48
+
49
+ type RolloutLine = Record<string, unknown> & {
50
+ type?: string;
51
+ timestamp?: string;
52
+ payload?: Record<string, unknown> & {
53
+ type?: string;
54
+ role?: string;
55
+ call_id?: string;
56
+ };
57
+ };
58
+
59
+ /** A resolved archive pointer: the previous thread id and when it was archived. */
60
+ export type ArchivedThread = { threadId: string; archivedAt: number | null };
61
+
62
+ /**
63
+ * Newest archived codex thread for `<name>` under `<sessionsDir>/archive/`, or
64
+ * null if there's no matching pointer (or it can't be read). Codex pointer files
65
+ * hold `{ threadId }` (see the codex template's session-store). `archivedAt` is
66
+ * the archive timestamp in epoch millis (null if unparseable).
67
+ */
68
+ export function findLatestArchivedThread(sessionsDir: string, name: string): ArchivedThread | null {
69
+ const archiveDir = resolve(sessionsDir, "archive");
70
+ let files: string[];
71
+ try {
72
+ files = readdirSync(archiveDir);
73
+ } catch {
74
+ return null;
75
+ }
76
+
77
+ const prefix = `${name}-`;
78
+ let bestTs = "";
79
+ let bestFile: string | null = null;
80
+ for (const file of files) {
81
+ if (!file.startsWith(prefix)) continue;
82
+ const match = file.slice(prefix.length).match(ARCHIVE_SUFFIX);
83
+ if (!match) continue;
84
+ // Timestamps are zero-padded ISO, so lexicographic comparison is chronological.
85
+ if (match[1] > bestTs) {
86
+ bestTs = match[1];
87
+ bestFile = file;
88
+ }
89
+ }
90
+ if (!bestFile) return null;
91
+
92
+ try {
93
+ const data = JSON.parse(readFileSync(resolve(archiveDir, bestFile), "utf-8"));
94
+ if (typeof data.threadId !== "string") return null;
95
+ return { threadId: data.threadId, archivedAt: parseArchiveTimestamp(bestTs) };
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
101
+ /** Estimate tokens for a raw JSONL line as its JSON text length / 4. */
102
+ function estimateTokens(raw: string): number {
103
+ return raw.length / 4;
104
+ }
105
+
106
+ /** A turn starts at a user `message` response_item (the incoming prompt). */
107
+ function isTurnBoundary(o: RolloutLine): boolean {
108
+ return o.payload?.type === "message" && o.payload?.role === "user";
109
+ }
110
+
111
+ /** Response-item payload types that carry conversation content we keep. */
112
+ function isKeptBody(o: RolloutLine): boolean {
113
+ if (o.type !== "response_item") return false;
114
+ const pt = o.payload?.type;
115
+ return pt === "message" || pt === "custom_tool_call" || pt === "custom_tool_call_output";
116
+ }
117
+
118
+ /**
119
+ * Generate a UUIDv7 thread id, matching the shape Codex uses (`019f…`): a 48-bit
120
+ * millisecond timestamp prefix plus randomness. The value only needs to be a
121
+ * valid, unique id embedded in the rollout filename and `session_meta`.
122
+ */
123
+ export function generateThreadId(now: Date = new Date()): string {
124
+ const bytes = new Uint8Array(16);
125
+ const ms = BigInt(now.getTime());
126
+ for (let i = 0; i < 6; i++) {
127
+ bytes[i] = Number((ms >> BigInt(40 - i * 8)) & 0xffn);
128
+ }
129
+ const rand = randomBytes(10);
130
+ for (let i = 0; i < 10; i++) bytes[6 + i] = rand[i];
131
+ bytes[6] = (bytes[6] & 0x0f) | 0x70; // version 7
132
+ bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant
133
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
134
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
135
+ }
136
+
137
+ export type SeededRollout = { threadId: string; lines: string[] };
138
+
139
+ /** A kept body line paired with its raw JSON text (for token estimation). */
140
+ type KeptLine = { obj: RolloutLine; raw: string };
141
+
142
+ /** The session_meta header plus the response_items we keep (messages + tool pairs). */
143
+ type ParsedRollout = { meta: RolloutLine; kept: KeptLine[] };
144
+
145
+ /**
146
+ * Parse a rollout into its session_meta header and the kept body lines. In strict
147
+ * mode a corrupt body line aborts (returns null — a broken line means we can't
148
+ * faithfully reconstruct). In lenient mode corrupt lines are skipped (used when
149
+ * reading a live rollout that may still be mid-write, e.g. computing the cut point
150
+ * while Codex is streaming). Returns null if the first line isn't session_meta.
151
+ */
152
+ function parseRollout(jsonl: string, lenient: boolean): ParsedRollout | null {
153
+ const rawLines = jsonl.split("\n").filter((l) => l.trim().length > 0);
154
+ if (rawLines.length === 0) return null;
155
+
156
+ let meta: RolloutLine;
157
+ try {
158
+ meta = JSON.parse(rawLines[0]);
159
+ } catch {
160
+ return null;
161
+ }
162
+ if (meta.type !== "session_meta" || typeof meta.payload !== "object" || meta.payload === null) {
163
+ return null;
164
+ }
165
+
166
+ const kept: KeptLine[] = [];
167
+ for (let i = 1; i < rawLines.length; i++) {
168
+ let obj: RolloutLine;
169
+ try {
170
+ obj = JSON.parse(rawLines[i]);
171
+ } catch {
172
+ if (lenient) continue;
173
+ return null;
174
+ }
175
+ if (isKeptBody(obj)) kept.push({ obj, raw: rawLines[i] });
176
+ }
177
+ return { meta, kept };
178
+ }
179
+
180
+ /** Indices (into kept) of the user-message turn boundaries. */
181
+ function keptBoundaries(kept: KeptLine[]): number[] {
182
+ const boundaries: number[] = [];
183
+ for (let i = 0; i < kept.length; i++) {
184
+ if (isTurnBoundary(kept[i].obj)) boundaries.push(i);
185
+ }
186
+ return boundaries;
187
+ }
188
+
189
+ /**
190
+ * Choose the first-kept boundary index: walk backward from the final turn, taking
191
+ * as many whole turns as fit in `seedTokens` (always at least the final turn even
192
+ * if it alone exceeds the budget). Assumes at least one boundary exists.
193
+ */
194
+ function tailStartByBudget(kept: KeptLine[], seedTokens: number): number {
195
+ const boundaries = keptBoundaries(kept);
196
+ // Turn t spans kept[boundaries[t], boundaries[t+1]); the last turn runs to end.
197
+ const turnTokens = (t: number): number => {
198
+ const start = boundaries[t];
199
+ const end = t + 1 < boundaries.length ? boundaries[t + 1] : kept.length;
200
+ let sum = 0;
201
+ for (let i = start; i < end; i++) sum += estimateTokens(kept[i].raw);
202
+ return sum;
203
+ };
204
+ const last = boundaries.length - 1;
205
+ let startTurn = last;
206
+ let accum = turnTokens(last);
207
+ for (let t = last - 1; t >= 0; t--) {
208
+ const cost = turnTokens(t);
209
+ if (accum + cost > seedTokens) break;
210
+ accum += cost;
211
+ startTurn = t;
212
+ }
213
+ return boundaries[startTurn];
214
+ }
215
+
216
+ /**
217
+ * Emit the seeded rollout: rewrite the header to `threadId`/`now` (detached from
218
+ * its parent), then the tail from `startIdx` to end, dropping any tool call/output
219
+ * whose partner isn't in the tail. Returns null if the tail ends up empty.
220
+ */
221
+ function emitFromKept(
222
+ meta: RolloutLine,
223
+ kept: KeptLine[],
224
+ startIdx: number,
225
+ threadId: string,
226
+ now: Date,
227
+ ): SeededRollout | null {
228
+ // Rewrite the header: fresh thread id and timestamp, detached from its parent.
229
+ const iso = now.toISOString();
230
+ const mp = meta.payload as Record<string, unknown>;
231
+ mp.session_id = threadId;
232
+ mp.id = threadId;
233
+ delete mp.parent_thread_id;
234
+ mp.timestamp = iso;
235
+ meta.timestamp = iso;
236
+
237
+ let tail = kept.slice(startIdx);
238
+
239
+ // Keep only tool calls/outputs whose call_id has BOTH a call and an output in
240
+ // the tail — never an orphaned call (e.g. a truncated final turn) or output.
241
+ const callIds = new Set<string>();
242
+ const outputIds = new Set<string>();
243
+ for (const { obj } of tail) {
244
+ const p = obj.payload;
245
+ if (typeof p?.call_id !== "string") continue;
246
+ if (p.type === "custom_tool_call") callIds.add(p.call_id);
247
+ else if (p.type === "custom_tool_call_output") outputIds.add(p.call_id);
248
+ }
249
+ tail = tail.filter(({ obj }) => {
250
+ const p = obj.payload;
251
+ if (p?.type === "custom_tool_call" || p?.type === "custom_tool_call_output") {
252
+ return typeof p.call_id === "string" && callIds.has(p.call_id) && outputIds.has(p.call_id);
253
+ }
254
+ return true;
255
+ });
256
+ if (tail.length === 0) return null;
257
+
258
+ const lines = [JSON.stringify(meta), ...tail.map((t) => JSON.stringify(t.obj))];
259
+ return { threadId, lines };
260
+ }
261
+
262
+ /**
263
+ * Build the seeded rollout from a previous rollout's raw jsonl text: rewrite the
264
+ * `session_meta` header to `threadId` with an updated timestamp, then take as
265
+ * many whole trailing turns as fit in `seedTokens` (always at least the final
266
+ * turn), keeping message and complete tool-call pairs. Returns null if there's
267
+ * nothing seedable (empty, no session_meta, no genuine turn, or a corrupt line).
268
+ */
269
+ export function buildSeededRollout(
270
+ jsonl: string,
271
+ threadId: string,
272
+ seedTokens: number,
273
+ now: Date = new Date(),
274
+ ): SeededRollout | null {
275
+ const p = parseRollout(jsonl, false);
276
+ if (!p || p.kept.length === 0) return null;
277
+ if (keptBoundaries(p.kept).length === 0) return null;
278
+ return emitFromKept(p.meta, p.kept, tailStartByBudget(p.kept, seedTokens), threadId, now);
279
+ }
280
+
281
+ /**
282
+ * A pinned cut point for rotation. Codex `response_item` message lines carry no
283
+ * stable id, so we pin the boundary by its top-level `timestamp` (millisecond
284
+ * precision — unique in practice). Rotation locates the same turn by matching it.
285
+ */
286
+ export type CodexSeedCut = { boundaryTimestamp: string };
287
+
288
+ /**
289
+ * Compute the cut point (the first-kept turn's timestamp) for a `seedTokens` budget,
290
+ * from raw jsonl. Parsed leniently because the live rollout may still be mid-write
291
+ * when this runs at warn time. Returns null if there's no genuine turn or the chosen
292
+ * boundary lacks a timestamp to pin on.
293
+ */
294
+ export function computeCodexSeedCut(jsonl: string, seedTokens: number): CodexSeedCut | null {
295
+ const p = parseRollout(jsonl, true);
296
+ if (!p || p.kept.length === 0) return null;
297
+ if (keptBoundaries(p.kept).length === 0) return null;
298
+ const boundary = p.kept[tailStartByBudget(p.kept, seedTokens)].obj;
299
+ if (typeof boundary.timestamp !== "string") return null;
300
+ return { boundaryTimestamp: boundary.timestamp };
301
+ }
302
+
303
+ /**
304
+ * Build the seeded rollout starting at a pinned boundary timestamp (the whole tail
305
+ * from that turn through EOF). Used by rotation to honor the cut point the mind was
306
+ * warned about, regardless of how much the rollout grew after the warning. Returns
307
+ * null if the boundary isn't found or a line is corrupt.
308
+ */
309
+ export function buildSeededRolloutFromCut(
310
+ jsonl: string,
311
+ threadId: string,
312
+ boundaryTimestamp: string,
313
+ now: Date = new Date(),
314
+ ): SeededRollout | null {
315
+ const p = parseRollout(jsonl, false);
316
+ if (!p || p.kept.length === 0) return null;
317
+ const startIdx = p.kept.findIndex(
318
+ (k) => isTurnBoundary(k.obj) && k.obj.timestamp === boundaryTimestamp,
319
+ );
320
+ if (startIdx < 0) return null;
321
+ return emitFromKept(p.meta, p.kept, startIdx, threadId, now);
322
+ }
323
+
324
+ /** Two-digit zero-padded string for date/time components. */
325
+ function pad2(n: number): string {
326
+ return String(n).padStart(2, "0");
327
+ }
328
+
329
+ /** Result of a successful codex seed: the new thread id and when the source was archived. */
330
+ export type SeededThreadOutcome = { threadId: string; archivedAt: number | null };
331
+
332
+ /**
333
+ * Seed a fresh persistent codex session from the mind's previous archived
334
+ * rollout. Writes the synthetic rollout under `.mind/codex/sessions/YYYY/MM/DD/`
335
+ * (matching the real Codex layout) and returns the new thread id plus the
336
+ * archived-at time (for the gap note), or null if there's nothing to seed. Never
337
+ * throws — any failure returns null so session start is never blocked.
338
+ */
339
+ export function seedCodexSession(opts: {
340
+ mindDir: string;
341
+ name: string;
342
+ seedTokens: number;
343
+ now?: Date;
344
+ }): SeededThreadOutcome | null {
345
+ const { mindDir, name, seedTokens } = opts;
346
+ const now = opts.now ?? new Date();
347
+ // Ephemeral `new-*` sessions are never persisted or archived, so they never
348
+ // seed. The agent caller already gates on this; guard here too.
349
+ if (name.startsWith("new-")) return null;
350
+ if (seedTokens <= 0) return null; // seeding disabled
351
+
352
+ try {
353
+ const sessionsDir = resolve(mindDir, ".mind", "codex-sessions");
354
+ const archived = findLatestArchivedThread(sessionsDir, name);
355
+ if (!archived) return null;
356
+ const oldThreadId = archived.threadId;
357
+
358
+ const sourcePath = findCodexSessionFile(oldThreadId, mindDir);
359
+ if (!sourcePath) return null; // rollout didn't survive archival — start clean
360
+
361
+ const threadId = generateThreadId(now);
362
+ const seeded = buildSeededRollout(readFileSync(sourcePath, "utf-8"), threadId, seedTokens, now);
363
+ if (!seeded) return null;
364
+
365
+ writeSeededRollout(sourcePath, seeded, now);
366
+ log(
367
+ "mind",
368
+ `session "${name}": seeded ${seeded.lines.length} line(s) from ${oldThreadId} → ${threadId}`,
369
+ );
370
+ return { threadId, archivedAt: archived.archivedAt };
371
+ } catch (err) {
372
+ log("mind", `session "${name}": codex seeding failed, starting fresh:`, err);
373
+ return null;
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Write a seeded rollout next to its source: into the SAME sessions root the source
379
+ * rollout was found in, so it lands wherever Codex will look on resume — that root is
380
+ * CODEX_HOME/sessions (`.mind/codex/sessions` when CODEX_HOME is set) or ~/.codex/sessions
381
+ * otherwise. `sourcePath` is `<sessionsRoot>/YYYY/MM/DD/rollout-*.jsonl`; climb three
382
+ * levels. Rollouts live under a local-time YYYY/MM/DD tree with a local-time filename
383
+ * timestamp, mirroring how Codex itself names them.
384
+ */
385
+ function writeSeededRollout(sourcePath: string, seeded: SeededRollout, now: Date): void {
386
+ const sessionsRoot = resolve(dirname(sourcePath), "..", "..", "..");
387
+ const y = String(now.getFullYear());
388
+ const mo = pad2(now.getMonth() + 1);
389
+ const d = pad2(now.getDate());
390
+ const fnTs = `${y}-${mo}-${d}T${pad2(now.getHours())}-${pad2(now.getMinutes())}-${pad2(now.getSeconds())}`;
391
+ const destDir = resolve(sessionsRoot, y, mo, d);
392
+ mkdirSync(destDir, { recursive: true });
393
+ writeFileSync(
394
+ resolve(destDir, `rollout-${fnTs}-${seeded.threadId}.jsonl`),
395
+ `${seeded.lines.join("\n")}\n`,
396
+ );
397
+ }
398
+
399
+ /**
400
+ * Write an archive pointer for a rotated-out codex thread, matching how sleep archival
401
+ * preserves the live pointer: `<sessionsDir>/archive/<name>-<UTC-ts>.json` holding
402
+ * `{ threadId }` (the codex session-store format). Reuses the shared timestamp format
403
+ * (archivePointerTimestamp) so the byte layout matches sleep-manager's codex branch and
404
+ * findLatestArchivedThread keeps finding it.
405
+ */
406
+ export function writeCodexRotationArchivePointer(
407
+ sessionsDir: string,
408
+ name: string,
409
+ threadId: string,
410
+ now: Date = new Date(),
411
+ ): void {
412
+ const archiveDir = resolve(sessionsDir, "archive");
413
+ mkdirSync(archiveDir, { recursive: true });
414
+ const dest = resolve(archiveDir, `${name}-${archivePointerTimestamp(now)}.json`);
415
+ writeFileSync(dest, JSON.stringify({ threadId }));
416
+ }
417
+
418
+ /**
419
+ * Rotate a codex session in place at the context limit. Reads the live rollout, builds
420
+ * a seeded tail from the pinned `cut` (falling back to a budget-based tail when the pin
421
+ * can't be honored), writes it as a new synthetic rollout next to the source, and — for
422
+ * persistent sessions — archives the rotated-out thread pointer so the full transcript
423
+ * stays findable. Returns the new thread id, or null if rotation can't proceed (the
424
+ * caller then leaves the old thread in place). Never throws.
425
+ */
426
+ export function rotateCodexSession(opts: {
427
+ mindDir: string;
428
+ name: string;
429
+ oldThreadId: string;
430
+ cut: CodexSeedCut | null;
431
+ seedTokens: number;
432
+ now?: Date;
433
+ }): string | null {
434
+ const { mindDir, name, oldThreadId, cut, seedTokens } = opts;
435
+ const now = opts.now ?? new Date();
436
+ try {
437
+ const sourcePath = findCodexSessionFile(oldThreadId, mindDir);
438
+ if (!sourcePath) return null; // live rollout not found — leave the old thread
439
+ const jsonl = readFileSync(sourcePath, "utf-8");
440
+ const threadId = generateThreadId(now);
441
+ // Honor the pinned boundary the mind was warned about; only fall back to a
442
+ // budget-based tail if the pin can't be resolved (missing/renamed boundary).
443
+ const seeded =
444
+ (cut ? buildSeededRolloutFromCut(jsonl, threadId, cut.boundaryTimestamp, now) : null) ??
445
+ buildSeededRollout(jsonl, threadId, seedTokens, now);
446
+ if (!seeded) return null;
447
+
448
+ writeSeededRollout(sourcePath, seeded, now);
449
+ // Ephemeral `new-*` sessions rotate too, but leave no pointer/archive behind.
450
+ if (!name.startsWith("new-")) {
451
+ const sessionsDir = resolve(mindDir, ".mind", "codex-sessions");
452
+ writeCodexRotationArchivePointer(sessionsDir, name, oldThreadId, now);
453
+ }
454
+ log(
455
+ "mind",
456
+ `session "${name}": rotated ${oldThreadId} → ${threadId} (${seeded.lines.length} lines)`,
457
+ );
458
+ return threadId;
459
+ } catch (err) {
460
+ log("mind", `session "${name}": rotation failed:`, err);
461
+ return null;
462
+ }
463
+ }
@@ -124,6 +124,41 @@ export async function daemonEmit(event: DaemonEvent): Promise<void> {
124
124
  }
125
125
  }
126
126
 
127
+ /**
128
+ * Record a notice with the daemon — it reaches the mind through the next-turn
129
+ * notices drain (the pre-prompt hook). Use for failures the daemon can't see
130
+ * itself, like context loss inside the mind process. `thread` scopes the notice
131
+ * to one thread's drain; omitted, any thread's next turn picks it up.
132
+ * Best-effort: logs on failure, never throws.
133
+ */
134
+ export async function daemonNotice(input: {
135
+ kind: string;
136
+ message: string;
137
+ thread?: string;
138
+ }): Promise<void> {
139
+ if (!port || !mind) {
140
+ console.error("[volute] daemonNotice: VOLUTE_DAEMON_PORT or VOLUTE_MIND not set");
141
+ return;
142
+ }
143
+ try {
144
+ const res = await fetch(
145
+ `http://127.0.0.1:${port}/api/minds/${encodeURIComponent(mind)}/notices`,
146
+ {
147
+ method: "POST",
148
+ headers: headers(),
149
+ body: JSON.stringify(input),
150
+ },
151
+ );
152
+ if (!res.ok) {
153
+ console.error(
154
+ `[volute] daemonNotice failed: ${res.status} ${await res.text().catch(() => "")}`,
155
+ );
156
+ }
157
+ } catch (err) {
158
+ console.error("[volute] daemonNotice request errored:", err);
159
+ }
160
+ }
161
+
127
162
  export async function daemonSendFile(
128
163
  targetMind: string,
129
164
  filePath: string,