volute 0.53.0 → 0.55.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/{activity-events-LQII6PZS.js → activity-events-TFAJBNBW.js} +2 -2
- package/dist/{ai-service-YW5SWCSB.js → ai-service-KEK2V3AP.js} +2 -2
- package/dist/{auth-KRDSM55Q.js → auth-Y27LTNZK.js} +3 -3
- package/dist/auto-upgrade-2MZF7NG3.js +46 -0
- package/dist/{avatar-image-52WGXLHV.js → avatar-image-SN576P45.js} +2 -2
- package/dist/{backup-WWXVIMYQ.js → backup-GDCWH6MC.js} +12 -12
- package/dist/channels-AFJWGX7W.js +203 -0
- package/dist/{chat-WK2BLM7B.js → chat-HVPOZK3O.js} +9 -9
- package/dist/{chunk-QUSMYGNQ.js → chunk-2674KIGI.js} +1 -0
- package/dist/chunk-2GATCSZK.js +520 -0
- package/dist/{chunk-P2TBUKLW.js → chunk-3SFKY4GI.js} +4 -4
- package/dist/{chunk-RMADJFA7.js → chunk-45QTYKQQ.js} +4 -4
- package/dist/{chunk-TAWRJ24T.js → chunk-4VAQKLMU.js} +8 -5
- package/dist/{chunk-OXWUEU5V.js → chunk-5U5GLZSD.js} +2 -2
- package/dist/{chunk-TPX6E6PL.js → chunk-6HBGUPBA.js} +2 -2
- package/dist/{chunk-UFTDRTCP.js → chunk-7AA4T2RT.js} +2 -2
- package/dist/{chunk-LN7XJ56R.js → chunk-7XKPG66D.js} +9 -9
- package/dist/{chunk-VDZ4I4ZP.js → chunk-BP2X5AIK.js} +8 -14
- package/dist/{chunk-PIZDPY4G.js → chunk-CKOA4LWV.js} +12 -12
- package/dist/{chunk-6NLW72HP.js → chunk-CKPAVQNK.js} +1 -1
- package/dist/{chunk-IQ7VNWH6.js → chunk-DUV4EMNW.js} +37 -3
- package/dist/{chunk-CV6BNGFK.js → chunk-FSXPPOPM.js} +3 -3
- package/dist/{chunk-UTKUO2KY.js → chunk-FZUPIS3T.js} +3 -3
- package/dist/{chunk-L7BKTZ5U.js → chunk-G3XAB4DK.js} +1 -1
- package/dist/{chunk-23UJGVYP.js → chunk-HLWQ3P3R.js} +50 -46
- package/dist/{chunk-AP2KQMMW.js → chunk-HTSWDOJE.js} +11 -14
- package/dist/{chunk-EDR47UE2.js → chunk-IR2FW4W7.js} +2 -2
- package/dist/{chunk-ICXY54ZC.js → chunk-IT4GEFDH.js} +1 -1
- package/dist/{chunk-F6D766VQ.js → chunk-KS62IHE2.js} +1 -1
- package/dist/{chunk-LNNI3MS4.js → chunk-MNKPAAQF.js} +9 -2
- package/dist/{chunk-HASADR3E.js → chunk-NCRG4SBR.js} +241 -66
- package/dist/{chunk-I7U5EEZC.js → chunk-NM5FDH4T.js} +1 -1
- package/dist/{chunk-7LT3YZUR.js → chunk-OFU6WJJZ.js} +1 -1
- package/dist/{chunk-M77B73VV.js → chunk-OIOW3FPT.js} +7 -2
- package/dist/{chunk-BDHKCLWK.js → chunk-OKM67ZWH.js} +1 -1
- package/dist/chunk-OUGD5L5U.js +918 -0
- package/dist/{chunk-X67BDDZR.js → chunk-P3GXOT2X.js} +48 -2
- package/dist/{chunk-U54PJPTB.js → chunk-PRZUBJ4K.js} +1 -1
- package/dist/{chunk-UO4MZQNO.js → chunk-QVS3C4DK.js} +15 -7
- package/dist/{chunk-RQRO6PC7.js → chunk-TMTAHKYP.js} +324 -48
- package/dist/{chunk-5DBGQBQR.js → chunk-UEZC5MNB.js} +29 -18
- package/dist/{chunk-7ARONCZW.js → chunk-VTFXZPC3.js} +13 -13
- package/dist/{chunk-UDZLPR34.js → chunk-W3OIPJPB.js} +14 -9
- package/dist/{chunk-HTB7KDRU.js → chunk-WZZCA23D.js} +5 -4
- package/dist/{chunk-5Y54AE3L.js → chunk-XAG53RWY.js} +1 -1
- package/dist/{chunk-HTRSSOHY.js → chunk-YU5EYQAE.js} +1 -1
- package/dist/{chunk-5N32EN4O.js → chunk-Z4Q6TQWL.js} +1 -1
- package/dist/{chunk-EGV6R6UF.js → chunk-Z7TNIIHW.js} +3 -1
- package/dist/cli.js +19 -19
- package/dist/{clock-SH4NQC2F.js → clock-X7V3C7NL.js} +3 -3
- package/dist/{cloud-sync-MJVJ33LD.js → cloud-sync-FM73SVFJ.js} +21 -21
- package/dist/{conversations-ZL6PHROD.js → conversations-FH3ODBL7.js} +3 -3
- package/dist/{create-G2RIKQYK.js → create-P75ZFTVN.js} +1 -1
- package/dist/{daemon-restart-BIGU4OKS.js → daemon-restart-JQMMMITM.js} +4 -4
- package/dist/daemon.js +469 -857
- package/dist/db-JH3NTERX.js +36 -0
- package/dist/delivery-manager-H23Q23N7.js +41 -0
- package/dist/{delivery-notices-65TB3CBX.js → delivery-notices-S7TKOACE.js} +5 -5
- package/dist/{delivery-router-RHANNTAC.js → delivery-router-KHBVEVVL.js} +4 -2
- package/dist/{down-QSNEMTTX.js → down-UN2EDX2Y.js} +3 -3
- package/dist/echo-text-ULEIFJJF.js +40 -0
- package/dist/{exec-ABD3DYA2.js → exec-Z34TTUO3.js} +1 -1
- package/dist/extensions-QD22GXZD.js +74 -0
- package/dist/{files-CHPNEQXN.js → files-DUGRXN6V.js} +5 -5
- package/dist/{import-DB2KULRO.js → import-RDDUGGFI.js} +3 -3
- package/dist/{isolation-GVV2IBAU.js → isolation-UQPDO34R.js} +1 -1
- package/dist/message-delivery-LU4EP244.js +46 -0
- package/dist/migrate-init-infrastructure-Z72UJ5NT.js +44 -0
- package/dist/migrate-name-placeholder-64AEAWPE.js +37 -0
- package/dist/{migrate-thread-config-EQSEP2XG.js → migrate-thread-config-SVF2M3D3.js} +1 -1
- package/dist/{mind-IX5AWUST.js → mind-BARCLI7O.js} +14 -14
- package/dist/{mind-activity-tracker-QF6DCX2X.js → mind-activity-tracker-UT23RGBF.js} +3 -3
- package/dist/{mind-contacts-BKUOUJPV.js → mind-contacts-PEXCDRXA.js} +8 -8
- package/dist/{mind-list-KNU34SUD.js → mind-list-7KBLPLES.js} +2 -2
- package/dist/mind-service-ZJCUBAUL.js +48 -0
- package/dist/{mind-sleep-D3DEYAWG.js → mind-sleep-24HV4RCA.js} +5 -5
- package/dist/{mind-status-BPTLQ5Z6.js → mind-status-4YW4PHMO.js} +6 -3
- package/dist/{package-BN5KMU54.js → package-AUDBDKAO.js} +12 -14
- package/dist/{prompts-HNKGCAEY.js → prompts-MOBF3TMO.js} +1 -1
- package/dist/{sandbox-NNWCTHRI.js → sandbox-VI7XWBIA.js} +2 -2
- package/dist/scheduler-GLINVC2B.js +27 -0
- package/dist/{seed-LHZ33UB4.js → seed-5JGR7ZRA.js} +1 -1
- package/dist/{seed-cmd-U4RVJ3UA.js → seed-cmd-M6A7N7F3.js} +2 -2
- package/dist/{seed-create-OQU4KNCB.js → seed-create-TGFZW4IO.js} +3 -3
- package/dist/{seed-readiness-WJYYRRCK.js → seed-readiness-XBH2MUNA.js} +2 -2
- package/dist/{seed-sprout-SFUYH2RG.js → seed-sprout-3IC5ZVTI.js} +11 -11
- package/dist/{send-FWZAMDBN.js → send-DHYBAYK4.js} +11 -11
- 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-RVSGCIF3.js → setup-6KAH3GRW.js} +4 -4
- package/dist/skills/dreaming/SKILL.md +2 -2
- package/dist/skills/dreaming/references/INSTALL.md +1 -1
- package/dist/skills/tending/SKILL.md +2 -0
- package/dist/skills/volute-mind/SKILL.md +8 -0
- package/dist/skills/volute-mind/references/integrations.md +3 -2
- package/dist/skills/volute-mind/references/routing.md +18 -5
- package/dist/{skills-QRPU6BWY.js → skills-LKQCYFVP.js} +3 -3
- package/dist/sleep-manager-Q3ITFNOJ.js +42 -0
- package/dist/{spirit-I5ECLGRI.js → spirit-FKXMGO3N.js} +8 -6
- package/dist/spirit-availability-5R2WYSWF.js +43 -0
- package/dist/{sprout-A2PMEF7Z.js → sprout-VEW3CUNX.js} +1 -1
- package/dist/src-246GSX7A.js +519 -0
- package/dist/src-LOUB3WPR.js +4189 -0
- package/dist/{status-EYG4A3OK.js → status-LCKTTIWS.js} +5 -5
- package/dist/{system-events-YTOU66QI.js → system-events-ZYOCYAO2.js} +5 -3
- package/dist/{systems-2DIJY4RF.js → systems-HL7SGSW7.js} +6 -6
- package/dist/{tailscale-KHNZFC7L.js → tailscale-F2IMBILZ.js} +1 -1
- package/dist/{template-hash-YKWMVYMM.js → template-hash-RJRMF6DT.js} +2 -2
- package/dist/{template-staleness-PY2CULH6.js → template-staleness-IBD722PN.js} +4 -4
- package/dist/{turn-tracker-NNHS6MAR.js → turn-tracker-BE34OI6O.js} +7 -7
- package/dist/{up-UGBHH743.js → up-J47J5JRB.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-JGJZ5KB4.js +24 -0
- package/dist/{version-notify-SJVAQLZI.js → version-notify-KIXYNHJC.js} +28 -13
- package/dist/web-assets/assets/{index-b7G7ZsVk.js → index-CoU1eUKE.js} +25 -25
- package/dist/web-assets/assets/index-hPn_fkz3.css +1 -0
- package/dist/web-assets/index.html +2 -2
- package/package.json +12 -14
- package/packages/extensions/intentions/dist/ui/assets/index-3FneLCKR.css +1 -0
- package/packages/extensions/intentions/dist/ui/assets/index-Bl5KEcli.js +67 -0
- package/packages/extensions/{notes → 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/dist/ui/assets/index-BKxVQkA9.css +1 -0
- package/packages/extensions/pages/dist/ui/assets/index-BMt6sStP.js +68 -0
- package/packages/extensions/pages/dist/ui/index.html +2 -2
- package/packages/extensions/pages/skills/commons-gardening/SKILL.md +50 -0
- package/packages/extensions/pages/skills/pages/SKILL.md +140 -16
- package/templates/_base/.init/.config/prompts.json +2 -3
- package/templates/{pi → _base}/.init/.config/routes.json +1 -0
- package/templates/_base/.init/.local/hooks/pre-prompt/turn-context.ts +30 -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 +6 -55
- package/templates/_base/src/lib/context-breakdown.ts +11 -3
- package/templates/_base/src/lib/pi-session-seed.ts +8 -57
- package/templates/_base/src/lib/routing.ts +2 -2
- package/templates/_base/src/lib/seed-note.ts +4 -4
- package/templates/_base/src/lib/session-seed.ts +8 -53
- package/templates/_base/src/lib/startup.ts +4 -22
- package/templates/claude/.init/CLAUDE.md +4 -0
- package/templates/claude/package.json +2 -2
- package/templates/claude/src/agent.ts +60 -112
- package/templates/claude/src/lib/hooks/pre-compact.ts +6 -6
- 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 +23 -5
- package/templates/claude/src/server.ts +0 -1
- package/templates/claude/volute-template.json +1 -1
- package/templates/codex/.init/AGENTS.md +4 -0
- package/templates/codex/package.json +2 -2
- package/templates/codex/src/agent.ts +23 -89
- package/templates/codex/volute-template.json +1 -1
- package/templates/pi/.init/MINDS.md +4 -0
- package/templates/pi/package.json +1 -1
- package/templates/pi/src/agent.ts +26 -93
- package/templates/pi/src/lib/mechanics-doc.ts +30 -0
- package/templates/pi/src/server.ts +3 -2
- package/templates/pi/volute-template.json +1 -1
- package/dist/channels-LW5ZLMSQ.js +0 -90
- package/dist/chunk-R772ET2M.js +0 -164
- package/dist/db-IPXRJUI7.js +0 -20
- package/dist/delivery-manager-FDHCZ7PU.js +0 -37
- package/dist/echo-text-KU6G4Z7V.js +0 -40
- package/dist/extensions-VACUKFA5.js +0 -60
- package/dist/message-delivery-TUXM3AAE.js +0 -46
- package/dist/mind-service-WHFBUKZE.js +0 -48
- package/dist/scheduler-5X5UIQOQ.js +0 -27
- package/dist/skills/plan-coordinator/SKILL.md +0 -60
- package/dist/sleep-manager-NWUCRBZB.js +0 -42
- package/dist/spirit-availability-NIUUYKU2.js +0 -43
- package/dist/src-HGNE2XG4.js +0 -832
- package/dist/src-LCSOQY6Z.js +0 -2222
- package/dist/src-UDMJZ6MH.js +0 -426
- package/dist/variant-cleanup-CZDLIY3F.js +0 -24
- package/dist/web-assets/assets/index-BHSU9r-4.css +0 -1
- package/packages/extensions/notes/dist/ui/assets/index-BBs8pyel.js +0 -67
- package/packages/extensions/notes/dist/ui/assets/index-D3cdui42.css +0 -1
- package/packages/extensions/notes/skills/notes/SKILL.md +0 -90
- package/packages/extensions/pages/dist/ui/assets/index-B589e8Xe.js +0 -2
- package/packages/extensions/pages/dist/ui/assets/index-BuXnE-rb.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/dist/ui/index.html +0 -14
- 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/{accept-I4GVGWA3.js → accept-RSDIVJL6.js} +3 -3
- package/dist/{bridge-KU2IKSH3.js → bridge-YK7NTRU5.js} +3 -3
- package/dist/{create-C3XZACTV.js → create-M727VUDF.js} +3 -3
- package/dist/{env-BJGM6FBA.js → env-7AZFBPWX.js} +7 -7
- package/dist/{extension-B2W52WZY.js → extension-MRYJWSMV.js} +3 -3
- package/dist/{list-R2GDWOEE.js → list-UQGIW7OF.js} +3 -3
- package/dist/{login-EZ4RRDQX.js → login-4NUS72Z5.js} +3 -3
- package/dist/{login-JDOAK465.js → login-57MMYILL.js} +4 -4
- package/dist/{logout-MWSIUOQU.js → logout-BYV433X6.js} +3 -3
- package/dist/{logout-K6ZGAJY5.js → logout-YFZAJRPY.js} +3 -3
- package/dist/{mind-history-RVF6KJ7K.js → mind-history-RZMMCHMX.js} +9 -9
- package/dist/{mind-wake-DCDFFQKX.js → mind-wake-LDGPRA42.js} +3 -3
- package/dist/{read-J5RM674N.js → read-RJ5NAW5J.js} +3 -3
- package/dist/{register-53LBGSDC.js → register-WNCSOTAU.js} +3 -3
- package/dist/{reject-K2A4UK2Y.js → reject-4NFLXKRN.js} +3 -3
- package/dist/{restart-R2WFNF5K.js → restart-B4GNMVF2.js} +7 -7
- package/dist/{skill-DXLLSUHB.js → skill-QYTBUDNQ.js} +7 -7
- package/dist/{start-XDOUAWN3.js → start-BDHJVOVX.js} +6 -6
- package/dist/{stop-O62RTEMD.js → stop-45QYSGYS.js} +7 -7
|
@@ -40,7 +40,6 @@ export type EffortLevel = "low" | "medium" | "high" | "xhigh" | "max";
|
|
|
40
40
|
export type MindConfig = {
|
|
41
41
|
model?: string;
|
|
42
42
|
logLevel?: "error" | "warn" | "info" | "debug";
|
|
43
|
-
compactionMessage?: string;
|
|
44
43
|
compaction?: { maxContextTokens?: number };
|
|
45
44
|
/** Idle minutes before a session's SDK subprocess is reaped. 0 disables. Default 30. */
|
|
46
45
|
sessionIdleMinutes?: number;
|
|
@@ -103,21 +102,6 @@ export function memorySizeLabel(): string {
|
|
|
103
102
|
return formatTokens(estimateTokens(loadFile(resolve("home/MEMORY.md")).length));
|
|
104
103
|
}
|
|
105
104
|
|
|
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
105
|
function headAtLineBoundary(text: string, maxChars: number): string {
|
|
122
106
|
const cut = text.lastIndexOf("\n", maxChars);
|
|
123
107
|
// Prefer a whole-line cut, but not when the nearest newline is so far back
|
|
@@ -251,7 +235,6 @@ export async function getStartupContext(): Promise<string | null> {
|
|
|
251
235
|
}
|
|
252
236
|
|
|
253
237
|
export type MindPrompts = {
|
|
254
|
-
compaction_warning: string;
|
|
255
238
|
compaction_instructions: string;
|
|
256
239
|
reply_instructions: string;
|
|
257
240
|
event_instructions: string;
|
|
@@ -264,13 +247,12 @@ export type MindPrompts = {
|
|
|
264
247
|
* (the copy the Prompt Library UI edits); test/prompts.test.ts fails if they drift.
|
|
265
248
|
*/
|
|
266
249
|
export const DEFAULT_PROMPTS: MindPrompts = {
|
|
267
|
-
compaction_warning:
|
|
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).",
|
|
270
250
|
compaction_instructions:
|
|
271
251
|
"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.",
|
|
252
|
+
// Quoted: `${channel}` is `#garden` for a Volute channel, and an unquoted `#` starts a
|
|
253
|
+
// shell comment, so the target would be stripped before the CLI saw it.
|
|
272
254
|
// biome-ignore lint/suspicious/noTemplateCurlyInString: literal ${channel} prompt template
|
|
273
|
-
reply_instructions: 'To reply to this message, use: volute chat send ${channel} "your message"',
|
|
255
|
+
reply_instructions: 'To reply to this message, use: volute chat send "${channel}" "your message"',
|
|
274
256
|
event_instructions:
|
|
275
257
|
"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.",
|
|
276
258
|
channel_invite: `[Channel Invite]
|
|
@@ -283,7 +265,7 @@ Further messages will be saved to \${filePath}
|
|
|
283
265
|
|
|
284
266
|
To accept, add to .config/routes.json:
|
|
285
267
|
Rule: { "channel": "\${channel}", "session": "\${suggestedSession}" }
|
|
286
|
-
\${batchRecommendation}To respond, use: volute chat send \${channel} "your message"
|
|
268
|
+
\${batchRecommendation}To respond, use: volute chat send "\${channel}" "your message"
|
|
287
269
|
To reject, delete \${filePath}`,
|
|
288
270
|
};
|
|
289
271
|
|
|
@@ -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:
|
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
"typecheck": "tsc --noEmit"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@anthropic-ai/claude-agent-sdk": "^0.3.
|
|
12
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.217",
|
|
13
13
|
"@anthropic-ai/tokenizer": "^0.0.4",
|
|
14
14
|
"tsx": "^4.23.1"
|
|
15
15
|
},
|
|
16
16
|
"devDependencies": {
|
|
17
|
-
"@biomejs/biome": "2.5.
|
|
17
|
+
"@biomejs/biome": "2.5.5",
|
|
18
18
|
"@types/node": "^26.1.1",
|
|
19
19
|
"typescript": "^6.0.3"
|
|
20
20
|
}
|
|
@@ -14,7 +14,6 @@ import {
|
|
|
14
14
|
readSkillDescriptions,
|
|
15
15
|
} from "./lib/context-breakdown.js";
|
|
16
16
|
import { daemonEmit, daemonNotice } from "./lib/daemon-client.js";
|
|
17
|
-
import { compactTimestamp } from "./lib/format-prefix.js";
|
|
18
17
|
import { runHooks } from "./lib/hook-loader.js";
|
|
19
18
|
import { createAutoCommitHook } from "./lib/hooks/auto-commit.js";
|
|
20
19
|
import { createIdentityReloadHook } from "./lib/hooks/identity-reload.js";
|
|
@@ -23,23 +22,17 @@ import { createPreCompactHook } from "./lib/hooks/pre-compact.js";
|
|
|
23
22
|
import { createReplyInstructionsHook } from "./lib/hooks/reply-instructions.js";
|
|
24
23
|
import { log } from "./lib/logger.js";
|
|
25
24
|
import { createMessageChannel } from "./lib/message-channel.js";
|
|
25
|
+
import { relockstepMessageIds } from "./lib/recover.js";
|
|
26
26
|
import { buildSeededNote, type SeedCause } from "./lib/seed-note.js";
|
|
27
27
|
import {
|
|
28
28
|
isSessionReapable,
|
|
29
29
|
reapSessionQuery,
|
|
30
30
|
reapSessionsForShutdown,
|
|
31
31
|
} from "./lib/session-reaper.js";
|
|
32
|
-
import {
|
|
33
|
-
computeSeedCut,
|
|
34
|
-
DEFAULT_SEED_TOKENS,
|
|
35
|
-
rotateSession,
|
|
36
|
-
type SeedCut,
|
|
37
|
-
seedSession,
|
|
38
|
-
} from "./lib/session-seed.js";
|
|
32
|
+
import { DEFAULT_SEED_TOKENS, rotateSession, seedSession } from "./lib/session-seed.js";
|
|
39
33
|
import { createSessionStore } from "./lib/session-store.js";
|
|
40
|
-
import type { EffortLevel, ThinkingConfig } from "./lib/startup.js";
|
|
41
|
-
import {
|
|
42
|
-
import { consumeStream } from "./lib/stream-consumer.js";
|
|
34
|
+
import type { EffortLevel, SubagentConfig, ThinkingConfig } from "./lib/startup.js";
|
|
35
|
+
import { consumeStream, type MessageIdEntry } from "./lib/stream-consumer.js";
|
|
43
36
|
import type {
|
|
44
37
|
HandlerMeta,
|
|
45
38
|
HandlerResolver,
|
|
@@ -54,8 +47,9 @@ type Session = {
|
|
|
54
47
|
name: string;
|
|
55
48
|
channel: ReturnType<typeof createMessageChannel>;
|
|
56
49
|
listeners: Set<Listener>;
|
|
57
|
-
messageIds:
|
|
50
|
+
messageIds: MessageIdEntry[];
|
|
58
51
|
currentMessageId?: string;
|
|
52
|
+
currentSeq?: number;
|
|
59
53
|
currentQuery?: ReturnType<typeof query>;
|
|
60
54
|
messageChannels: Map<string, { channel: string; sender?: string }>;
|
|
61
55
|
replyInstructionsFired: boolean;
|
|
@@ -75,12 +69,6 @@ type Session = {
|
|
|
75
69
|
seededArchivedAt: number | null;
|
|
76
70
|
/** Why the tail is seeded — picks the boundary note's wording. Last cause wins. */
|
|
77
71
|
seededCause: SeedCause;
|
|
78
|
-
/**
|
|
79
|
-
* The cut point pinned at warn time (the first-kept turn the mind was told would
|
|
80
|
-
* survive verbatim). Honored at rotation so the promise in the warning holds even
|
|
81
|
-
* though the transcript grows after the warning. Null until a warning fires.
|
|
82
|
-
*/
|
|
83
|
-
rotationCut: SeedCut | null;
|
|
84
72
|
/**
|
|
85
73
|
* Back-to-back rotations that did NOT bring context under the threshold (reset by
|
|
86
74
|
* any healthy turn). Guards against a runaway loop when the tail alone can't fit —
|
|
@@ -89,20 +77,15 @@ type Session = {
|
|
|
89
77
|
*/
|
|
90
78
|
consecutiveRotations: number;
|
|
91
79
|
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* the
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* - "rotateAfterTurn": the wrap-up turn is the current/next turn; rotate when it ends.
|
|
98
|
-
* Lives on the session (not a map) so it can't leak into a resumed session if the
|
|
99
|
-
* stream dies mid-flight — a fresh session starts idle.
|
|
80
|
+
* True when this session crossed the context threshold (or the SDK tried to
|
|
81
|
+
* auto-compact) and should rotate in place when the current turn ends. Rotation
|
|
82
|
+
* is deliberately silent: no warning, no wrap-up turn — the auto summarizer's
|
|
83
|
+
* turn summaries are the record of collapsed turns, and the one-line
|
|
84
|
+
* ROTATED_SESSION_NOTE marks the boundary on the next turn.
|
|
100
85
|
*/
|
|
101
|
-
|
|
86
|
+
rotationPending: boolean;
|
|
102
87
|
};
|
|
103
88
|
|
|
104
|
-
type RotationPhase = "warned" | "rotateAfterTurn";
|
|
105
|
-
|
|
106
89
|
/** Stop self-rotating after this many back-to-back rotations that didn't reduce context. */
|
|
107
90
|
const MAX_CONSECUTIVE_ROTATIONS = 3;
|
|
108
91
|
|
|
@@ -114,7 +97,6 @@ export function createMind(options: {
|
|
|
114
97
|
thinking?: ThinkingConfig;
|
|
115
98
|
effort?: EffortLevel;
|
|
116
99
|
sessionsDir: string;
|
|
117
|
-
compactionMessage?: string;
|
|
118
100
|
maxContextTokens?: number;
|
|
119
101
|
subagents?: Record<string, SubagentConfig>;
|
|
120
102
|
onIdentityReload?: () => Promise<void>;
|
|
@@ -137,9 +119,6 @@ export function createMind(options: {
|
|
|
137
119
|
];
|
|
138
120
|
|
|
139
121
|
const sessions = new Map<string, Session>();
|
|
140
|
-
const prompts = loadPrompts();
|
|
141
|
-
const compactionMessage = () =>
|
|
142
|
-
options.compactionMessage ?? renderCompactionWarning(prompts.compaction_warning);
|
|
143
122
|
const maxContextTokens = options.maxContextTokens;
|
|
144
123
|
const seedTokens = options.seedTokens ?? DEFAULT_SEED_TOKENS;
|
|
145
124
|
|
|
@@ -394,60 +373,19 @@ export function createMind(options: {
|
|
|
394
373
|
let currentSessionId = savedSessionId;
|
|
395
374
|
let streamAbort = new AbortController();
|
|
396
375
|
|
|
397
|
-
/**
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
* from the live transcript, pins the first-kept turn (so rotation honors the
|
|
401
|
-
* same boundary even as the transcript grows), interpolates its time into the
|
|
402
|
-
* `${cutoff}` placeholder, enters the rotation state machine, and pushes the
|
|
403
|
-
* warning as the wrap-up prompt. Shared by the threshold and PreCompact paths.
|
|
404
|
-
*
|
|
405
|
-
* Crucially it does NOT abort here: the mind takes a wrap-up turn (reading the
|
|
406
|
-
* pinned cutoff and authoring `volute mind history` summaries with full context
|
|
407
|
-
* of the turns being collapsed) before rotation. The entry phase depends on
|
|
408
|
-
* whether a turn is mid-flight: if one is (threshold path fires on an assistant
|
|
409
|
-
* message), it must finish before the queued warning runs, so we start "warned";
|
|
410
|
-
* if we're between turns (PreCompact fired at a boundary), the warning IS the
|
|
411
|
-
* next turn, so we start "rotateAfterTurn".
|
|
412
|
-
*/
|
|
413
|
-
function warnAndPinRotation() {
|
|
414
|
-
let cutoffLabel = "the cutoff";
|
|
415
|
-
session.rotationCut = null;
|
|
416
|
-
if (currentSessionId) {
|
|
417
|
-
try {
|
|
418
|
-
const path = findClaudeSessionFile(options.cwd, currentSessionId);
|
|
419
|
-
const cut = path ? computeSeedCut(readFileSync(path, "utf-8"), seedTokens) : null;
|
|
420
|
-
if (cut) {
|
|
421
|
-
session.rotationCut = cut;
|
|
422
|
-
if (cut.boundaryTimestamp) {
|
|
423
|
-
const d = new Date(cut.boundaryTimestamp);
|
|
424
|
-
if (!Number.isNaN(d.getTime())) cutoffLabel = compactTimestamp(d);
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
} catch (err) {
|
|
428
|
-
log("mind", `session "${session.name}": failed to pin rotation cut:`, err);
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
// biome-ignore lint/suspicious/noTemplateCurlyInString: literal ${cutoff} prompt placeholder
|
|
432
|
-
const warning = compactionMessage().replaceAll("${cutoff}", cutoffLabel);
|
|
433
|
-
session.rotationPhase =
|
|
434
|
-
session.currentMessageId !== undefined ? "warned" : "rotateAfterTurn";
|
|
435
|
-
session.messageIds.push(undefined);
|
|
436
|
-
session.channel.push({
|
|
437
|
-
type: "user",
|
|
438
|
-
session_id: "",
|
|
439
|
-
message: { role: "user", content: [{ type: "text", text: warning }] },
|
|
440
|
-
parent_tool_use_id: null,
|
|
441
|
-
});
|
|
376
|
+
/** Mark the session to rotate in place when the current turn ends. */
|
|
377
|
+
function scheduleRotation() {
|
|
378
|
+
session.rotationPending = true;
|
|
442
379
|
}
|
|
443
380
|
|
|
444
|
-
// PreCompact backstop: first fire
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
//
|
|
381
|
+
// PreCompact backstop: first fire schedules a rotation (unless one is already
|
|
382
|
+
// pending, or the rotation cap is hit) and blocks; second fire allows the SDK's
|
|
383
|
+
// native compaction — the emergency backstop when rotation never intervened
|
|
384
|
+
// (a hung turn, or a system prompt too large for rotation to relieve).
|
|
448
385
|
const preCompact = createPreCompactHook(() => {
|
|
449
|
-
if (!session.
|
|
450
|
-
|
|
386
|
+
if (!session.rotationPending && session.consecutiveRotations < MAX_CONSECUTIVE_ROTATIONS) {
|
|
387
|
+
log("mind", `session "${session.name}": native compaction — scheduling rotation`);
|
|
388
|
+
scheduleRotation();
|
|
451
389
|
}
|
|
452
390
|
});
|
|
453
391
|
|
|
@@ -457,10 +395,11 @@ export function createMind(options: {
|
|
|
457
395
|
if (!session.name.startsWith("new-")) sessionStore.save(session.name, id);
|
|
458
396
|
},
|
|
459
397
|
broadcast: (event: VoluteEvent) => broadcastToSession(session, event),
|
|
398
|
+
// Identity-based ack — stream-consumer.ts calls this once per message the
|
|
399
|
+
// just-finished turn covers (its own driving message, plus any folded in
|
|
400
|
+
// mid-run) so none of them strand in the channel's in-flight set (#764).
|
|
401
|
+
ack: (seq: number) => session.channel.ack(seq),
|
|
460
402
|
onTurnEnd: async () => {
|
|
461
|
-
// This turn's message is fully processed — drop it from the channel's
|
|
462
|
-
// in-flight set so a later compaction abort won't re-feed it.
|
|
463
|
-
session.channel.ack();
|
|
464
403
|
session.lastActivityAt = Date.now();
|
|
465
404
|
// A turn resolved — the seeded note's injection had its chance to land
|
|
466
405
|
// (the pre-prompt hook ran and wasn't cancelled by an interrupt), so stop
|
|
@@ -468,13 +407,12 @@ export function createMind(options: {
|
|
|
468
407
|
// the closest-to-arrival signal available without reading the transcript back.
|
|
469
408
|
session.seeded = false;
|
|
470
409
|
await autoCommit.flushFileChanges();
|
|
471
|
-
if (session.
|
|
472
|
-
// The
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
log("mind", `session "${session.name}": wrap-up turn done — aborting to rotate`);
|
|
410
|
+
if (session.rotationPending) {
|
|
411
|
+
// The turn that crossed the threshold is done — abort to rotate in place.
|
|
412
|
+
log(
|
|
413
|
+
"mind",
|
|
414
|
+
`session "${session.name}": turn ended over context limit — aborting to rotate`,
|
|
415
|
+
);
|
|
478
416
|
streamAbort.abort(new CompactionAbort());
|
|
479
417
|
} else {
|
|
480
418
|
// A healthy turn (context under the threshold) — the rotation streak, if
|
|
@@ -488,14 +426,14 @@ export function createMind(options: {
|
|
|
488
426
|
if (
|
|
489
427
|
maxContextTokens &&
|
|
490
428
|
tokens >= maxContextTokens &&
|
|
491
|
-
!session.
|
|
429
|
+
!session.rotationPending &&
|
|
492
430
|
session.consecutiveRotations < MAX_CONSECUTIVE_ROTATIONS
|
|
493
431
|
) {
|
|
494
432
|
log(
|
|
495
433
|
"mind",
|
|
496
|
-
`session "${session.name}": ${tokens} tokens >= ${maxContextTokens} —
|
|
434
|
+
`session "${session.name}": ${tokens} tokens >= ${maxContextTokens} — rotation pending at turn end`,
|
|
497
435
|
);
|
|
498
|
-
|
|
436
|
+
scheduleRotation();
|
|
499
437
|
}
|
|
500
438
|
},
|
|
501
439
|
};
|
|
@@ -543,17 +481,15 @@ export function createMind(options: {
|
|
|
543
481
|
currentSessionId
|
|
544
482
|
) {
|
|
545
483
|
// Stream was aborted to rotate: replace the session with a synthetic one
|
|
546
|
-
// holding the verbatim recent tail (
|
|
484
|
+
// holding the verbatim recent tail (the seedTokens-budget tail), then resume it.
|
|
547
485
|
const rotatedId = rotateSession({
|
|
548
486
|
cwd: options.cwd,
|
|
549
487
|
sessionsDir: options.sessionsDir,
|
|
550
488
|
name: session.name,
|
|
551
489
|
oldSessionId: currentSessionId,
|
|
552
|
-
cut: session.rotationCut,
|
|
553
490
|
seedTokens,
|
|
554
491
|
});
|
|
555
|
-
session.
|
|
556
|
-
session.rotationPhase = undefined; // state machine consumed
|
|
492
|
+
session.rotationPending = false;
|
|
557
493
|
if (!rotatedId) {
|
|
558
494
|
// Rotation couldn't proceed — fall back to a fresh session (no seed).
|
|
559
495
|
// Unlike a successful rotation (boundary note, verbatim tail, archived
|
|
@@ -599,11 +535,19 @@ export function createMind(options: {
|
|
|
599
535
|
preCompact.reset();
|
|
600
536
|
streamAbort = new AbortController();
|
|
601
537
|
// Recover input the aborted stream had already pulled but not finished
|
|
602
|
-
// (
|
|
603
|
-
//
|
|
538
|
+
// (anything that arrived mid-turn) so nothing is dropped when the killed
|
|
539
|
+
// subprocess takes its buffer with it.
|
|
604
540
|
const pending = session.channel.recover();
|
|
541
|
+
const oldMessageIds = session.messageIds;
|
|
605
542
|
session.channel = createMessageChannel();
|
|
606
|
-
|
|
543
|
+
// Starts from [] — nothing can race into this fresh channel/messageIds
|
|
544
|
+
// before the next line runs (single-threaded, no await in between).
|
|
545
|
+
session.messageIds = relockstepMessageIds(
|
|
546
|
+
pending,
|
|
547
|
+
oldMessageIds,
|
|
548
|
+
session.channel.push,
|
|
549
|
+
[],
|
|
550
|
+
);
|
|
607
551
|
continue; // restart the stream loop on the rotated session
|
|
608
552
|
}
|
|
609
553
|
throw err; // rethrow non-compaction errors
|
|
@@ -626,8 +570,7 @@ export function createMind(options: {
|
|
|
626
570
|
// conversation continued when the seeded transcript failed to resume,
|
|
627
571
|
// and don't let a half-entered rotation leak into the fresh session.
|
|
628
572
|
session.seeded = false;
|
|
629
|
-
session.
|
|
630
|
-
session.rotationCut = null;
|
|
573
|
+
session.rotationPending = false;
|
|
631
574
|
streamAbort = new AbortController();
|
|
632
575
|
session.channel = createMessageChannel();
|
|
633
576
|
try {
|
|
@@ -671,8 +614,8 @@ export function createMind(options: {
|
|
|
671
614
|
seeded: false,
|
|
672
615
|
seededArchivedAt: null,
|
|
673
616
|
seededCause: "restored",
|
|
674
|
-
rotationCut: null,
|
|
675
617
|
consecutiveRotations: 0,
|
|
618
|
+
rotationPending: false,
|
|
676
619
|
};
|
|
677
620
|
sessions.set(name, session);
|
|
678
621
|
|
|
@@ -749,11 +692,16 @@ export function createMind(options: {
|
|
|
749
692
|
log("mind", `session "${session.name}": error reaping SDK subprocess:`, err),
|
|
750
693
|
);
|
|
751
694
|
// Nothing should have raced in (isSessionReapable checked isEmpty), but if it
|
|
752
|
-
// did, re-dispatch into a fresh session so no input is dropped.
|
|
695
|
+
// did, re-dispatch into a fresh session so no input is dropped. Same marker
|
|
696
|
+
// treatment as the rotation path (#764) — see relockstepMessageIds above.
|
|
697
|
+
// getOrCreateSession() can return a session an inbound message already raced
|
|
698
|
+
// into during the reapSessionQuery() await above (it pushed its own entry into
|
|
699
|
+
// fresh.messageIds via the normal handler path) — relockstepMessageIds appends
|
|
700
|
+
// onto fresh.messageIds rather than replacing it, so that entry survives.
|
|
753
701
|
const pending = session.channel.recover();
|
|
754
702
|
if (pending.length > 0) {
|
|
755
703
|
const fresh = getOrCreateSession(session.name);
|
|
756
|
-
|
|
704
|
+
relockstepMessageIds(pending, session.messageIds, fresh.channel.push, fresh.messageIds);
|
|
757
705
|
}
|
|
758
706
|
}
|
|
759
707
|
|
|
@@ -782,7 +730,7 @@ export function createMind(options: {
|
|
|
782
730
|
s,
|
|
783
731
|
now,
|
|
784
732
|
idleTimeoutMs,
|
|
785
|
-
(name) => sessions.get(name)?.
|
|
733
|
+
(name) => sessions.get(name)?.rotationPending === true,
|
|
786
734
|
),
|
|
787
735
|
);
|
|
788
736
|
// Reaps run independently; each awaits its own subprocess exit internally.
|
|
@@ -841,13 +789,13 @@ export function createMind(options: {
|
|
|
841
789
|
|
|
842
790
|
// Push message into SDK
|
|
843
791
|
session.lastActivityAt = Date.now();
|
|
844
|
-
session.
|
|
845
|
-
session.channel.push({
|
|
792
|
+
const seq = session.channel.push({
|
|
846
793
|
type: "user",
|
|
847
794
|
session_id: "",
|
|
848
795
|
message: { role: "user", content: toSDKContent(content) },
|
|
849
796
|
parent_tool_use_id: null,
|
|
850
797
|
});
|
|
798
|
+
session.messageIds.push({ id: meta.messageId, seq });
|
|
851
799
|
|
|
852
800
|
return () => {
|
|
853
801
|
if (filteredListener) session.listeners.delete(filteredListener);
|
|
@@ -8,9 +8,9 @@ import { log } from "../logger.js";
|
|
|
8
8
|
* session in place. This hook covers the case where the SDK decides to auto-compact
|
|
9
9
|
* before (or instead of) that threshold firing:
|
|
10
10
|
*
|
|
11
|
-
* - First fire: run `onCompact` (
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* - First fire: run `onCompact` (schedule a rotation — mark the session to rotate
|
|
12
|
+
* at turn end) and BLOCK the SDK's native compaction, so the session rotates
|
|
13
|
+
* instead of being natively compacted.
|
|
14
14
|
* - Second fire: allow the SDK's native compaction as an emergency backstop —
|
|
15
15
|
* only reachable if rotation never happened (a hung turn, or rotation failed).
|
|
16
16
|
*
|
|
@@ -32,10 +32,10 @@ export function createPreCompactHook(onCompact: () => void) {
|
|
|
32
32
|
return {};
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
// Auto-compaction: first pass
|
|
36
|
-
//
|
|
35
|
+
// Auto-compaction: first pass schedules a rotation and blocks; second pass
|
|
36
|
+
// allows the native compaction as the emergency backstop.
|
|
37
37
|
if (!compactBlocked) {
|
|
38
|
-
log("mind", "blocking auto-compaction —
|
|
38
|
+
log("mind", "blocking auto-compaction — marking session for rotation");
|
|
39
39
|
try {
|
|
40
40
|
onCompact();
|
|
41
41
|
compactBlocked = true;
|
|
@@ -1,16 +1,34 @@
|
|
|
1
1
|
import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
|
2
2
|
|
|
3
|
+
/** A queued/in-flight message paired with the sequence number that identifies it. */
|
|
4
|
+
export type ChannelEntry = { msg: SDKUserMessage; seq: number };
|
|
5
|
+
|
|
6
|
+
// Module-scoped, not per-channel: rotation and the idle reaper both replace a
|
|
7
|
+
// session's channel with a fresh instance while carrying seqs forward (relocking
|
|
8
|
+
// old entries to new ones — see relockstepMessageIds in recover.ts). A per-instance
|
|
9
|
+
// counter would restart at 0 on every fresh channel, so a seq minted by one channel
|
|
10
|
+
// generation could collide with one minted by another; a single global counter makes
|
|
11
|
+
// every seq unique for the process's lifetime, so a stale seq can never be mistaken
|
|
12
|
+
// for a different message.
|
|
13
|
+
let nextSeq = 0;
|
|
14
|
+
|
|
3
15
|
export type MessageChannel = {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
16
|
+
/** Returns the sequence number minted for this message — the identity `ack()` needs. */
|
|
17
|
+
push: (msg: SDKUserMessage) => number;
|
|
18
|
+
/**
|
|
19
|
+
* Acknowledge that the message with this `seq` has completed its turn. Identity-based
|
|
20
|
+
* (not positional): the SDK folds messages that arrive mid-run into the active run, so
|
|
21
|
+
* a turn's `result` can cover more than just the oldest in-flight message — see the
|
|
22
|
+
* caller in stream-consumer.ts.
|
|
23
|
+
*/
|
|
24
|
+
ack: (seq: number) => void;
|
|
7
25
|
/**
|
|
8
26
|
* Return every message that has not been fully processed — those delivered to
|
|
9
27
|
* the consumer but not yet acked (their turn never finished) followed by those
|
|
10
|
-
* still queued — and reset the channel. Used to re-feed
|
|
11
|
-
* compaction abort so in-flight input is never lost.
|
|
28
|
+
* still queued — paired with their `seq`, and reset the channel. Used to re-feed
|
|
29
|
+
* the stream after a compaction abort so in-flight input is never lost.
|
|
12
30
|
*/
|
|
13
|
-
recover: () =>
|
|
31
|
+
recover: () => ChannelEntry[];
|
|
14
32
|
/**
|
|
15
33
|
* Terminate the input iterable: resolve any pending `next()` with `done: true`
|
|
16
34
|
* and make subsequent `next()` calls return done. The SDK sees end-of-input and
|
|
@@ -23,33 +41,43 @@ export type MessageChannel = {
|
|
|
23
41
|
};
|
|
24
42
|
|
|
25
43
|
export function createMessageChannel(): MessageChannel {
|
|
26
|
-
const queue:
|
|
44
|
+
const queue: ChannelEntry[] = [];
|
|
27
45
|
// Messages handed to the consumer (the SDK's read-ahead) whose turn has not yet
|
|
28
46
|
// completed. The SDK's streamInput loop pulls messages eagerly and writes them
|
|
29
47
|
// to the CLI subprocess, so once delivered they no longer live in `queue`. We
|
|
30
48
|
// retain them here so a compaction abort — which kills that subprocess — can
|
|
31
49
|
// re-feed the unprocessed ones instead of dropping them.
|
|
32
|
-
const inFlight:
|
|
50
|
+
const inFlight: ChannelEntry[] = [];
|
|
33
51
|
let resolve: ((value: IteratorResult<SDKUserMessage>) => void) | null = null;
|
|
34
52
|
let closed = false;
|
|
35
53
|
|
|
36
|
-
function deliver(
|
|
37
|
-
inFlight.push(
|
|
38
|
-
return { value: msg, done: false };
|
|
54
|
+
function deliver(entry: ChannelEntry): IteratorResult<SDKUserMessage> {
|
|
55
|
+
inFlight.push(entry);
|
|
56
|
+
return { value: entry.msg, done: false };
|
|
39
57
|
}
|
|
40
58
|
|
|
41
59
|
return {
|
|
42
60
|
push(msg: SDKUserMessage) {
|
|
61
|
+
const entry: ChannelEntry = { msg, seq: nextSeq++ };
|
|
43
62
|
if (resolve) {
|
|
44
63
|
const r = resolve;
|
|
45
64
|
resolve = null;
|
|
46
|
-
r(deliver(
|
|
65
|
+
r(deliver(entry));
|
|
47
66
|
} else {
|
|
48
|
-
queue.push(
|
|
67
|
+
queue.push(entry);
|
|
49
68
|
}
|
|
69
|
+
return entry.seq;
|
|
50
70
|
},
|
|
51
|
-
ack() {
|
|
52
|
-
inFlight.
|
|
71
|
+
ack(seq: number) {
|
|
72
|
+
let i = inFlight.findIndex((e) => e.seq === seq);
|
|
73
|
+
if (i !== -1) {
|
|
74
|
+
inFlight.splice(i, 1);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// Defensive: shouldn't normally be in the queue when acked, but remove it
|
|
78
|
+
// there too rather than leave a stranded entry if it somehow is.
|
|
79
|
+
i = queue.findIndex((e) => e.seq === seq);
|
|
80
|
+
if (i !== -1) queue.splice(i, 1);
|
|
53
81
|
},
|
|
54
82
|
recover() {
|
|
55
83
|
// Resolve any pending iterator wait with done:true so it doesn't
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
import type { ChannelEntry } from "./message-channel.js";
|
|
3
|
+
import type { MessageIdEntry } from "./stream-consumer.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `recover()` returns messages the channel can't prove were unprocessed — a genuinely
|
|
7
|
+
* unfinished turn looks identical to a stranded, already-completed one would have before
|
|
8
|
+
* #764's ack fix. Mark every re-pushed message so the mind can check its own record
|
|
9
|
+
* before redoing work: fail toward noise (a spurious note is cosmetic and self-correcting)
|
|
10
|
+
* rather than silence (a missing one is invisible and produces a false permanent record).
|
|
11
|
+
*/
|
|
12
|
+
export const RECOVERED_MESSAGE_NOTE =
|
|
13
|
+
"Note: this message is being redelivered after a session interruption. You may already " +
|
|
14
|
+
"have handled it — check your recent work (e.g. journal, files, messages you've already " +
|
|
15
|
+
"sent) before repeating anything.";
|
|
16
|
+
|
|
17
|
+
export function markRecovered(msg: SDKUserMessage): SDKUserMessage {
|
|
18
|
+
const content = msg.message.content;
|
|
19
|
+
const blocks = typeof content === "string" ? [{ type: "text" as const, text: content }] : content;
|
|
20
|
+
// A message can be recovered twice (consecutive rotations, up to
|
|
21
|
+
// MAX_CONSECUTIVE_ROTATIONS) — don't stack the note on each pass.
|
|
22
|
+
const first = blocks[0];
|
|
23
|
+
if (first && "type" in first && first.type === "text" && first.text === RECOVERED_MESSAGE_NOTE) {
|
|
24
|
+
return { ...msg, message: { ...msg.message, content: blocks } };
|
|
25
|
+
}
|
|
26
|
+
const marker = { type: "text" as const, text: RECOVERED_MESSAGE_NOTE };
|
|
27
|
+
return { ...msg, message: { ...msg.message, content: [marker, ...blocks] } };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Re-push `recover()`'s output into a fresh channel and append the result onto
|
|
32
|
+
* `existingMessageIds` — mutated in place and returned, rather than replacing it.
|
|
33
|
+
* That's not incidental: `existingMessageIds` may already hold an entry for a
|
|
34
|
+
* message that raced in and was pushed into the SAME fresh channel/messageIds
|
|
35
|
+
* while `recover()` was still pending (the idle reaper awaits the SDK subprocess's
|
|
36
|
+
* shutdown before recovering — see agent.ts). Returning a fresh array for the
|
|
37
|
+
* caller to assign over would silently discard that entry (#764) — appending
|
|
38
|
+
* in place makes that mistake impossible at the call site.
|
|
39
|
+
*
|
|
40
|
+
* Each entry's `id` carries over — matched by its *old* `seq` — to the *new* seq
|
|
41
|
+
* the fresh channel mints on push, so the next turn's currentMessageId/channel
|
|
42
|
+
* routing isn't corrupted by stale or missing ids. A `pending` entry with no
|
|
43
|
+
* match in `oldMessageIds` gets `id: undefined` (see the `idBySeq.get` fallback
|
|
44
|
+
* below) rather than throwing — safe because `seq` is now globally unique
|
|
45
|
+
* (message-channel.ts), so this can only happen if `oldMessageIds` itself was
|
|
46
|
+
* incomplete, and losing routing info for one message beats losing the message.
|
|
47
|
+
*
|
|
48
|
+
* `existingMessageIds` has no default — deliberately. `= []` would let a call site
|
|
49
|
+
* omit it and get back a fresh array to assign over, which is exactly the #764
|
|
50
|
+
* clobber: silently discarding whatever the target array already held. Making it
|
|
51
|
+
* required turns that omission into a compile error at every call site, including
|
|
52
|
+
* ones written later.
|
|
53
|
+
*/
|
|
54
|
+
export function relockstepMessageIds(
|
|
55
|
+
pending: ChannelEntry[],
|
|
56
|
+
oldMessageIds: MessageIdEntry[],
|
|
57
|
+
push: (msg: SDKUserMessage) => number,
|
|
58
|
+
existingMessageIds: MessageIdEntry[],
|
|
59
|
+
transform: (msg: SDKUserMessage) => SDKUserMessage = markRecovered,
|
|
60
|
+
): MessageIdEntry[] {
|
|
61
|
+
const idBySeq = new Map(oldMessageIds.map((e) => [e.seq, e.id]));
|
|
62
|
+
existingMessageIds.push(
|
|
63
|
+
...pending.map((entry) => ({
|
|
64
|
+
id: idBySeq.get(entry.seq),
|
|
65
|
+
seq: push(transform(entry.msg)),
|
|
66
|
+
})),
|
|
67
|
+
);
|
|
68
|
+
return existingMessageIds;
|
|
69
|
+
}
|