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.
- package/dist/{accept-5NK5XJB5.js → accept-RSDIVJL6.js} +3 -0
- package/dist/auto-upgrade-HEVZKPTF.js +46 -0
- package/dist/{backup-YVGV2ZNM.js → backup-QRNX6RK7.js} +10 -10
- package/dist/channels-CKCY64TD.js +203 -0
- package/dist/{chat-VAZ25KVT.js → chat-ZUGNL5GS.js} +5 -5
- package/dist/chunk-3LHUXY3E.js +102 -0
- package/dist/{chunk-SW2U6HRB.js → chunk-444RR3YF.js} +21 -4
- package/dist/{chunk-YVR6EHWL.js → chunk-56IT2ZXF.js} +160 -18
- package/dist/chunk-5RBEEAKW.js +58 -0
- package/dist/{chunk-OXWUEU5V.js → chunk-5U5GLZSD.js} +2 -2
- package/dist/{chunk-G7A5LEVI.js → chunk-7XKPG66D.js} +5 -5
- package/dist/{chunk-R772ET2M.js → chunk-AAPNU7GA.js} +4 -0
- package/dist/{chunk-XRWUQPCA.js → chunk-ATZF2RNJ.js} +25 -3
- package/dist/{chunk-6NLW72HP.js → chunk-CKPAVQNK.js} +1 -1
- package/dist/{chunk-JHKBALS2.js → chunk-CLCHOXLR.js} +1 -1
- package/dist/{chunk-DMK5ASKV.js → chunk-F6D766VQ.js} +6 -2
- package/dist/{chunk-AXFLY5GU.js → chunk-HKGYZVWN.js} +16 -1
- package/dist/{chunk-ZEZQNP2G.js → chunk-LGXVGIWN.js} +103 -25
- package/dist/{chunk-CW7BQKF6.js → chunk-LKUGUQUW.js} +3 -3
- package/dist/{chunk-FXWBFDQR.js → chunk-OAYY3WF3.js} +59 -12
- package/dist/{chunk-ITK6HV47.js → chunk-OGPVJ53N.js} +14 -6
- package/dist/{chunk-M77B73VV.js → chunk-OIOW3FPT.js} +7 -2
- package/dist/chunk-OT5SXF7L.js +904 -0
- package/dist/{chunk-EDR47UE2.js → chunk-OYF25B62.js} +1 -1
- package/dist/{chunk-RPPTGPYF.js → chunk-PPSN2MLC.js} +2 -2
- package/dist/{chunk-K2F237KS.js → chunk-RGHF3OK5.js} +154 -18
- package/dist/{chunk-RU74YG46.js → chunk-VDUCLVZI.js} +338 -30
- package/dist/{chunk-724DVYHU.js → chunk-WHL6PKYP.js} +10 -10
- package/dist/{chunk-EEOMA4DX.js → chunk-WZZCA23D.js} +2 -1
- package/dist/{chunk-UDZLPR34.js → chunk-YSRYCS3N.js} +13 -8
- package/dist/{chunk-5N32EN4O.js → chunk-Z4Q6TQWL.js} +1 -1
- package/dist/{chunk-VPOMJMV6.js → chunk-ZNHL5KQJ.js} +1 -1
- package/dist/cli.js +17 -17
- package/dist/{clock-ZT5YT6HA.js → clock-X7V3C7NL.js} +2 -2
- package/dist/{cloud-sync-H7EWZASD.js → cloud-sync-52YNJRMR.js} +12 -11
- package/dist/{daemon-restart-4FWUA5JF.js → daemon-restart-QWHN5VMU.js} +4 -4
- package/dist/daemon.js +538 -782
- package/dist/{db-IPXRJUI7.js → db-I4EH5SLF.js} +3 -1
- package/dist/{delivery-manager-VYXVKUMP.js → delivery-manager-7R2YIQ2W.js} +13 -7
- package/dist/delivery-notices-RZQ6GONP.js +18 -0
- package/dist/{delivery-router-RHANNTAC.js → delivery-router-QS4NLJR6.js} +3 -1
- package/dist/{down-QSNEMTTX.js → down-UN2EDX2Y.js} +3 -3
- package/dist/{echo-text-PEEV7FQU.js → echo-text-73CYQRL7.js} +14 -13
- package/dist/{exec-ABD3DYA2.js → exec-Z34TTUO3.js} +1 -1
- package/dist/{extensions-N6L5NYTK.js → extensions-QT65JGV6.js} +22 -11
- package/dist/{files-BRY5RRIM.js → files-PCKBQHAY.js} +2 -1
- package/dist/{isolation-GVV2IBAU.js → isolation-UQPDO34R.js} +1 -1
- package/dist/{message-delivery-7URC52ME.js → message-delivery-TRKT4T2P.js} +16 -13
- package/dist/migrate-name-placeholder-C7NCNLWF.js +37 -0
- package/dist/{mind-NEXGT3KE.js → mind-CT6ANXK4.js} +11 -11
- package/dist/{mind-contacts-QYU4TPEA.js → mind-contacts-PEXCDRXA.js} +6 -6
- package/dist/{mind-history-OGNR4L7W.js → mind-history-RZMMCHMX.js} +83 -11
- package/dist/{mind-list-SYRA7CH5.js → mind-list-7KBLPLES.js} +1 -1
- package/dist/{mind-service-E26ZQXDS.js → mind-service-7EFIJ3ZA.js} +12 -11
- package/dist/{mind-sleep-LZAFBDZD.js → mind-sleep-24HV4RCA.js} +2 -2
- package/dist/{mind-status-Z2LQFA6F.js → mind-status-4YW4PHMO.js} +15 -0
- package/dist/{package-2W2XKSIP.js → package-F6S5NHY2.js} +4 -4
- package/dist/{prompts-NBO3HRK3.js → prompts-JECJIFPQ.js} +1 -1
- package/dist/{reject-TWB4QJHG.js → reject-4NFLXKRN.js} +6 -2
- package/dist/{scheduler-JTTWZDYU.js → scheduler-73Q4MQ7Y.js} +8 -6
- package/dist/{seed-64XEHRUP.js → seed-DRKAP3XV.js} +1 -1
- package/dist/{seed-cmd-UEL2QB3Q.js → seed-cmd-6IMTO3LG.js} +2 -2
- package/dist/{seed-create-4JRQDPGA.js → seed-create-VNLDYRFK.js} +1 -1
- package/dist/{seed-readiness-DYOVCYHN.js → seed-readiness-H5TTPAQH.js} +2 -2
- package/dist/{seed-sprout-NIDIOJXT.js → seed-sprout-PPGYGK4G.js} +5 -5
- package/dist/{send-2YKOZJJ5.js → send-UXUQKO4N.js} +37 -22
- package/dist/{service-XQPMZAGI.js → service-T4P5W6RS.js} +2 -2
- package/dist/{service-install-2EWD7KXD.js → service-install-R4SNYUVO.js} +3 -3
- package/dist/{setup-4IX6PCD3.js → setup-YUAQMDVZ.js} +4 -4
- package/dist/skills/dreaming/SKILL.md +2 -2
- package/dist/skills/dreaming/references/INSTALL.md +1 -1
- package/dist/skills/memory/SKILL.md +47 -43
- package/dist/skills/tending/SKILL.md +2 -0
- package/dist/skills/volute-mind/references/integrations.md +3 -2
- package/dist/skills/volute-mind/references/routing.md +18 -5
- package/dist/{skills-MPUALV3S.js → skills-GMQ5UIIW.js} +2 -2
- package/dist/{sleep-manager-OFP2GFXF.js → sleep-manager-KHI65FEX.js} +12 -11
- package/dist/{spirit-4ECYIVGE.js → spirit-Q5VGFWUI.js} +19 -3
- package/dist/{spirit-availability-Y54Y3675.js → spirit-availability-G5FMWKRH.js} +13 -12
- package/dist/{sprout-GJMZFRGZ.js → sprout-PMTRL7QN.js} +1 -1
- package/dist/{src-LCSOQY6Z.js → src-3FIW7DLH.js} +241 -87
- package/dist/src-H3UCOP6B.js +516 -0
- package/dist/{src-HGNE2XG4.js → src-LTTQVN2Z.js} +1 -1
- package/dist/{status-MWLE3RVC.js → status-LCKTTIWS.js} +2 -2
- package/dist/{system-events-V43HDA3T.js → system-events-Q7IVZ5T4.js} +5 -1
- package/dist/{systems-VDRUZXNF.js → systems-HL7SGSW7.js} +2 -2
- package/dist/{turn-tracker-C5PVIC3B.js → turn-tracker-HZDPMSCC.js} +4 -2
- package/dist/{up-H6LJZDRK.js → up-XZDSJVMU.js} +3 -3
- package/dist/{update-CIWI2JYA.js → update-ILCPVNDD.js} +2 -2
- package/dist/{upgrade-LIE46UBB.js → upgrade-XY2J7NWZ.js} +9 -1
- package/dist/{variant-cleanup-STC6B5HR.js → variant-cleanup-QHGGY7ND.js} +8 -6
- package/dist/{version-notify-LBVHH5YV.js → version-notify-VNVFX6UP.js} +22 -7
- package/dist/web-assets/assets/{index-B_eYTa3R.js → index-CwtxGwe_.js} +16 -16
- package/dist/web-assets/assets/index-DhM1yYRN.css +1 -0
- package/dist/web-assets/index.html +2 -2
- package/package.json +4 -4
- package/packages/extensions/intentions/dist/ui/assets/index-3FneLCKR.css +1 -0
- package/packages/extensions/intentions/dist/ui/assets/index-CQz4aceM.js +67 -0
- package/packages/extensions/{plan → intentions}/dist/ui/index.html +3 -3
- package/packages/extensions/intentions/skills/intention-review/SKILL.md +38 -0
- package/packages/extensions/intentions/skills/intentions/SKILL.md +49 -0
- package/packages/extensions/intentions/skills/intentions/scripts/intentions-hook.sh +45 -0
- package/packages/extensions/pages/skills/commons-gardening/SKILL.md +50 -0
- package/packages/extensions/pages/skills/pages/SKILL.md +18 -13
- package/templates/_base/.init/.config/prompts.json +3 -3
- package/templates/{pi → _base}/.init/.config/routes.json +1 -0
- package/templates/_base/.init/memory/dreams/.gitkeep +0 -0
- package/templates/_base/home/.config/routes.json +1 -1
- package/templates/_base/home/VOLUTE.md +1 -1
- package/templates/_base/src/lib/codex-session-seed.ts +463 -0
- package/templates/_base/src/lib/context-breakdown.ts +11 -3
- package/templates/_base/src/lib/daemon-client.ts +35 -0
- package/templates/_base/src/lib/pi-session-seed.ts +434 -0
- package/templates/_base/src/lib/routing.ts +2 -2
- package/templates/_base/src/lib/seed-note.ts +78 -0
- package/templates/_base/src/lib/session-seed.ts +375 -0
- package/templates/_base/src/lib/startup.ts +112 -6
- package/templates/claude/.init/CLAUDE.md +7 -3
- package/templates/claude/src/agent.ts +295 -101
- package/templates/claude/src/lib/hooks/pre-compact.ts +30 -5
- package/templates/claude/src/lib/message-channel.ts +43 -15
- package/templates/claude/src/lib/recover.ts +69 -0
- package/templates/claude/src/lib/stream-consumer.ts +33 -2
- package/templates/claude/src/server.ts +2 -1
- package/templates/claude/volute-template.json +1 -1
- package/templates/codex/.init/AGENTS.md +7 -3
- package/templates/codex/src/agent.ts +256 -39
- package/templates/codex/src/server.ts +2 -1
- package/templates/codex/volute-template.json +1 -1
- package/templates/pi/.init/MINDS.md +7 -3
- package/templates/pi/src/agent.ts +269 -43
- package/templates/pi/src/lib/event-handler.ts +11 -0
- package/templates/pi/src/lib/mechanics-doc.ts +30 -0
- package/templates/pi/src/server.ts +5 -1
- package/templates/pi/volute-template.json +1 -1
- package/dist/channels-FSAHOQWI.js +0 -90
- package/dist/skills/plan-coordinator/SKILL.md +0 -60
- package/dist/src-UDMJZ6MH.js +0 -426
- package/dist/web-assets/assets/index-DhZrzddW.css +0 -1
- package/packages/extensions/plan/dist/ui/assets/index-B8s8wxyt.js +0 -67
- package/packages/extensions/plan/dist/ui/assets/index-jIkrt-vI.css +0 -1
- package/packages/extensions/plan/skills/plan/SKILL.md +0 -43
- package/packages/extensions/plan/skills/plan/scripts/plan-hook.sh +0 -37
- package/templates/claude/.init/.config/routes.json +0 -11
- package/templates/codex/.init/.config/routes.json +0 -11
- package/dist/{env-IBW2QI6K.js → env-7AZFBPWX.js} +5 -5
- package/dist/{login-RJJTCVR3.js → login-4NUS72Z5.js} +3 -3
- package/dist/{login-SB5OENMV.js → login-57MMYILL.js} +4 -4
- package/dist/{register-OX4HRSGC.js → register-WNCSOTAU.js} +3 -3
- package/dist/{restart-AGEGNSYQ.js → restart-B4GNMVF2.js} +4 -4
- package/dist/{skill-B5OJEQUA.js → skill-QYTBUDNQ.js} +4 -4
- package/dist/{start-MUMNFR52.js → start-BDHJVOVX.js} +3 -3
- package/dist/{stop-EKERNYTX.js → stop-45QYSGYS.js} +4 -4
|
@@ -53,7 +53,7 @@ Messages are routed to named threads based on rules in `.config/routes.json`. Ea
|
|
|
53
53
|
|
|
54
54
|
## New Channels
|
|
55
55
|
|
|
56
|
-
When a message arrives from a channel you don't have a routing rule for, it's held rather than delivered — and because you haven't seen it, it isn't recorded in your history or counted as a message you received. You'll get a **[New channel: ...]** note in your main thread with the sender and a preview; it repeats (1st held message, then every 10th) so a channel stays visible. To start hearing it, add a rule for that channel to `.config/routes.json` — the backlog is released (the 10 most recent per channel; older ones stay readable via `volute chat
|
|
56
|
+
When a message arrives from a channel you don't have a routing rule for, it's held rather than delivered — and because you haven't seen it, it isn't recorded in your history or counted as a message you received. You'll get a **[New channel: ...]** note in your main thread with the sender and a preview; it repeats (1st held message, then every 10th) so a channel stays visible. To start hearing it, add a rule for that channel to `.config/routes.json` — the backlog is released (the 10 most recent per channel; older ones stay readable via `volute chat channels peek "<channel>"`) and recorded as inbound when you actually receive them. To stop the notes and archive the backlog, run `volute chat channels decline "<channel>"`; `volute chat channels list` shows what's currently held. Quote the channel in these commands — an unquoted `#name` is a shell comment and gets stripped before the CLI sees it. (To skip gating entirely and route everything to your default thread, set `"gateUnmatched": false`.)
|
|
57
57
|
|
|
58
58
|
## Variants
|
|
59
59
|
|
|
@@ -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
|
+
}
|
|
@@ -451,9 +451,17 @@ export async function processPiSession(
|
|
|
451
451
|
|
|
452
452
|
// --- Preamble text readers ---
|
|
453
453
|
|
|
454
|
-
/**
|
|
454
|
+
/**
|
|
455
|
+
* Read the instruction file the runtime loads on its own (CLAUDE.md for the
|
|
456
|
+
* Claude Agent SDK, AGENTS.md for codex).
|
|
457
|
+
*
|
|
458
|
+
* MINDS.md is deliberately absent: pi does not auto-load it, so the pi template
|
|
459
|
+
* appends it to the system prompt instead (see pi's `withMechanicsDoc`). Listing
|
|
460
|
+
* it here would count those tokens twice — once under `systemPrompt` and again
|
|
461
|
+
* under `sdkInstructions`.
|
|
462
|
+
*/
|
|
455
463
|
export function readSdkInstructions(cwd: string): string {
|
|
456
|
-
for (const name of ["CLAUDE.md", "
|
|
464
|
+
for (const name of ["CLAUDE.md", "AGENTS.md"]) {
|
|
457
465
|
try {
|
|
458
466
|
return readFileSync(resolve(cwd, name), "utf-8");
|
|
459
467
|
} catch (err: any) {
|
|
@@ -497,7 +505,7 @@ export function countSystemPromptTokens(systemPrompt: string): number {
|
|
|
497
505
|
return countTokens(systemPrompt);
|
|
498
506
|
}
|
|
499
507
|
|
|
500
|
-
/** Count tokens in the
|
|
508
|
+
/** Count tokens in the runtime-loaded instruction file (CLAUDE.md or AGENTS.md). */
|
|
501
509
|
export function countSdkInstructionTokens(cwd: string): number {
|
|
502
510
|
const content = readSdkInstructions(cwd);
|
|
503
511
|
return content ? countTokens(content) : 0;
|
|
@@ -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,
|