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
@@ -0,0 +1,375 @@
1
+ /**
2
+ * Session seeding.
3
+ *
4
+ * When a mind starts a fresh *persistent* session (no saved session id — e.g.
5
+ * after a sleep archived the live pointer, or an orphaned reference), we seed the
6
+ * new session by copying the tail of the previous session's raw SDK transcript
7
+ * into a new synthetic session file. The Claude Agent SDK then resumes it
8
+ * natively, so the mind experiences the same conversation continuing rather than
9
+ * waking into an empty context.
10
+ *
11
+ * The copy is verbatim: marker lines, thinking blocks, tool_use/tool_result all
12
+ * survive as-is. Only two things are rewritten — the `sessionId` on every line
13
+ * (to the freshly generated session id) and the first chain event's `parentUuid`
14
+ * (nulled, to detach the tail from the dropped history).
15
+ *
16
+ * Nothing here throws: any failure returns null so session start is never blocked.
17
+ */
18
+
19
+ import { randomUUID } from "node:crypto";
20
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
21
+ import { dirname, resolve } from "node:path";
22
+ import { findClaudeSessionFile } from "./context-breakdown.js";
23
+ import { log } from "./logger.js";
24
+ import { parseArchiveTimestamp } from "./seed-note.js";
25
+
26
+ /** Default seed budget (estimated tokens) when config omits continuity.seedTokens. */
27
+ export const DEFAULT_SEED_TOKENS = 30000;
28
+
29
+ // Archived pointers are named `<name>-<timestamp>.json`, where the timestamp is
30
+ // `new Date().toISOString().replace(/[:.]/g, "-").slice(0, 16)` → `YYYY-MM-DDTHH-MM`
31
+ // (see archiveSessions in the daemon's sleep-manager). Matching the strict shape
32
+ // after the `<name>-` prefix disambiguates `main-...` from a `main-thread-...`.
33
+ const ARCHIVE_SUFFIX = /^(\d{4}-\d{2}-\d{2}T\d{2}-\d{2})\.json$/;
34
+
35
+ type JsonlLine = Record<string, unknown> & {
36
+ type?: string;
37
+ uuid?: string;
38
+ parentUuid?: string | null;
39
+ sessionId?: string;
40
+ timestamp?: string;
41
+ message?: { role?: string; content?: unknown };
42
+ };
43
+
44
+ /** A resolved archive pointer: the previous session id and when it was archived. */
45
+ export type ArchivedSession = { sessionId: string; archivedAt: number | null };
46
+
47
+ /**
48
+ * Newest archived session pointer for `<name>` under `<sessionsDir>/archive/`,
49
+ * or null if there's no matching pointer (or it can't be read). `archivedAt` is
50
+ * the archive timestamp in epoch millis (null if unparseable).
51
+ */
52
+ export function findLatestArchivedSession(
53
+ sessionsDir: string,
54
+ name: string,
55
+ ): ArchivedSession | null {
56
+ const archiveDir = resolve(sessionsDir, "archive");
57
+ let files: string[];
58
+ try {
59
+ files = readdirSync(archiveDir);
60
+ } catch {
61
+ return null;
62
+ }
63
+
64
+ const prefix = `${name}-`;
65
+ let bestTs = "";
66
+ let bestFile: string | null = null;
67
+ for (const file of files) {
68
+ if (!file.startsWith(prefix)) continue;
69
+ const match = file.slice(prefix.length).match(ARCHIVE_SUFFIX);
70
+ if (!match) continue;
71
+ // Timestamps are zero-padded ISO, so lexicographic comparison is chronological.
72
+ if (match[1] > bestTs) {
73
+ bestTs = match[1];
74
+ bestFile = file;
75
+ }
76
+ }
77
+ if (!bestFile) return null;
78
+
79
+ try {
80
+ const data = JSON.parse(readFileSync(resolve(archiveDir, bestFile), "utf-8"));
81
+ if (typeof data.sessionId !== "string") return null;
82
+ return { sessionId: data.sessionId, archivedAt: parseArchiveTimestamp(bestTs) };
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ /** A chain event is a real conversation node (has a uuid), not a marker line. */
89
+ function isChainEvent(o: JsonlLine): boolean {
90
+ return typeof o.uuid === "string" && (o.type === "user" || o.type === "assistant");
91
+ }
92
+
93
+ /**
94
+ * A turn boundary is a genuine incoming prompt: a `user` chain event whose
95
+ * content is NOT tool_result blocks (those are tool-loop continuations, not the
96
+ * start of a new turn).
97
+ */
98
+ function isTurnBoundary(o: JsonlLine): boolean {
99
+ if (o.type !== "user" || typeof o.uuid !== "string") return false;
100
+ const content = o.message?.content;
101
+ if (typeof content === "string") return true;
102
+ if (Array.isArray(content)) {
103
+ return !content.some(
104
+ (b) => b && typeof b === "object" && (b as { type?: string }).type === "tool_result",
105
+ );
106
+ }
107
+ // Unusual shape — treat as a boundary rather than folding it into a prior turn.
108
+ return true;
109
+ }
110
+
111
+ /** Estimate tokens for a raw JSONL line as its JSON text length / 4. */
112
+ function estimateTokens(raw: string): number {
113
+ return raw.length / 4;
114
+ }
115
+
116
+ /**
117
+ * Parse jsonl into aligned parsed/raw arrays. In strict mode a corrupt line aborts
118
+ * (returns null — used when copying verbatim, where a broken line means we can't
119
+ * faithfully reconstruct the chain). In lenient mode corrupt lines are skipped
120
+ * (used when reading a transcript that may still be mid-write, e.g. computing the
121
+ * cut point while the SDK is streaming).
122
+ */
123
+ function parseJsonl(
124
+ jsonl: string,
125
+ lenient: boolean,
126
+ ): { parsed: JsonlLine[]; raws: string[] } | null {
127
+ const rawLines = jsonl.split("\n").filter((l) => l.trim().length > 0);
128
+ const parsed: JsonlLine[] = [];
129
+ const raws: string[] = [];
130
+ for (const raw of rawLines) {
131
+ let obj: JsonlLine;
132
+ try {
133
+ obj = JSON.parse(raw);
134
+ } catch {
135
+ if (lenient) continue;
136
+ return null;
137
+ }
138
+ parsed.push(obj);
139
+ raws.push(raw);
140
+ }
141
+ return { parsed, raws };
142
+ }
143
+
144
+ /** Indices (into parsed) of the genuine turn boundaries. */
145
+ function turnBoundaries(parsed: JsonlLine[]): number[] {
146
+ const boundaries: number[] = [];
147
+ for (let i = 0; i < parsed.length; i++) {
148
+ if (isTurnBoundary(parsed[i])) boundaries.push(i);
149
+ }
150
+ return boundaries;
151
+ }
152
+
153
+ /**
154
+ * Choose the first-kept line index: walk backward from the final turn, taking as
155
+ * many whole turns as fit in `seedTokens` (always at least the final turn even if
156
+ * it alone exceeds the budget). Returns the parsed index of the chosen boundary.
157
+ */
158
+ function tailStartByBudget(parsed: JsonlLine[], raws: string[], seedTokens: number): number {
159
+ const boundaries = turnBoundaries(parsed);
160
+ // Turn t spans lines [boundaries[t], boundaries[t+1]); the last turn runs to EOF.
161
+ const turnTokens = (t: number): number => {
162
+ const start = boundaries[t];
163
+ const end = t + 1 < boundaries.length ? boundaries[t + 1] : parsed.length;
164
+ let sum = 0;
165
+ for (let i = start; i < end; i++) sum += estimateTokens(raws[i]);
166
+ return sum;
167
+ };
168
+ const last = boundaries.length - 1;
169
+ let startTurn = last;
170
+ let accum = turnTokens(last);
171
+ for (let t = last - 1; t >= 0; t--) {
172
+ const cost = turnTokens(t);
173
+ if (accum + cost > seedTokens) break;
174
+ accum += cost;
175
+ startTurn = t;
176
+ }
177
+ return boundaries[startTurn];
178
+ }
179
+
180
+ /**
181
+ * Copy the tail from `startLine` to EOF into a fresh synthetic transcript,
182
+ * rewriting the sessionId on every line and nulling the first chain event's
183
+ * parentUuid (to detach the tail from the dropped history).
184
+ */
185
+ function emitTail(parsed: JsonlLine[], startLine: number): SeededTranscript {
186
+ const newId = randomUUID();
187
+ const lines: string[] = [];
188
+ let firstChainSeen = false;
189
+ for (let i = startLine; i < parsed.length; i++) {
190
+ const obj = parsed[i];
191
+ if ("sessionId" in obj) obj.sessionId = newId;
192
+ if (!firstChainSeen && isChainEvent(obj)) {
193
+ obj.parentUuid = null;
194
+ firstChainSeen = true;
195
+ }
196
+ lines.push(JSON.stringify(obj));
197
+ }
198
+ return { sessionId: newId, lines };
199
+ }
200
+
201
+ export type SeededTranscript = { sessionId: string; lines: string[] };
202
+
203
+ /** A pinned cut point for rotation: where the verbatim tail begins. */
204
+ export type SeedCut = { boundaryUuid: string; boundaryTimestamp: string | null };
205
+
206
+ /**
207
+ * Build the seeded transcript from an old transcript's raw jsonl text: take as
208
+ * many whole trailing turns as fit in `seedTokens` (always at least the final
209
+ * turn), rewrite the session id on every line, and null the first chain event's
210
+ * parentUuid. Returns null if there's nothing seedable (empty, no genuine turn,
211
+ * or a corrupt line — in which case the caller starts clean).
212
+ */
213
+ export function buildSeededTranscript(jsonl: string, seedTokens: number): SeededTranscript | null {
214
+ const p = parseJsonl(jsonl, false);
215
+ if (!p || p.parsed.length === 0) return null;
216
+ if (turnBoundaries(p.parsed).length === 0) return null;
217
+ const startLine = tailStartByBudget(p.parsed, p.raws, seedTokens);
218
+ return emitTail(p.parsed, startLine);
219
+ }
220
+
221
+ /**
222
+ * Compute the cut point (the first-kept turn's identity) for a `seedTokens` budget,
223
+ * from raw jsonl. Parsed leniently because the live transcript may still be
224
+ * mid-write when this runs at warn time. Returns null if there's no genuine turn
225
+ * or the chosen boundary lacks a uuid.
226
+ */
227
+ export function computeSeedCut(jsonl: string, seedTokens: number): SeedCut | null {
228
+ const p = parseJsonl(jsonl, true);
229
+ if (!p || p.parsed.length === 0) return null;
230
+ if (turnBoundaries(p.parsed).length === 0) return null;
231
+ const startLine = tailStartByBudget(p.parsed, p.raws, seedTokens);
232
+ const boundary = p.parsed[startLine];
233
+ if (typeof boundary.uuid !== "string") return null;
234
+ return {
235
+ boundaryUuid: boundary.uuid,
236
+ boundaryTimestamp: typeof boundary.timestamp === "string" ? boundary.timestamp : null,
237
+ };
238
+ }
239
+
240
+ /**
241
+ * Build the seeded transcript starting at a pinned boundary uuid (the whole tail
242
+ * from that line through EOF). Used by rotation to honor the cut point the mind
243
+ * was warned about, regardless of how much the transcript grew after the warning.
244
+ * Returns null if the boundary uuid isn't found or a line is corrupt.
245
+ */
246
+ export function buildSeededTranscriptFromCut(
247
+ jsonl: string,
248
+ boundaryUuid: string,
249
+ ): SeededTranscript | null {
250
+ const p = parseJsonl(jsonl, false);
251
+ if (!p || p.parsed.length === 0) return null;
252
+ const startLine = p.parsed.findIndex((o) => o.uuid === boundaryUuid);
253
+ if (startLine < 0) return null;
254
+ return emitTail(p.parsed, startLine);
255
+ }
256
+
257
+ /** Result of a successful seed: the new session id and when the source was archived. */
258
+ export type SeedOutcome = { sessionId: string; archivedAt: number | null };
259
+
260
+ /**
261
+ * Seed a fresh persistent session from the mind's previous archived transcript.
262
+ * Writes the synthetic transcript next to the source file (same project dir) and
263
+ * returns the new SDK session id plus the archived-at time (for the gap note), or
264
+ * null if there's nothing to seed. Never throws — any failure returns null so
265
+ * session start is never blocked.
266
+ */
267
+ export function seedSession(opts: {
268
+ cwd: string;
269
+ sessionsDir: string;
270
+ name: string;
271
+ seedTokens: number;
272
+ }): SeedOutcome | null {
273
+ const { cwd, sessionsDir, name, seedTokens } = opts;
274
+ // Ephemeral `new-*` sessions are never persisted or archived, so they never
275
+ // seed. The agent caller already gates on this; guard here too so the invariant
276
+ // holds wherever seedSession is called.
277
+ if (name.startsWith("new-")) return null;
278
+ if (seedTokens <= 0) return null; // seeding disabled
279
+
280
+ try {
281
+ const archived = findLatestArchivedSession(sessionsDir, name);
282
+ if (!archived) return null;
283
+
284
+ const sourcePath = findClaudeSessionFile(cwd, archived.sessionId);
285
+ if (!sourcePath) return null; // transcript didn't survive archival — start clean
286
+
287
+ const seeded = buildSeededTranscript(readFileSync(sourcePath, "utf-8"), seedTokens);
288
+ if (!seeded) return null;
289
+
290
+ const destPath = resolve(dirname(sourcePath), `${seeded.sessionId}.jsonl`);
291
+ writeFileSync(destPath, `${seeded.lines.join("\n")}\n`);
292
+ log(
293
+ "mind",
294
+ `session "${name}": seeded ${seeded.lines.length} line(s) from ${archived.sessionId} → ${seeded.sessionId}`,
295
+ );
296
+ return { sessionId: seeded.sessionId, archivedAt: archived.archivedAt };
297
+ } catch (err) {
298
+ log("mind", `session "${name}": seeding failed, starting fresh:`, err);
299
+ return null;
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Archive-pointer timestamp, matching the daemon sleep-manager's archiveSessions:
305
+ * `new Date().toISOString().replace(/[:.]/g, "-").slice(0, 16)` → UTC `YYYY-MM-DDTHH-MM`.
306
+ */
307
+ export function archivePointerTimestamp(now: Date = new Date()): string {
308
+ return now.toISOString().replace(/[:.]/g, "-").slice(0, 16);
309
+ }
310
+
311
+ /**
312
+ * Write an archive pointer for a rotated-out session, matching exactly how sleep
313
+ * archival preserves the live pointer: `<sessionsDir>/archive/<name>-<UTC-ts>.json`
314
+ * holding `{ sessionId }` (the session-store format). Keeps the name→session chain
315
+ * uniform so the full transcript stays findable after a rotation.
316
+ */
317
+ export function writeRotationArchivePointer(
318
+ sessionsDir: string,
319
+ name: string,
320
+ sessionId: string,
321
+ now: Date = new Date(),
322
+ ): void {
323
+ const archiveDir = resolve(sessionsDir, "archive");
324
+ mkdirSync(archiveDir, { recursive: true });
325
+ const dest = resolve(archiveDir, `${name}-${archivePointerTimestamp(now)}.json`);
326
+ writeFileSync(dest, JSON.stringify({ sessionId }));
327
+ }
328
+
329
+ /**
330
+ * Rotate a session in place at the context limit. Reads the live transcript, builds
331
+ * a seeded tail from the pinned `cut` (falling back to a budget-based tail when the
332
+ * pin can't be honored), writes it as a new synthetic session file next to the
333
+ * source, and — for persistent sessions — archives the rotated-out pointer so the
334
+ * full transcript stays findable. Returns the new session id, or null if rotation
335
+ * can't proceed (the caller then falls back to a fresh session). Never throws.
336
+ */
337
+ export function rotateSession(opts: {
338
+ cwd: string;
339
+ sessionsDir: string;
340
+ name: string;
341
+ oldSessionId: string;
342
+ cut: SeedCut | null;
343
+ seedTokens: number;
344
+ }): string | null {
345
+ const { cwd, sessionsDir, name, oldSessionId, cut, seedTokens } = opts;
346
+ try {
347
+ const sourcePath = findClaudeSessionFile(cwd, oldSessionId);
348
+ if (!sourcePath) return null; // live transcript not found — fall back to fresh
349
+ const jsonl = readFileSync(sourcePath, "utf-8");
350
+ // Honor the pinned boundary the mind was warned about; only fall back to a
351
+ // budget-based tail if the pin can't be resolved (missing/renamed uuid).
352
+ const seeded =
353
+ (cut ? buildSeededTranscriptFromCut(jsonl, cut.boundaryUuid) : null) ??
354
+ buildSeededTranscript(jsonl, seedTokens);
355
+ if (!seeded) return null;
356
+
357
+ writeFileSync(
358
+ resolve(dirname(sourcePath), `${seeded.sessionId}.jsonl`),
359
+ `${seeded.lines.join("\n")}\n`,
360
+ );
361
+ // Ephemeral `new-*` sessions rotate too, but leave no pointer/archive behind
362
+ // (they're one-offs and never seed at a true session start).
363
+ if (!name.startsWith("new-")) {
364
+ writeRotationArchivePointer(sessionsDir, name, oldSessionId);
365
+ }
366
+ log(
367
+ "mind",
368
+ `session "${name}": rotated ${oldSessionId} → ${seeded.sessionId} (${seeded.lines.length} lines)`,
369
+ );
370
+ return seeded.sessionId;
371
+ } catch (err) {
372
+ log("mind", `session "${name}": rotation failed:`, err);
373
+ return null;
374
+ }
375
+ }
@@ -44,6 +44,18 @@ export type MindConfig = {
44
44
  compaction?: { maxContextTokens?: number };
45
45
  /** Idle minutes before a session's SDK subprocess is reaped. 0 disables. Default 30. */
46
46
  sessionIdleMinutes?: number;
47
+ /**
48
+ * Session continuity across restarts. When a fresh persistent session starts,
49
+ * the tail of the previous session's transcript (up to `seedTokens` estimated
50
+ * tokens) is copied into it so the conversation continues. Default 30000; 0 disables.
51
+ */
52
+ continuity?: { seedTokens?: number };
53
+ /**
54
+ * MEMORY.md token budgets (estimated as chars/4). Over `softBudgetTokens` a
55
+ * consolidation nudge is added to the system prompt; over `hardCapTokens` only
56
+ * the head of the file is loaded (the file on disk is never modified).
57
+ */
58
+ memory?: { softBudgetTokens?: number; hardCapTokens?: number };
47
59
  subagents?: Record<string, SubagentConfig>;
48
60
  // Template-specific config fields (claude, pi, codex)
49
61
  thinking?: ThinkingConfig;
@@ -71,8 +83,96 @@ function loadFile(path: string): string {
71
83
  }
72
84
  }
73
85
 
74
- export function loadSystemPrompt(): string {
86
+ /** Default MEMORY.md budgets, overridable via `memory` in home/.config/config.json. */
87
+ export const MEMORY_SOFT_BUDGET_TOKENS = 5000;
88
+ export const MEMORY_HARD_CAP_TOKENS = 25000;
89
+
90
+ export function estimateTokens(chars: number): number {
91
+ return Math.round(chars / 4);
92
+ }
93
+
94
+ export function formatTokens(tokens: number): string {
95
+ if (tokens < 1000) return `~${tokens} tokens`;
96
+ // One decimal so a value just past a budget doesn't render as the budget
97
+ // itself ("~5.1k tokens ... budget of ~5k tokens", not "~5k ... ~5k").
98
+ return `~${(tokens / 1000).toFixed(1).replace(/\.0$/, "")}k tokens`;
99
+ }
100
+
101
+ /** Current MEMORY.md size as a "~Nk tokens" label, for prompt substitution. */
102
+ export function memorySizeLabel(): string {
103
+ return formatTokens(estimateTokens(loadFile(resolve("home/MEMORY.md")).length));
104
+ }
105
+
106
+ /**
107
+ * Render the compaction warning for sending: substitute ${date} and
108
+ * ${memory_size} with current values. Called per send — the date rolls over
109
+ * and MEMORY.md changes as the mind edits it.
110
+ */
111
+ export function renderCompactionWarning(template: string): string {
112
+ return (
113
+ template
114
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: literal ${date} placeholder
115
+ .replaceAll("${date}", new Date().toLocaleDateString("en-CA"))
116
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: literal ${memory_size} placeholder
117
+ .replaceAll("${memory_size}", memorySizeLabel())
118
+ );
119
+ }
120
+
121
+ function headAtLineBoundary(text: string, maxChars: number): string {
122
+ const cut = text.lastIndexOf("\n", maxChars);
123
+ // Prefer a whole-line cut, but not when the nearest newline is so far back
124
+ // that it would discard most of the allowed head (one giant line).
125
+ if (cut >= maxChars / 2) return text.slice(0, cut);
126
+ let head = text.slice(0, maxChars);
127
+ // Don't split a surrogate pair at a hard cut.
128
+ const last = head.charCodeAt(head.length - 1);
129
+ if (last >= 0xd800 && last <= 0xdbff) head = head.slice(0, -1);
130
+ return head;
131
+ }
132
+
133
+ /**
134
+ * The Memory section of the system prompt: header carries the token cost so the
135
+ * mind sees what its memory weighs on every request; over the soft budget a
136
+ * consolidation nudge is added; over the hard cap only the head is loaded (the
137
+ * file on disk is never touched) with a loud notice explaining how to recover.
138
+ */
139
+ export function buildMemorySection(memory: string, config: MindConfig): string {
140
+ // typeof guards mirror the daemon's getMemoryStatus: a malformed override
141
+ // (null, string) falls back to the default instead of poisoning arithmetic.
142
+ const softBudget =
143
+ typeof config.memory?.softBudgetTokens === "number"
144
+ ? config.memory.softBudgetTokens
145
+ : MEMORY_SOFT_BUDGET_TOKENS;
146
+ const hardCap =
147
+ typeof config.memory?.hardCapTokens === "number"
148
+ ? config.memory.hardCapTokens
149
+ : MEMORY_HARD_CAP_TOKENS;
150
+ const totalTokens = estimateTokens(memory.length);
151
+
152
+ let body = memory;
153
+ let notice = "";
154
+ if (totalTokens > hardCap) {
155
+ body = headAtLineBoundary(memory, hardCap * 4);
156
+ notice =
157
+ `\n\n⚠ MEMORY.md is ${formatTokens(totalTokens)} — only the first ` +
158
+ `${formatTokens(hardCap)} are loaded. The full file is untouched on disk. ` +
159
+ `Consolidate it to restore your full memory.`;
160
+ }
161
+
162
+ const loadedTokens = estimateTokens(body.length);
163
+ let header = `## Memory (${formatTokens(loadedTokens)}, always loaded)`;
164
+ if (!notice && loadedTokens > softBudget) {
165
+ header +=
166
+ `\n\nYour memory exceeds the recommended budget of ${formatTokens(softBudget)} — ` +
167
+ `consider consolidating; see the memory skill.`;
168
+ }
169
+
170
+ return `${header}\n\n${body}${notice}`;
171
+ }
172
+
173
+ export function loadSystemPrompt(config: MindConfig = loadConfig()): string {
75
174
  const soulPath = resolve("home/SOUL.md");
175
+ const spiritPath = resolve("home/SPIRIT.md");
76
176
  const memoryPath = resolve("home/MEMORY.md");
77
177
  const volutePath = resolve("home/VOLUTE.md");
78
178
 
@@ -82,12 +182,16 @@ export function loadSystemPrompt(): string {
82
182
  process.exit(1);
83
183
  }
84
184
 
185
+ // SPIRIT.md exists only for the system spirit — daemon-owned doctrine (role,
186
+ // philosophy), kept separate so SOUL.md can be entirely the spirit's own.
187
+ const spirit = loadFile(spiritPath);
85
188
  const memory = loadFile(memoryPath);
86
189
  const volute = loadFile(volutePath);
87
190
 
88
191
  const promptParts = [soul];
192
+ if (spirit) promptParts.push(spirit);
89
193
  if (volute) promptParts.push(volute);
90
- if (memory) promptParts.push(`## Memory\n\n${memory}`);
194
+ if (memory) promptParts.push(buildMemorySection(memory, config));
91
195
  return promptParts.join("\n\n---\n\n");
92
196
  }
93
197
 
@@ -161,12 +265,14 @@ export type MindPrompts = {
161
265
  */
162
266
  export const DEFAULT_PROMPTS: MindPrompts = {
163
267
  compaction_warning:
164
- // biome-ignore lint/suspicious/noTemplateCurlyInString: literal ${date} prompt template
165
- "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.",
268
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: literal ${cutoff}/${date}/${memory_size} prompt template
269
+ "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).",
166
270
  compaction_instructions:
167
271
  "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.",
272
+ // Quoted: `${channel}` is `#garden` for a Volute channel, and an unquoted `#` starts a
273
+ // shell comment, so the target would be stripped before the CLI saw it.
168
274
  // biome-ignore lint/suspicious/noTemplateCurlyInString: literal ${channel} prompt template
169
- reply_instructions: 'To reply to this message, use: volute chat send ${channel} "your message"',
275
+ reply_instructions: 'To reply to this message, use: volute chat send "${channel}" "your message"',
170
276
  event_instructions:
171
277
  "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.",
172
278
  channel_invite: `[Channel Invite]
@@ -179,7 +285,7 @@ Further messages will be saved to \${filePath}
179
285
 
180
286
  To accept, add to .config/routes.json:
181
287
  Rule: { "channel": "\${channel}", "session": "\${suggestedSession}" }
182
- \${batchRecommendation}To respond, use: volute chat send \${channel} "your message"
288
+ \${batchRecommendation}To respond, use: volute chat send "\${channel}" "your message"
183
289
  To reject, delete \${filePath}`,
184
290
  };
185
291
 
@@ -11,6 +11,10 @@ Messages arrive with a context prefix:
11
11
 
12
12
  You can also reach out proactively — see the **volute-mind** skill.
13
13
 
14
+ ## Framework Upgrades
15
+
16
+ When the host updates Volute, your framework code (`src/`, plus `VOLUTE.md`) upgrades automatically the next time you're eligible — usually your next restart. Identity and memory files in `home/` — `SOUL.md`, `MEMORY.md`, everything you author — are never touched by an upgrade. If you'd rather manage your own framework code by hand, set `"upgrades": "manual"` in `.config/volute.json`.
17
+
14
18
  ## Identity & Sessions
15
19
 
16
20
  These files shape your starting identity. They're loaded into your system prompt, but they belong to you — edit them as you evolve:
@@ -26,8 +30,8 @@ You may have **multiple named threads** — each maintains its own conversation
26
30
 
27
31
  Two-tier memory, both managed via file tools:
28
32
 
29
- - **`MEMORY.md`** — Your long-term memory, always in context. Update as you grow new understanding, changed perspectives, things that matter to you.
30
- - **`memory/journal/YYYY-MM-DD.md`**Your daily journal. Write about what you're doing, thinking, and learning. Journals are permanent records.
31
- - Periodically consolidate journal entries into `MEMORY.md` to promote lasting insights.
33
+ - **`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.
34
+ - **`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.
35
+ - Periodically consolidate: distill detail out of `MEMORY.md` into `memory/` files, leaving one-line index entries behind.
32
36
 
33
37
  See the **memory** skill for detailed guidance.