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
|
@@ -13,7 +13,8 @@ import {
|
|
|
13
13
|
readSdkInstructions,
|
|
14
14
|
readSkillDescriptions,
|
|
15
15
|
} from "./lib/context-breakdown.js";
|
|
16
|
-
import { daemonEmit } from "./lib/daemon-client.js";
|
|
16
|
+
import { daemonEmit, daemonNotice } from "./lib/daemon-client.js";
|
|
17
|
+
import { compactTimestamp } from "./lib/format-prefix.js";
|
|
17
18
|
import { runHooks } from "./lib/hook-loader.js";
|
|
18
19
|
import { createAutoCommitHook } from "./lib/hooks/auto-commit.js";
|
|
19
20
|
import { createIdentityReloadHook } from "./lib/hooks/identity-reload.js";
|
|
@@ -22,15 +23,24 @@ import { createPreCompactHook } from "./lib/hooks/pre-compact.js";
|
|
|
22
23
|
import { createReplyInstructionsHook } from "./lib/hooks/reply-instructions.js";
|
|
23
24
|
import { log } from "./lib/logger.js";
|
|
24
25
|
import { createMessageChannel } from "./lib/message-channel.js";
|
|
26
|
+
import { relockstepMessageIds } from "./lib/recover.js";
|
|
27
|
+
import { buildSeededNote, type SeedCause } from "./lib/seed-note.js";
|
|
25
28
|
import {
|
|
26
29
|
isSessionReapable,
|
|
27
30
|
reapSessionQuery,
|
|
28
31
|
reapSessionsForShutdown,
|
|
29
32
|
} from "./lib/session-reaper.js";
|
|
33
|
+
import {
|
|
34
|
+
computeSeedCut,
|
|
35
|
+
DEFAULT_SEED_TOKENS,
|
|
36
|
+
rotateSession,
|
|
37
|
+
type SeedCut,
|
|
38
|
+
seedSession,
|
|
39
|
+
} from "./lib/session-seed.js";
|
|
30
40
|
import { createSessionStore } from "./lib/session-store.js";
|
|
31
41
|
import type { EffortLevel, ThinkingConfig } from "./lib/startup.js";
|
|
32
|
-
import { loadPrompts, type SubagentConfig } from "./lib/startup.js";
|
|
33
|
-
import { consumeStream } from "./lib/stream-consumer.js";
|
|
42
|
+
import { loadPrompts, renderCompactionWarning, type SubagentConfig } from "./lib/startup.js";
|
|
43
|
+
import { consumeStream, type MessageIdEntry } from "./lib/stream-consumer.js";
|
|
34
44
|
import type {
|
|
35
45
|
HandlerMeta,
|
|
36
46
|
HandlerResolver,
|
|
@@ -45,8 +55,9 @@ type Session = {
|
|
|
45
55
|
name: string;
|
|
46
56
|
channel: ReturnType<typeof createMessageChannel>;
|
|
47
57
|
listeners: Set<Listener>;
|
|
48
|
-
messageIds:
|
|
58
|
+
messageIds: MessageIdEntry[];
|
|
49
59
|
currentMessageId?: string;
|
|
60
|
+
currentSeq?: number;
|
|
50
61
|
currentQuery?: ReturnType<typeof query>;
|
|
51
62
|
messageChannels: Map<string, { channel: string; sender?: string }>;
|
|
52
63
|
replyInstructionsFired: boolean;
|
|
@@ -56,8 +67,47 @@ type Session = {
|
|
|
56
67
|
contextTokens: number;
|
|
57
68
|
/** Last inbound message or completed turn — drives idle reaping. */
|
|
58
69
|
lastActivityAt: number;
|
|
70
|
+
/**
|
|
71
|
+
* True when this session was seeded from the previous session's transcript.
|
|
72
|
+
* Injects the honest-boundary note on each prompt until a turn resolves (see
|
|
73
|
+
* the pre-prompt hook and onTurnEnd), so an interrupted first turn re-offers it.
|
|
74
|
+
*/
|
|
75
|
+
seeded: boolean;
|
|
76
|
+
/** When the seeded-from session was archived (epoch ms), for the gap note; null if unknown. */
|
|
77
|
+
seededArchivedAt: number | null;
|
|
78
|
+
/** Why the tail is seeded — picks the boundary note's wording. Last cause wins. */
|
|
79
|
+
seededCause: SeedCause;
|
|
80
|
+
/**
|
|
81
|
+
* The cut point pinned at warn time (the first-kept turn the mind was told would
|
|
82
|
+
* survive verbatim). Honored at rotation so the promise in the warning holds even
|
|
83
|
+
* though the transcript grows after the warning. Null until a warning fires.
|
|
84
|
+
*/
|
|
85
|
+
rotationCut: SeedCut | null;
|
|
86
|
+
/**
|
|
87
|
+
* Back-to-back rotations that did NOT bring context under the threshold (reset by
|
|
88
|
+
* any healthy turn). Guards against a runaway loop when the tail alone can't fit —
|
|
89
|
+
* e.g. a system prompt (large MEMORY.md) that already fills most of the window, which
|
|
90
|
+
* rotation can't trim. Past the cap we stop rotating and defer to native compaction.
|
|
91
|
+
*/
|
|
92
|
+
consecutiveRotations: number;
|
|
93
|
+
/**
|
|
94
|
+
* Rotation state machine (undefined = idle). The warning is pushed and the mind
|
|
95
|
+
* takes a wrap-up turn — with FULL context of the turns it's summarizing — before
|
|
96
|
+
* the session rotates:
|
|
97
|
+
* - "warned": a turn was mid-flight when we warned; it must finish before the
|
|
98
|
+
* wrap-up turn (which consumes the warning) runs.
|
|
99
|
+
* - "rotateAfterTurn": the wrap-up turn is the current/next turn; rotate when it ends.
|
|
100
|
+
* Lives on the session (not a map) so it can't leak into a resumed session if the
|
|
101
|
+
* stream dies mid-flight — a fresh session starts idle.
|
|
102
|
+
*/
|
|
103
|
+
rotationPhase?: RotationPhase;
|
|
59
104
|
};
|
|
60
105
|
|
|
106
|
+
type RotationPhase = "warned" | "rotateAfterTurn";
|
|
107
|
+
|
|
108
|
+
/** Stop self-rotating after this many back-to-back rotations that didn't reduce context. */
|
|
109
|
+
const MAX_CONSECUTIVE_ROTATIONS = 3;
|
|
110
|
+
|
|
61
111
|
export function createMind(options: {
|
|
62
112
|
systemPrompt: string;
|
|
63
113
|
cwd: string;
|
|
@@ -72,6 +122,8 @@ export function createMind(options: {
|
|
|
72
122
|
onIdentityReload?: () => Promise<void>;
|
|
73
123
|
/** Idle minutes before a session's SDK subprocess is reaped. 0 disables. Default 30. */
|
|
74
124
|
sessionIdleMinutes?: number;
|
|
125
|
+
/** Estimated-token budget for seeding a fresh persistent session. 0 disables. Default 30000. */
|
|
126
|
+
seedTokens?: number;
|
|
75
127
|
}): {
|
|
76
128
|
resolve: HandlerResolver;
|
|
77
129
|
waitForCommits: () => Promise<void>;
|
|
@@ -88,20 +140,15 @@ export function createMind(options: {
|
|
|
88
140
|
|
|
89
141
|
const sessions = new Map<string, Session>();
|
|
90
142
|
const prompts = loadPrompts();
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
// biome-ignore lint/suspicious/noTemplateCurlyInString: literal ${date} in prompt template
|
|
94
|
-
options.compactionMessage ?? prompts.compaction_warning.replace("${date}", today);
|
|
95
|
-
const compactionInstructions = prompts.compaction_instructions;
|
|
143
|
+
const compactionMessage = () =>
|
|
144
|
+
options.compactionMessage ?? renderCompactionWarning(prompts.compaction_warning);
|
|
96
145
|
const maxContextTokens = options.maxContextTokens;
|
|
146
|
+
const seedTokens = options.seedTokens ?? DEFAULT_SEED_TOKENS;
|
|
97
147
|
|
|
98
148
|
if (maxContextTokens) {
|
|
99
149
|
log("mind", `compaction threshold: ${maxContextTokens} tokens`);
|
|
100
150
|
}
|
|
101
151
|
|
|
102
|
-
// Per-session compaction state
|
|
103
|
-
const compactionTriggered = new Map<string, boolean>();
|
|
104
|
-
|
|
105
152
|
// --- Subagents (config-driven) ---
|
|
106
153
|
|
|
107
154
|
type SDKAgent = {
|
|
@@ -242,11 +289,43 @@ export function createMind(options: {
|
|
|
242
289
|
}
|
|
243
290
|
}
|
|
244
291
|
// Only UserPromptSubmit hooks can inject additionalContext into the conversation
|
|
245
|
-
if (event !== "pre-prompt"
|
|
292
|
+
if (event !== "pre-prompt") return {};
|
|
293
|
+
let additionalContext = result.additionalContext;
|
|
294
|
+
// On a seeded session, prepend the honest-boundary note. Deliberately NOT
|
|
295
|
+
// cleared here: this hook only hands the note to the SDK at *dispatch*, and
|
|
296
|
+
// the SDK cancels UserPromptSubmit hooks when an interrupt arrives (a
|
|
297
|
+
// `hook_cancelled` transcript entry). That's most likely on the seeded first
|
|
298
|
+
// turn post-wake, when queued inbound messages flood in — exactly when we'd
|
|
299
|
+
// lose the note. Clearing on a mere injection attempt could drop it forever
|
|
300
|
+
// and silently. So the flag is cleared only when a turn actually resolves
|
|
301
|
+
// (onTurnEnd); an interrupted turn re-offers the note next prompt. Worst case
|
|
302
|
+
// it fires twice (cosmetic) instead of zero times (unrecoverable, invisible).
|
|
303
|
+
if (session.seeded) {
|
|
304
|
+
const note = buildSeededNote({
|
|
305
|
+
cause: session.seededCause,
|
|
306
|
+
archivedAtMs: session.seededArchivedAt,
|
|
307
|
+
});
|
|
308
|
+
additionalContext = additionalContext ? `${note}\n\n${additionalContext}` : note;
|
|
309
|
+
// Also surface the note as its own context event (matching codex/pi) so it
|
|
310
|
+
// isn't delivered only in the same low-salience container as routine
|
|
311
|
+
// per-prompt hooks. Once per injection attempt; a duplicate on re-offer is fine.
|
|
312
|
+
const channel = session.currentMessageId
|
|
313
|
+
? session.messageChannels.get(session.currentMessageId)?.channel
|
|
314
|
+
: undefined;
|
|
315
|
+
daemonEmit({
|
|
316
|
+
type: "context",
|
|
317
|
+
content: note,
|
|
318
|
+
metadata: { source: "seeded-session" },
|
|
319
|
+
session: session.name,
|
|
320
|
+
channel,
|
|
321
|
+
messageId: session.currentMessageId,
|
|
322
|
+
}).catch((err) => log("mind", "seeded-session context emit failed:", err));
|
|
323
|
+
}
|
|
324
|
+
if (!additionalContext) return {};
|
|
246
325
|
return {
|
|
247
326
|
hookSpecificOutput: {
|
|
248
327
|
hookEventName: "UserPromptSubmit" as const,
|
|
249
|
-
additionalContext
|
|
328
|
+
additionalContext,
|
|
250
329
|
},
|
|
251
330
|
};
|
|
252
331
|
} catch (err) {
|
|
@@ -317,17 +396,61 @@ export function createMind(options: {
|
|
|
317
396
|
let currentSessionId = savedSessionId;
|
|
318
397
|
let streamAbort = new AbortController();
|
|
319
398
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
399
|
+
/**
|
|
400
|
+
* Warn the mind that the session will rotate at the context limit and pin the
|
|
401
|
+
* cut point it's told about. Computes which whole turns fit in `seedTokens`
|
|
402
|
+
* from the live transcript, pins the first-kept turn (so rotation honors the
|
|
403
|
+
* same boundary even as the transcript grows), interpolates its time into the
|
|
404
|
+
* `${cutoff}` placeholder, enters the rotation state machine, and pushes the
|
|
405
|
+
* warning as the wrap-up prompt. Shared by the threshold and PreCompact paths.
|
|
406
|
+
*
|
|
407
|
+
* Crucially it does NOT abort here: the mind takes a wrap-up turn (reading the
|
|
408
|
+
* pinned cutoff and authoring `volute mind history` summaries with full context
|
|
409
|
+
* of the turns being collapsed) before rotation. The entry phase depends on
|
|
410
|
+
* whether a turn is mid-flight: if one is (threshold path fires on an assistant
|
|
411
|
+
* message), it must finish before the queued warning runs, so we start "warned";
|
|
412
|
+
* if we're between turns (PreCompact fired at a boundary), the warning IS the
|
|
413
|
+
* next turn, so we start "rotateAfterTurn".
|
|
414
|
+
*/
|
|
415
|
+
function warnAndPinRotation() {
|
|
416
|
+
let cutoffLabel = "the cutoff";
|
|
417
|
+
session.rotationCut = null;
|
|
418
|
+
if (currentSessionId) {
|
|
419
|
+
try {
|
|
420
|
+
const path = findClaudeSessionFile(options.cwd, currentSessionId);
|
|
421
|
+
const cut = path ? computeSeedCut(readFileSync(path, "utf-8"), seedTokens) : null;
|
|
422
|
+
if (cut) {
|
|
423
|
+
session.rotationCut = cut;
|
|
424
|
+
if (cut.boundaryTimestamp) {
|
|
425
|
+
const d = new Date(cut.boundaryTimestamp);
|
|
426
|
+
if (!Number.isNaN(d.getTime())) cutoffLabel = compactTimestamp(d);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
} catch (err) {
|
|
430
|
+
log("mind", `session "${session.name}": failed to pin rotation cut:`, err);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: literal ${cutoff} prompt placeholder
|
|
434
|
+
const warning = compactionMessage().replaceAll("${cutoff}", cutoffLabel);
|
|
435
|
+
session.rotationPhase =
|
|
436
|
+
session.currentMessageId !== undefined ? "warned" : "rotateAfterTurn";
|
|
437
|
+
const seq = session.channel.push({
|
|
323
438
|
type: "user",
|
|
324
439
|
session_id: "",
|
|
325
|
-
message: {
|
|
326
|
-
role: "user",
|
|
327
|
-
content: [{ type: "text", text: compactionMessage }],
|
|
328
|
-
},
|
|
440
|
+
message: { role: "user", content: [{ type: "text", text: warning }] },
|
|
329
441
|
parent_tool_use_id: null,
|
|
330
442
|
});
|
|
443
|
+
session.messageIds.push({ id: undefined, seq });
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// PreCompact backstop: first fire warns + pins + enters the rotation machine
|
|
447
|
+
// (unless already warned, or the rotation cap is hit) and blocks; second fire
|
|
448
|
+
// allows the SDK's native compaction — the emergency backstop when rotation never
|
|
449
|
+
// intervened (a hung turn, or a system prompt too large for rotation to relieve).
|
|
450
|
+
const preCompact = createPreCompactHook(() => {
|
|
451
|
+
if (!session.rotationPhase && session.consecutiveRotations < MAX_CONSECUTIVE_ROTATIONS) {
|
|
452
|
+
warnAndPinRotation();
|
|
453
|
+
}
|
|
331
454
|
});
|
|
332
455
|
|
|
333
456
|
const callbacks = {
|
|
@@ -336,20 +459,31 @@ export function createMind(options: {
|
|
|
336
459
|
if (!session.name.startsWith("new-")) sessionStore.save(session.name, id);
|
|
337
460
|
},
|
|
338
461
|
broadcast: (event: VoluteEvent) => broadcastToSession(session, event),
|
|
462
|
+
// Identity-based ack — stream-consumer.ts calls this once per message the
|
|
463
|
+
// just-finished turn covers (its own driving message, plus any folded in
|
|
464
|
+
// mid-run) so none of them strand in the channel's in-flight set (#764).
|
|
465
|
+
ack: (seq: number) => session.channel.ack(seq),
|
|
339
466
|
onTurnEnd: async () => {
|
|
340
|
-
// This turn's message is fully processed — drop it from the channel's
|
|
341
|
-
// in-flight set so a later compaction abort won't re-feed it.
|
|
342
|
-
session.channel.ack();
|
|
343
467
|
session.lastActivityAt = Date.now();
|
|
468
|
+
// A turn resolved — the seeded note's injection had its chance to land
|
|
469
|
+
// (the pre-prompt hook ran and wasn't cancelled by an interrupt), so stop
|
|
470
|
+
// re-offering it. This is the honest place to clear: a completed turn is
|
|
471
|
+
// the closest-to-arrival signal available without reading the transcript back.
|
|
472
|
+
session.seeded = false;
|
|
344
473
|
await autoCommit.flushFileChanges();
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
474
|
+
if (session.rotationPhase === "warned") {
|
|
475
|
+
// The mid-flight turn finished; the queued warning runs next as the wrap-up
|
|
476
|
+
// turn. Advance — do NOT abort yet, so the mind summarizes with full context.
|
|
477
|
+
session.rotationPhase = "rotateAfterTurn";
|
|
478
|
+
} else if (session.rotationPhase === "rotateAfterTurn") {
|
|
479
|
+
// The wrap-up turn is done (summaries authored) — abort to rotate in place.
|
|
480
|
+
log("mind", `session "${session.name}": wrap-up turn done — aborting to rotate`);
|
|
350
481
|
streamAbort.abort(new CompactionAbort());
|
|
351
|
-
} else
|
|
352
|
-
|
|
482
|
+
} else {
|
|
483
|
+
// A healthy turn (context under the threshold) — the rotation streak, if
|
|
484
|
+
// any, is over, so re-arm the self-rotation cap.
|
|
485
|
+
session.consecutiveRotations = 0;
|
|
486
|
+
if (identityReload.shouldRequestReload()) options.onIdentityReload?.();
|
|
353
487
|
}
|
|
354
488
|
},
|
|
355
489
|
onContextTokens: (tokens: number) => {
|
|
@@ -357,64 +491,18 @@ export function createMind(options: {
|
|
|
357
491
|
if (
|
|
358
492
|
maxContextTokens &&
|
|
359
493
|
tokens >= maxContextTokens &&
|
|
360
|
-
!
|
|
494
|
+
!session.rotationPhase &&
|
|
495
|
+
session.consecutiveRotations < MAX_CONSECUTIVE_ROTATIONS
|
|
361
496
|
) {
|
|
362
|
-
compactionTriggered.set(session.name, true);
|
|
363
497
|
log(
|
|
364
498
|
"mind",
|
|
365
|
-
`session "${session.name}": ${tokens} tokens >= ${maxContextTokens} —
|
|
499
|
+
`session "${session.name}": ${tokens} tokens >= ${maxContextTokens} — warning + pinning rotation`,
|
|
366
500
|
);
|
|
367
|
-
|
|
368
|
-
session.channel.push({
|
|
369
|
-
type: "user",
|
|
370
|
-
session_id: "",
|
|
371
|
-
message: {
|
|
372
|
-
role: "user",
|
|
373
|
-
content: [{ type: "text", text: compactionMessage }],
|
|
374
|
-
},
|
|
375
|
-
parent_tool_use_id: null,
|
|
376
|
-
});
|
|
501
|
+
warnAndPinRotation();
|
|
377
502
|
}
|
|
378
503
|
},
|
|
379
504
|
};
|
|
380
505
|
|
|
381
|
-
async function runCompact(sessionId: string) {
|
|
382
|
-
log("mind", `session "${session.name}": compacting with custom instructions`);
|
|
383
|
-
const compactAbort = new AbortController();
|
|
384
|
-
// Forward mind-level abort to the compact query
|
|
385
|
-
options.abortController.signal.addEventListener("abort", () => compactAbort.abort(), {
|
|
386
|
-
once: true,
|
|
387
|
-
});
|
|
388
|
-
const compactQuery = query({
|
|
389
|
-
prompt: `/compact ${compactionInstructions}`,
|
|
390
|
-
options: {
|
|
391
|
-
systemPrompt: options.systemPrompt,
|
|
392
|
-
permissionMode: "bypassPermissions",
|
|
393
|
-
allowDangerouslySkipPermissions: true,
|
|
394
|
-
settingSources: ["project", "user"],
|
|
395
|
-
skills: installedSkills(),
|
|
396
|
-
env: sdkEnv,
|
|
397
|
-
cwd: options.cwd,
|
|
398
|
-
abortController: compactAbort,
|
|
399
|
-
model: options.model,
|
|
400
|
-
resume: sessionId,
|
|
401
|
-
},
|
|
402
|
-
});
|
|
403
|
-
let gotResult = false;
|
|
404
|
-
for await (const msg of compactQuery) {
|
|
405
|
-
if ("session_id" in msg && msg.session_id) {
|
|
406
|
-
currentSessionId = msg.session_id as string;
|
|
407
|
-
if (!session.name.startsWith("new-")) {
|
|
408
|
-
sessionStore.save(session.name, currentSessionId);
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
if (msg.type === "result") gotResult = true;
|
|
412
|
-
}
|
|
413
|
-
if (!gotResult)
|
|
414
|
-
log("mind", `session "${session.name}": compaction stream ended without result`);
|
|
415
|
-
log("mind", `session "${session.name}": compaction complete`);
|
|
416
|
-
}
|
|
417
|
-
|
|
418
506
|
/** Emit done to both local listeners and the daemon (best-effort with retries). */
|
|
419
507
|
function emitDone() {
|
|
420
508
|
broadcastToSession(session, { type: "done" });
|
|
@@ -457,30 +545,77 @@ export function createMind(options: {
|
|
|
457
545
|
streamAbort.signal.reason instanceof CompactionAbort &&
|
|
458
546
|
currentSessionId
|
|
459
547
|
) {
|
|
460
|
-
// Stream was aborted
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
548
|
+
// Stream was aborted to rotate: replace the session with a synthetic one
|
|
549
|
+
// holding the verbatim recent tail (from the pinned cut), then resume it.
|
|
550
|
+
const rotatedId = rotateSession({
|
|
551
|
+
cwd: options.cwd,
|
|
552
|
+
sessionsDir: options.sessionsDir,
|
|
553
|
+
name: session.name,
|
|
554
|
+
oldSessionId: currentSessionId,
|
|
555
|
+
cut: session.rotationCut,
|
|
556
|
+
seedTokens,
|
|
557
|
+
});
|
|
558
|
+
session.rotationCut = null;
|
|
559
|
+
session.rotationPhase = undefined; // state machine consumed
|
|
560
|
+
if (!rotatedId) {
|
|
561
|
+
// Rotation couldn't proceed — fall back to a fresh session (no seed).
|
|
562
|
+
// Unlike a successful rotation (boundary note, verbatim tail, archived
|
|
563
|
+
// transcript), this genuinely loses the live context — say so (#367).
|
|
564
|
+
log("mind", `session "${session.name}": rotation failed, starting fresh`);
|
|
565
|
+
daemonNotice({
|
|
566
|
+
kind: "context_lost",
|
|
567
|
+
thread: session.name,
|
|
568
|
+
message:
|
|
569
|
+
"Session rotation failed at the context limit and this thread was " +
|
|
570
|
+
"reset — conversation context before this point was lost. Your turn " +
|
|
571
|
+
"summaries survive in `volute mind history`; check your journal for " +
|
|
572
|
+
"where you left off.",
|
|
573
|
+
}).catch((err) =>
|
|
574
|
+
log("mind", `session "${session.name}": failed to record notice:`, err),
|
|
468
575
|
);
|
|
469
576
|
sessionStore.delete(session.name);
|
|
470
577
|
currentSessionId = undefined;
|
|
578
|
+
session.seeded = false;
|
|
471
579
|
streamAbort = new AbortController();
|
|
472
580
|
session.channel = createMessageChannel();
|
|
473
581
|
break;
|
|
474
582
|
}
|
|
583
|
+
// Point the live pointer at the rotated session and arm the boundary
|
|
584
|
+
// note (rotation cause — no gap). Ephemeral `new-*` keep no pointer.
|
|
585
|
+
if (!session.name.startsWith("new-")) sessionStore.save(session.name, rotatedId);
|
|
586
|
+
currentSessionId = rotatedId;
|
|
587
|
+
session.seeded = true;
|
|
588
|
+
session.seededCause = "rotation";
|
|
589
|
+
session.seededArchivedAt = null;
|
|
590
|
+
// Count this rotation; a healthy turn resets it. If back-to-back rotations
|
|
591
|
+
// don't reduce context (system prompt too large to fit the tail under the
|
|
592
|
+
// threshold), the cap stops the loop and defers to native compaction.
|
|
593
|
+
session.consecutiveRotations++;
|
|
594
|
+
if (session.consecutiveRotations >= MAX_CONSECUTIVE_ROTATIONS) {
|
|
595
|
+
log(
|
|
596
|
+
"mind",
|
|
597
|
+
`session "${session.name}": ${session.consecutiveRotations} rotations without relief — deferring further compaction to the SDK (system prompt likely too large)`,
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
// Re-arm the PreCompact backstop so a future auto-compact blocks + rotates
|
|
601
|
+
// again rather than falling straight through to native compaction.
|
|
602
|
+
preCompact.reset();
|
|
475
603
|
streamAbort = new AbortController();
|
|
476
|
-
// Recover input the aborted stream had already pulled but not
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
// is dropped when the killed subprocess takes its buffer with it.
|
|
604
|
+
// Recover input the aborted stream had already pulled but not finished
|
|
605
|
+
// (the wrap-up warning plus anything that arrived mid-turn) so nothing is
|
|
606
|
+
// dropped when the killed subprocess takes its buffer with it.
|
|
480
607
|
const pending = session.channel.recover();
|
|
608
|
+
const oldMessageIds = session.messageIds;
|
|
481
609
|
session.channel = createMessageChannel();
|
|
482
|
-
|
|
483
|
-
|
|
610
|
+
// Starts from [] — nothing can race into this fresh channel/messageIds
|
|
611
|
+
// before the next line runs (single-threaded, no await in between).
|
|
612
|
+
session.messageIds = relockstepMessageIds(
|
|
613
|
+
pending,
|
|
614
|
+
oldMessageIds,
|
|
615
|
+
session.channel.push,
|
|
616
|
+
[],
|
|
617
|
+
);
|
|
618
|
+
continue; // restart the stream loop on the rotated session
|
|
484
619
|
}
|
|
485
620
|
throw err; // rethrow non-compaction errors
|
|
486
621
|
}
|
|
@@ -489,8 +624,21 @@ export function createMind(options: {
|
|
|
489
624
|
session.messageChannels.clear();
|
|
490
625
|
if (currentSessionId) {
|
|
491
626
|
log("mind", `session "${session.name}": resume failed, starting fresh:`, err);
|
|
627
|
+
daemonNotice({
|
|
628
|
+
kind: "context_lost",
|
|
629
|
+
thread: session.name,
|
|
630
|
+
message:
|
|
631
|
+
"Your session couldn't be resumed after an error, so you're starting fresh. " +
|
|
632
|
+
"Recent context may be in memory/journal/.",
|
|
633
|
+
}).catch((e) => log("mind", `session "${session.name}": failed to record notice:`, e));
|
|
492
634
|
sessionStore.delete(session.name);
|
|
493
635
|
currentSessionId = undefined;
|
|
636
|
+
// We fell back to a truly empty session — don't tell the mind its
|
|
637
|
+
// conversation continued when the seeded transcript failed to resume,
|
|
638
|
+
// and don't let a half-entered rotation leak into the fresh session.
|
|
639
|
+
session.seeded = false;
|
|
640
|
+
session.rotationPhase = undefined;
|
|
641
|
+
session.rotationCut = null;
|
|
494
642
|
streamAbort = new AbortController();
|
|
495
643
|
session.channel = createMessageChannel();
|
|
496
644
|
try {
|
|
@@ -531,6 +679,11 @@ export function createMind(options: {
|
|
|
531
679
|
eventNoteFired: false,
|
|
532
680
|
contextTokens: 0,
|
|
533
681
|
lastActivityAt: Date.now(),
|
|
682
|
+
seeded: false,
|
|
683
|
+
seededArchivedAt: null,
|
|
684
|
+
seededCause: "restored",
|
|
685
|
+
rotationCut: null,
|
|
686
|
+
consecutiveRotations: 0,
|
|
534
687
|
};
|
|
535
688
|
sessions.set(name, session);
|
|
536
689
|
|
|
@@ -542,9 +695,41 @@ export function createMind(options: {
|
|
|
542
695
|
log("mind", `session "${name}": stored session ${savedSessionId} not found, starting fresh`);
|
|
543
696
|
sessionStore.delete(name);
|
|
544
697
|
savedSessionId = undefined;
|
|
698
|
+
// Worded so it stays true whether or not transcript seeding (below) partially
|
|
699
|
+
// restores context: the live session is gone either way.
|
|
700
|
+
daemonNotice({
|
|
701
|
+
kind: "context_lost",
|
|
702
|
+
thread: name,
|
|
703
|
+
message:
|
|
704
|
+
"Your previous session couldn't be restored (session file missing), so this " +
|
|
705
|
+
"thread was reset. Recent context may be in memory/journal/.",
|
|
706
|
+
}).catch((err) => log("mind", `session "${name}": failed to record notice:`, err));
|
|
545
707
|
}
|
|
546
708
|
if (savedSessionId) {
|
|
547
709
|
log("mind", `session "${name}": resuming ${savedSessionId}`);
|
|
710
|
+
} else if (!isEphemeral) {
|
|
711
|
+
// Fresh persistent session — seed it from the previous session's archived
|
|
712
|
+
// transcript so the mind experiences the conversation continuing rather
|
|
713
|
+
// than waking into an empty context. Ephemeral `new-*` sessions never seed.
|
|
714
|
+
const seeded = seedSession({
|
|
715
|
+
cwd: options.cwd,
|
|
716
|
+
sessionsDir: options.sessionsDir,
|
|
717
|
+
name,
|
|
718
|
+
seedTokens: options.seedTokens ?? DEFAULT_SEED_TOKENS,
|
|
719
|
+
});
|
|
720
|
+
if (seeded) {
|
|
721
|
+
sessionStore.save(name, seeded.sessionId);
|
|
722
|
+
savedSessionId = seeded.sessionId;
|
|
723
|
+
session.seeded = true;
|
|
724
|
+
session.seededArchivedAt = seeded.archivedAt;
|
|
725
|
+
session.seededCause = "restored";
|
|
726
|
+
log(
|
|
727
|
+
"mind",
|
|
728
|
+
`session "${name}": seeded from previous transcript, resuming ${seeded.sessionId}`,
|
|
729
|
+
);
|
|
730
|
+
} else {
|
|
731
|
+
log("mind", `session "${name}": starting fresh`);
|
|
732
|
+
}
|
|
548
733
|
} else {
|
|
549
734
|
log("mind", `session "${name}": starting fresh`);
|
|
550
735
|
}
|
|
@@ -565,7 +750,6 @@ export function createMind(options: {
|
|
|
565
750
|
// Delete first so a racing inbound message spins up a fresh resumed session
|
|
566
751
|
// instead of reusing the one we're tearing down.
|
|
567
752
|
sessions.delete(session.name);
|
|
568
|
-
compactionTriggered.delete(session.name);
|
|
569
753
|
// End the input iterable so the stream consumer unwinds (its finally block
|
|
570
754
|
// also deletes from the map, now a no-op), then await the SDK's graceful
|
|
571
755
|
// shutdown via query.return() — unlike the fire-and-forget close(), this
|
|
@@ -576,11 +760,16 @@ export function createMind(options: {
|
|
|
576
760
|
log("mind", `session "${session.name}": error reaping SDK subprocess:`, err),
|
|
577
761
|
);
|
|
578
762
|
// Nothing should have raced in (isSessionReapable checked isEmpty), but if it
|
|
579
|
-
// did, re-dispatch into a fresh session so no input is dropped.
|
|
763
|
+
// did, re-dispatch into a fresh session so no input is dropped. Same marker
|
|
764
|
+
// treatment as the rotation path (#764) — see relockstepMessageIds above.
|
|
765
|
+
// getOrCreateSession() can return a session an inbound message already raced
|
|
766
|
+
// into during the reapSessionQuery() await above (it pushed its own entry into
|
|
767
|
+
// fresh.messageIds via the normal handler path) — relockstepMessageIds appends
|
|
768
|
+
// onto fresh.messageIds rather than replacing it, so that entry survives.
|
|
580
769
|
const pending = session.channel.recover();
|
|
581
770
|
if (pending.length > 0) {
|
|
582
771
|
const fresh = getOrCreateSession(session.name);
|
|
583
|
-
|
|
772
|
+
relockstepMessageIds(pending, session.messageIds, fresh.channel.push, fresh.messageIds);
|
|
584
773
|
}
|
|
585
774
|
}
|
|
586
775
|
|
|
@@ -605,7 +794,12 @@ export function createMind(options: {
|
|
|
605
794
|
const reaper = setInterval(() => {
|
|
606
795
|
const now = Date.now();
|
|
607
796
|
const stale = [...sessions.values()].filter((s) =>
|
|
608
|
-
isSessionReapable(
|
|
797
|
+
isSessionReapable(
|
|
798
|
+
s,
|
|
799
|
+
now,
|
|
800
|
+
idleTimeoutMs,
|
|
801
|
+
(name) => sessions.get(name)?.rotationPhase !== undefined,
|
|
802
|
+
),
|
|
609
803
|
);
|
|
610
804
|
// Reaps run independently; each awaits its own subprocess exit internally.
|
|
611
805
|
for (const session of stale) {
|
|
@@ -663,13 +857,13 @@ export function createMind(options: {
|
|
|
663
857
|
|
|
664
858
|
// Push message into SDK
|
|
665
859
|
session.lastActivityAt = Date.now();
|
|
666
|
-
session.
|
|
667
|
-
session.channel.push({
|
|
860
|
+
const seq = session.channel.push({
|
|
668
861
|
type: "user",
|
|
669
862
|
session_id: "",
|
|
670
863
|
message: { role: "user", content: toSDKContent(content) },
|
|
671
864
|
parent_tool_use_id: null,
|
|
672
865
|
});
|
|
866
|
+
session.messageIds.push({ id: meta.messageId, seq });
|
|
673
867
|
|
|
674
868
|
return () => {
|
|
675
869
|
if (filteredListener) session.listeners.delete(filteredListener);
|
|
@@ -1,21 +1,41 @@
|
|
|
1
1
|
import type { HookCallback, PreCompactHookInput } from "@anthropic-ai/claude-agent-sdk";
|
|
2
2
|
import { log } from "../logger.js";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* PreCompact hook: the backstop for SDK-initiated auto-compaction.
|
|
6
|
+
*
|
|
7
|
+
* The primary compaction path is volute's own token threshold, which rotates the
|
|
8
|
+
* session in place. This hook covers the case where the SDK decides to auto-compact
|
|
9
|
+
* before (or instead of) that threshold firing:
|
|
10
|
+
*
|
|
11
|
+
* - First fire: run `onCompact` (warn the mind + pin the rotation boundary, same
|
|
12
|
+
* as the threshold path) and BLOCK the SDK's native compaction. The wrap-up
|
|
13
|
+
* turn then ends and the session rotates.
|
|
14
|
+
* - Second fire: allow the SDK's native compaction as an emergency backstop —
|
|
15
|
+
* only reachable if rotation never happened (a hung turn, or rotation failed).
|
|
16
|
+
*
|
|
17
|
+
* `reset()` re-arms the first-fire behavior; the agent calls it after a successful
|
|
18
|
+
* rotation so a later auto-compact blocks + rotates again instead of falling
|
|
19
|
+
* straight through to native compaction.
|
|
20
|
+
*
|
|
21
|
+
* Manual `/compact` with custom instructions is always allowed through untouched.
|
|
22
|
+
*/
|
|
4
23
|
export function createPreCompactHook(onCompact: () => void) {
|
|
5
24
|
let compactBlocked = false;
|
|
6
25
|
|
|
7
26
|
const hook: HookCallback = async (input) => {
|
|
8
27
|
const { trigger, custom_instructions } = input as PreCompactHookInput;
|
|
9
28
|
|
|
10
|
-
//
|
|
29
|
+
// Manual compaction with custom instructions — allow through without the block.
|
|
11
30
|
if (trigger === "manual" && custom_instructions) {
|
|
12
31
|
log("mind", "allowing manual compaction with custom instructions");
|
|
13
32
|
return {};
|
|
14
33
|
}
|
|
15
34
|
|
|
16
|
-
// Auto-compaction:
|
|
35
|
+
// Auto-compaction: first pass warns + marks for rotation and blocks; second
|
|
36
|
+
// pass allows the native compaction as the emergency backstop.
|
|
17
37
|
if (!compactBlocked) {
|
|
18
|
-
log("mind", "blocking compaction —
|
|
38
|
+
log("mind", "blocking auto-compaction — warning mind + marking session for rotation");
|
|
19
39
|
try {
|
|
20
40
|
onCompact();
|
|
21
41
|
compactBlocked = true;
|
|
@@ -26,9 +46,14 @@ export function createPreCompactHook(onCompact: () => void) {
|
|
|
26
46
|
return { decision: "block" };
|
|
27
47
|
}
|
|
28
48
|
compactBlocked = false;
|
|
29
|
-
log("mind", "allowing compaction");
|
|
49
|
+
log("mind", "allowing native compaction (rotation did not intervene)");
|
|
30
50
|
return {};
|
|
31
51
|
};
|
|
32
52
|
|
|
33
|
-
|
|
53
|
+
/** Re-arm the first-fire block/rotate behavior (called after a rotation). */
|
|
54
|
+
function reset() {
|
|
55
|
+
compactBlocked = false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { hook, reset };
|
|
34
59
|
}
|