volute 0.52.0 → 0.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{accept-5NK5XJB5.js → accept-I4GVGWA3.js} +6 -3
- package/dist/{chat-VAZ25KVT.js → chat-WK2BLM7B.js} +9 -9
- package/dist/{chunk-724DVYHU.js → chunk-23UJGVYP.js} +9 -9
- package/dist/{chunk-K2F237KS.js → chunk-5DBGQBQR.js} +138 -13
- package/dist/chunk-5RBEEAKW.js +58 -0
- package/dist/{chunk-XRWUQPCA.js → chunk-7ARONCZW.js} +24 -2
- package/dist/{chunk-FXWBFDQR.js → chunk-AP2KQMMW.js} +48 -9
- package/dist/{chunk-DMK5ASKV.js → chunk-F6D766VQ.js} +6 -2
- package/dist/{chunk-ZEZQNP2G.js → chunk-HASADR3E.js} +46 -9
- package/dist/{chunk-AXFLY5GU.js → chunk-IQ7VNWH6.js} +16 -1
- package/dist/{chunk-G7A5LEVI.js → chunk-LN7XJ56R.js} +6 -6
- package/dist/{chunk-CW7BQKF6.js → chunk-P2TBUKLW.js} +3 -3
- package/dist/{chunk-SW2U6HRB.js → chunk-PIZDPY4G.js} +20 -3
- package/dist/{chunk-JHKBALS2.js → chunk-RMADJFA7.js} +1 -1
- package/dist/{chunk-RU74YG46.js → chunk-RQRO6PC7.js} +40 -6
- package/dist/{chunk-RPPTGPYF.js → chunk-TAWRJ24T.js} +1 -1
- package/dist/{chunk-ITK6HV47.js → chunk-UO4MZQNO.js} +1 -1
- package/dist/chunk-UTKUO2KY.js +102 -0
- package/dist/{chunk-YVR6EHWL.js → chunk-VDZ4I4ZP.js} +160 -18
- package/dist/cli.js +16 -16
- package/dist/{clock-ZT5YT6HA.js → clock-SH4NQC2F.js} +3 -3
- package/dist/{cloud-sync-H7EWZASD.js → cloud-sync-MJVJ33LD.js} +10 -9
- package/dist/{daemon-restart-4FWUA5JF.js → daemon-restart-BIGU4OKS.js} +1 -1
- package/dist/daemon.js +261 -89
- package/dist/{delivery-manager-VYXVKUMP.js → delivery-manager-FDHCZ7PU.js} +7 -5
- package/dist/delivery-notices-65TB3CBX.js +18 -0
- package/dist/{echo-text-PEEV7FQU.js → echo-text-KU6G4Z7V.js} +11 -10
- package/dist/{extensions-N6L5NYTK.js → extensions-VACUKFA5.js} +10 -9
- package/dist/{files-BRY5RRIM.js → files-CHPNEQXN.js} +5 -4
- package/dist/{message-delivery-7URC52ME.js → message-delivery-TUXM3AAE.js} +14 -11
- package/dist/{mind-NEXGT3KE.js → mind-IX5AWUST.js} +11 -11
- package/dist/{mind-contacts-QYU4TPEA.js → mind-contacts-BKUOUJPV.js} +5 -5
- package/dist/{mind-history-OGNR4L7W.js → mind-history-RVF6KJ7K.js} +83 -11
- package/dist/{mind-list-SYRA7CH5.js → mind-list-KNU34SUD.js} +2 -2
- package/dist/{mind-service-E26ZQXDS.js → mind-service-WHFBUKZE.js} +10 -9
- package/dist/{mind-sleep-LZAFBDZD.js → mind-sleep-D3DEYAWG.js} +5 -5
- package/dist/{mind-status-Z2LQFA6F.js → mind-status-BPTLQ5Z6.js} +15 -3
- package/dist/{package-2W2XKSIP.js → package-BN5KMU54.js} +1 -1
- package/dist/{prompts-NBO3HRK3.js → prompts-HNKGCAEY.js} +1 -1
- package/dist/{reject-TWB4QJHG.js → reject-K2A4UK2Y.js} +9 -5
- package/dist/{scheduler-JTTWZDYU.js → scheduler-5X5UIQOQ.js} +7 -5
- package/dist/{seed-64XEHRUP.js → seed-LHZ33UB4.js} +1 -1
- package/dist/{seed-cmd-UEL2QB3Q.js → seed-cmd-U4RVJ3UA.js} +2 -2
- package/dist/{seed-create-4JRQDPGA.js → seed-create-OQU4KNCB.js} +1 -1
- package/dist/{seed-readiness-DYOVCYHN.js → seed-readiness-WJYYRRCK.js} +2 -2
- package/dist/{seed-sprout-NIDIOJXT.js → seed-sprout-SFUYH2RG.js} +7 -7
- package/dist/{send-2YKOZJJ5.js → send-FWZAMDBN.js} +35 -20
- package/dist/{setup-4IX6PCD3.js → setup-RVSGCIF3.js} +1 -1
- package/dist/skills/memory/SKILL.md +47 -43
- package/dist/{skills-MPUALV3S.js → skills-QRPU6BWY.js} +1 -1
- package/dist/{sleep-manager-OFP2GFXF.js → sleep-manager-NWUCRBZB.js} +10 -9
- package/dist/{spirit-4ECYIVGE.js → spirit-I5ECLGRI.js} +16 -2
- package/dist/{spirit-availability-Y54Y3675.js → spirit-availability-NIUUYKU2.js} +11 -10
- package/dist/{sprout-GJMZFRGZ.js → sprout-A2PMEF7Z.js} +1 -1
- package/dist/{system-events-V43HDA3T.js → system-events-YTOU66QI.js} +5 -1
- package/dist/{systems-VDRUZXNF.js → systems-2DIJY4RF.js} +6 -6
- package/dist/{turn-tracker-C5PVIC3B.js → turn-tracker-NNHS6MAR.js} +4 -2
- package/dist/{up-H6LJZDRK.js → up-UGBHH743.js} +1 -1
- package/dist/{variant-cleanup-STC6B5HR.js → variant-cleanup-CZDLIY3F.js} +7 -5
- package/dist/{version-notify-LBVHH5YV.js → version-notify-SJVAQLZI.js} +4 -4
- package/dist/web-assets/assets/index-BHSU9r-4.css +1 -0
- package/dist/web-assets/assets/{index-B_eYTa3R.js → index-b7G7ZsVk.js} +13 -13
- package/dist/web-assets/index.html +2 -2
- package/package.json +1 -1
- package/templates/_base/.init/.config/prompts.json +1 -1
- package/templates/_base/src/lib/codex-session-seed.ts +463 -0
- 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/seed-note.ts +78 -0
- package/templates/_base/src/lib/session-seed.ts +375 -0
- package/templates/_base/src/lib/startup.ts +108 -4
- package/templates/claude/.init/CLAUDE.md +3 -3
- package/templates/claude/src/agent.ts +267 -89
- package/templates/claude/src/lib/hooks/pre-compact.ts +30 -5
- package/templates/claude/src/lib/stream-consumer.ts +13 -0
- package/templates/claude/src/server.ts +2 -1
- package/templates/codex/.init/AGENTS.md +3 -3
- package/templates/codex/src/agent.ts +256 -39
- package/templates/codex/src/server.ts +2 -1
- package/templates/pi/.init/MINDS.md +3 -3
- package/templates/pi/src/agent.ts +269 -43
- package/templates/pi/src/lib/event-handler.ts +11 -0
- package/templates/pi/src/server.ts +3 -1
- package/dist/web-assets/assets/index-DhZrzddW.css +0 -1
- package/dist/{backup-YVGV2ZNM.js → backup-WWXVIMYQ.js} +5 -5
- package/dist/{bridge-YK7NTRU5.js → bridge-KU2IKSH3.js} +3 -3
- package/dist/{channels-FSAHOQWI.js → channels-LW5ZLMSQ.js} +3 -3
- package/dist/{chunk-VPOMJMV6.js → chunk-BDHKCLWK.js} +0 -0
- package/dist/{chunk-EEOMA4DX.js → chunk-HTB7KDRU.js} +3 -3
- package/dist/{create-M727VUDF.js → create-C3XZACTV.js} +3 -3
- package/dist/{env-IBW2QI6K.js → env-BJGM6FBA.js} +5 -5
- package/dist/{extension-MRYJWSMV.js → extension-B2W52WZY.js} +3 -3
- package/dist/{list-UQGIW7OF.js → list-R2GDWOEE.js} +3 -3
- package/dist/{login-RJJTCVR3.js → login-EZ4RRDQX.js} +3 -3
- package/dist/{login-SB5OENMV.js → login-JDOAK465.js} +4 -4
- package/dist/{logout-YFZAJRPY.js → logout-K6ZGAJY5.js} +3 -3
- package/dist/{logout-BYV433X6.js → logout-MWSIUOQU.js} +3 -3
- package/dist/{mind-wake-LDGPRA42.js → mind-wake-DCDFFQKX.js} +3 -3
- package/dist/{read-RJ5NAW5J.js → read-J5RM674N.js} +3 -3
- package/dist/{register-OX4HRSGC.js → register-53LBGSDC.js} +3 -3
- package/dist/{restart-AGEGNSYQ.js → restart-R2WFNF5K.js} +4 -4
- package/dist/{skill-B5OJEQUA.js → skill-DXLLSUHB.js} +5 -5
- package/dist/{start-MUMNFR52.js → start-XDOUAWN3.js} +4 -4
- package/dist/{status-MWLE3RVC.js → status-EYG4A3OK.js} +3 -3
- package/dist/{stop-EKERNYTX.js → stop-O62RTEMD.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,14 +23,22 @@ 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 { buildSeededNote, type SeedCause } from "./lib/seed-note.js";
|
|
25
27
|
import {
|
|
26
28
|
isSessionReapable,
|
|
27
29
|
reapSessionQuery,
|
|
28
30
|
reapSessionsForShutdown,
|
|
29
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";
|
|
30
39
|
import { createSessionStore } from "./lib/session-store.js";
|
|
31
40
|
import type { EffortLevel, ThinkingConfig } from "./lib/startup.js";
|
|
32
|
-
import { loadPrompts, type SubagentConfig } from "./lib/startup.js";
|
|
41
|
+
import { loadPrompts, renderCompactionWarning, type SubagentConfig } from "./lib/startup.js";
|
|
33
42
|
import { consumeStream } from "./lib/stream-consumer.js";
|
|
34
43
|
import type {
|
|
35
44
|
HandlerMeta,
|
|
@@ -56,8 +65,47 @@ type Session = {
|
|
|
56
65
|
contextTokens: number;
|
|
57
66
|
/** Last inbound message or completed turn — drives idle reaping. */
|
|
58
67
|
lastActivityAt: number;
|
|
68
|
+
/**
|
|
69
|
+
* True when this session was seeded from the previous session's transcript.
|
|
70
|
+
* Injects the honest-boundary note on each prompt until a turn resolves (see
|
|
71
|
+
* the pre-prompt hook and onTurnEnd), so an interrupted first turn re-offers it.
|
|
72
|
+
*/
|
|
73
|
+
seeded: boolean;
|
|
74
|
+
/** When the seeded-from session was archived (epoch ms), for the gap note; null if unknown. */
|
|
75
|
+
seededArchivedAt: number | null;
|
|
76
|
+
/** Why the tail is seeded — picks the boundary note's wording. Last cause wins. */
|
|
77
|
+
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
|
+
/**
|
|
85
|
+
* Back-to-back rotations that did NOT bring context under the threshold (reset by
|
|
86
|
+
* any healthy turn). Guards against a runaway loop when the tail alone can't fit —
|
|
87
|
+
* e.g. a system prompt (large MEMORY.md) that already fills most of the window, which
|
|
88
|
+
* rotation can't trim. Past the cap we stop rotating and defer to native compaction.
|
|
89
|
+
*/
|
|
90
|
+
consecutiveRotations: number;
|
|
91
|
+
/**
|
|
92
|
+
* Rotation state machine (undefined = idle). The warning is pushed and the mind
|
|
93
|
+
* takes a wrap-up turn — with FULL context of the turns it's summarizing — before
|
|
94
|
+
* the session rotates:
|
|
95
|
+
* - "warned": a turn was mid-flight when we warned; it must finish before the
|
|
96
|
+
* wrap-up turn (which consumes the warning) runs.
|
|
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.
|
|
100
|
+
*/
|
|
101
|
+
rotationPhase?: RotationPhase;
|
|
59
102
|
};
|
|
60
103
|
|
|
104
|
+
type RotationPhase = "warned" | "rotateAfterTurn";
|
|
105
|
+
|
|
106
|
+
/** Stop self-rotating after this many back-to-back rotations that didn't reduce context. */
|
|
107
|
+
const MAX_CONSECUTIVE_ROTATIONS = 3;
|
|
108
|
+
|
|
61
109
|
export function createMind(options: {
|
|
62
110
|
systemPrompt: string;
|
|
63
111
|
cwd: string;
|
|
@@ -72,6 +120,8 @@ export function createMind(options: {
|
|
|
72
120
|
onIdentityReload?: () => Promise<void>;
|
|
73
121
|
/** Idle minutes before a session's SDK subprocess is reaped. 0 disables. Default 30. */
|
|
74
122
|
sessionIdleMinutes?: number;
|
|
123
|
+
/** Estimated-token budget for seeding a fresh persistent session. 0 disables. Default 30000. */
|
|
124
|
+
seedTokens?: number;
|
|
75
125
|
}): {
|
|
76
126
|
resolve: HandlerResolver;
|
|
77
127
|
waitForCommits: () => Promise<void>;
|
|
@@ -88,20 +138,15 @@ export function createMind(options: {
|
|
|
88
138
|
|
|
89
139
|
const sessions = new Map<string, Session>();
|
|
90
140
|
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;
|
|
141
|
+
const compactionMessage = () =>
|
|
142
|
+
options.compactionMessage ?? renderCompactionWarning(prompts.compaction_warning);
|
|
96
143
|
const maxContextTokens = options.maxContextTokens;
|
|
144
|
+
const seedTokens = options.seedTokens ?? DEFAULT_SEED_TOKENS;
|
|
97
145
|
|
|
98
146
|
if (maxContextTokens) {
|
|
99
147
|
log("mind", `compaction threshold: ${maxContextTokens} tokens`);
|
|
100
148
|
}
|
|
101
149
|
|
|
102
|
-
// Per-session compaction state
|
|
103
|
-
const compactionTriggered = new Map<string, boolean>();
|
|
104
|
-
|
|
105
150
|
// --- Subagents (config-driven) ---
|
|
106
151
|
|
|
107
152
|
type SDKAgent = {
|
|
@@ -242,11 +287,43 @@ export function createMind(options: {
|
|
|
242
287
|
}
|
|
243
288
|
}
|
|
244
289
|
// Only UserPromptSubmit hooks can inject additionalContext into the conversation
|
|
245
|
-
if (event !== "pre-prompt"
|
|
290
|
+
if (event !== "pre-prompt") return {};
|
|
291
|
+
let additionalContext = result.additionalContext;
|
|
292
|
+
// On a seeded session, prepend the honest-boundary note. Deliberately NOT
|
|
293
|
+
// cleared here: this hook only hands the note to the SDK at *dispatch*, and
|
|
294
|
+
// the SDK cancels UserPromptSubmit hooks when an interrupt arrives (a
|
|
295
|
+
// `hook_cancelled` transcript entry). That's most likely on the seeded first
|
|
296
|
+
// turn post-wake, when queued inbound messages flood in — exactly when we'd
|
|
297
|
+
// lose the note. Clearing on a mere injection attempt could drop it forever
|
|
298
|
+
// and silently. So the flag is cleared only when a turn actually resolves
|
|
299
|
+
// (onTurnEnd); an interrupted turn re-offers the note next prompt. Worst case
|
|
300
|
+
// it fires twice (cosmetic) instead of zero times (unrecoverable, invisible).
|
|
301
|
+
if (session.seeded) {
|
|
302
|
+
const note = buildSeededNote({
|
|
303
|
+
cause: session.seededCause,
|
|
304
|
+
archivedAtMs: session.seededArchivedAt,
|
|
305
|
+
});
|
|
306
|
+
additionalContext = additionalContext ? `${note}\n\n${additionalContext}` : note;
|
|
307
|
+
// Also surface the note as its own context event (matching codex/pi) so it
|
|
308
|
+
// isn't delivered only in the same low-salience container as routine
|
|
309
|
+
// per-prompt hooks. Once per injection attempt; a duplicate on re-offer is fine.
|
|
310
|
+
const channel = session.currentMessageId
|
|
311
|
+
? session.messageChannels.get(session.currentMessageId)?.channel
|
|
312
|
+
: undefined;
|
|
313
|
+
daemonEmit({
|
|
314
|
+
type: "context",
|
|
315
|
+
content: note,
|
|
316
|
+
metadata: { source: "seeded-session" },
|
|
317
|
+
session: session.name,
|
|
318
|
+
channel,
|
|
319
|
+
messageId: session.currentMessageId,
|
|
320
|
+
}).catch((err) => log("mind", "seeded-session context emit failed:", err));
|
|
321
|
+
}
|
|
322
|
+
if (!additionalContext) return {};
|
|
246
323
|
return {
|
|
247
324
|
hookSpecificOutput: {
|
|
248
325
|
hookEventName: "UserPromptSubmit" as const,
|
|
249
|
-
additionalContext
|
|
326
|
+
additionalContext,
|
|
250
327
|
},
|
|
251
328
|
};
|
|
252
329
|
} catch (err) {
|
|
@@ -317,17 +394,61 @@ export function createMind(options: {
|
|
|
317
394
|
let currentSessionId = savedSessionId;
|
|
318
395
|
let streamAbort = new AbortController();
|
|
319
396
|
|
|
320
|
-
|
|
397
|
+
/**
|
|
398
|
+
* Warn the mind that the session will rotate at the context limit and pin the
|
|
399
|
+
* cut point it's told about. Computes which whole turns fit in `seedTokens`
|
|
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";
|
|
321
435
|
session.messageIds.push(undefined);
|
|
322
436
|
session.channel.push({
|
|
323
437
|
type: "user",
|
|
324
438
|
session_id: "",
|
|
325
|
-
message: {
|
|
326
|
-
role: "user",
|
|
327
|
-
content: [{ type: "text", text: compactionMessage }],
|
|
328
|
-
},
|
|
439
|
+
message: { role: "user", content: [{ type: "text", text: warning }] },
|
|
329
440
|
parent_tool_use_id: null,
|
|
330
441
|
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// PreCompact backstop: first fire warns + pins + enters the rotation machine
|
|
445
|
+
// (unless already warned, or the rotation cap is hit) and blocks; second fire
|
|
446
|
+
// allows the SDK's native compaction — the emergency backstop when rotation never
|
|
447
|
+
// intervened (a hung turn, or a system prompt too large for rotation to relieve).
|
|
448
|
+
const preCompact = createPreCompactHook(() => {
|
|
449
|
+
if (!session.rotationPhase && session.consecutiveRotations < MAX_CONSECUTIVE_ROTATIONS) {
|
|
450
|
+
warnAndPinRotation();
|
|
451
|
+
}
|
|
331
452
|
});
|
|
332
453
|
|
|
333
454
|
const callbacks = {
|
|
@@ -341,15 +462,25 @@ export function createMind(options: {
|
|
|
341
462
|
// in-flight set so a later compaction abort won't re-feed it.
|
|
342
463
|
session.channel.ack();
|
|
343
464
|
session.lastActivityAt = Date.now();
|
|
465
|
+
// A turn resolved — the seeded note's injection had its chance to land
|
|
466
|
+
// (the pre-prompt hook ran and wasn't cancelled by an interrupt), so stop
|
|
467
|
+
// re-offering it. This is the honest place to clear: a completed turn is
|
|
468
|
+
// the closest-to-arrival signal available without reading the transcript back.
|
|
469
|
+
session.seeded = false;
|
|
344
470
|
await autoCommit.flushFileChanges();
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
471
|
+
if (session.rotationPhase === "warned") {
|
|
472
|
+
// The mid-flight turn finished; the queued warning runs next as the wrap-up
|
|
473
|
+
// turn. Advance — do NOT abort yet, so the mind summarizes with full context.
|
|
474
|
+
session.rotationPhase = "rotateAfterTurn";
|
|
475
|
+
} else if (session.rotationPhase === "rotateAfterTurn") {
|
|
476
|
+
// The wrap-up turn is done (summaries authored) — abort to rotate in place.
|
|
477
|
+
log("mind", `session "${session.name}": wrap-up turn done — aborting to rotate`);
|
|
350
478
|
streamAbort.abort(new CompactionAbort());
|
|
351
|
-
} else
|
|
352
|
-
|
|
479
|
+
} else {
|
|
480
|
+
// A healthy turn (context under the threshold) — the rotation streak, if
|
|
481
|
+
// any, is over, so re-arm the self-rotation cap.
|
|
482
|
+
session.consecutiveRotations = 0;
|
|
483
|
+
if (identityReload.shouldRequestReload()) options.onIdentityReload?.();
|
|
353
484
|
}
|
|
354
485
|
},
|
|
355
486
|
onContextTokens: (tokens: number) => {
|
|
@@ -357,64 +488,18 @@ export function createMind(options: {
|
|
|
357
488
|
if (
|
|
358
489
|
maxContextTokens &&
|
|
359
490
|
tokens >= maxContextTokens &&
|
|
360
|
-
!
|
|
491
|
+
!session.rotationPhase &&
|
|
492
|
+
session.consecutiveRotations < MAX_CONSECUTIVE_ROTATIONS
|
|
361
493
|
) {
|
|
362
|
-
compactionTriggered.set(session.name, true);
|
|
363
494
|
log(
|
|
364
495
|
"mind",
|
|
365
|
-
`session "${session.name}": ${tokens} tokens >= ${maxContextTokens} —
|
|
496
|
+
`session "${session.name}": ${tokens} tokens >= ${maxContextTokens} — warning + pinning rotation`,
|
|
366
497
|
);
|
|
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
|
-
});
|
|
498
|
+
warnAndPinRotation();
|
|
377
499
|
}
|
|
378
500
|
},
|
|
379
501
|
};
|
|
380
502
|
|
|
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
503
|
/** Emit done to both local listeners and the daemon (best-effort with retries). */
|
|
419
504
|
function emitDone() {
|
|
420
505
|
broadcastToSession(session, { type: "done" });
|
|
@@ -457,30 +542,69 @@ export function createMind(options: {
|
|
|
457
542
|
streamAbort.signal.reason instanceof CompactionAbort &&
|
|
458
543
|
currentSessionId
|
|
459
544
|
) {
|
|
460
|
-
// Stream was aborted
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
545
|
+
// Stream was aborted to rotate: replace the session with a synthetic one
|
|
546
|
+
// holding the verbatim recent tail (from the pinned cut), then resume it.
|
|
547
|
+
const rotatedId = rotateSession({
|
|
548
|
+
cwd: options.cwd,
|
|
549
|
+
sessionsDir: options.sessionsDir,
|
|
550
|
+
name: session.name,
|
|
551
|
+
oldSessionId: currentSessionId,
|
|
552
|
+
cut: session.rotationCut,
|
|
553
|
+
seedTokens,
|
|
554
|
+
});
|
|
555
|
+
session.rotationCut = null;
|
|
556
|
+
session.rotationPhase = undefined; // state machine consumed
|
|
557
|
+
if (!rotatedId) {
|
|
558
|
+
// Rotation couldn't proceed — fall back to a fresh session (no seed).
|
|
559
|
+
// Unlike a successful rotation (boundary note, verbatim tail, archived
|
|
560
|
+
// transcript), this genuinely loses the live context — say so (#367).
|
|
561
|
+
log("mind", `session "${session.name}": rotation failed, starting fresh`);
|
|
562
|
+
daemonNotice({
|
|
563
|
+
kind: "context_lost",
|
|
564
|
+
thread: session.name,
|
|
565
|
+
message:
|
|
566
|
+
"Session rotation failed at the context limit and this thread was " +
|
|
567
|
+
"reset — conversation context before this point was lost. Your turn " +
|
|
568
|
+
"summaries survive in `volute mind history`; check your journal for " +
|
|
569
|
+
"where you left off.",
|
|
570
|
+
}).catch((err) =>
|
|
571
|
+
log("mind", `session "${session.name}": failed to record notice:`, err),
|
|
468
572
|
);
|
|
469
573
|
sessionStore.delete(session.name);
|
|
470
574
|
currentSessionId = undefined;
|
|
575
|
+
session.seeded = false;
|
|
471
576
|
streamAbort = new AbortController();
|
|
472
577
|
session.channel = createMessageChannel();
|
|
473
578
|
break;
|
|
474
579
|
}
|
|
580
|
+
// Point the live pointer at the rotated session and arm the boundary
|
|
581
|
+
// note (rotation cause — no gap). Ephemeral `new-*` keep no pointer.
|
|
582
|
+
if (!session.name.startsWith("new-")) sessionStore.save(session.name, rotatedId);
|
|
583
|
+
currentSessionId = rotatedId;
|
|
584
|
+
session.seeded = true;
|
|
585
|
+
session.seededCause = "rotation";
|
|
586
|
+
session.seededArchivedAt = null;
|
|
587
|
+
// Count this rotation; a healthy turn resets it. If back-to-back rotations
|
|
588
|
+
// don't reduce context (system prompt too large to fit the tail under the
|
|
589
|
+
// threshold), the cap stops the loop and defers to native compaction.
|
|
590
|
+
session.consecutiveRotations++;
|
|
591
|
+
if (session.consecutiveRotations >= MAX_CONSECUTIVE_ROTATIONS) {
|
|
592
|
+
log(
|
|
593
|
+
"mind",
|
|
594
|
+
`session "${session.name}": ${session.consecutiveRotations} rotations without relief — deferring further compaction to the SDK (system prompt likely too large)`,
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
// Re-arm the PreCompact backstop so a future auto-compact blocks + rotates
|
|
598
|
+
// again rather than falling straight through to native compaction.
|
|
599
|
+
preCompact.reset();
|
|
475
600
|
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.
|
|
601
|
+
// Recover input the aborted stream had already pulled but not finished
|
|
602
|
+
// (the wrap-up warning plus anything that arrived mid-turn) so nothing is
|
|
603
|
+
// dropped when the killed subprocess takes its buffer with it.
|
|
480
604
|
const pending = session.channel.recover();
|
|
481
605
|
session.channel = createMessageChannel();
|
|
482
606
|
for (const msg of pending) session.channel.push(msg);
|
|
483
|
-
continue; // restart the stream loop
|
|
607
|
+
continue; // restart the stream loop on the rotated session
|
|
484
608
|
}
|
|
485
609
|
throw err; // rethrow non-compaction errors
|
|
486
610
|
}
|
|
@@ -489,8 +613,21 @@ export function createMind(options: {
|
|
|
489
613
|
session.messageChannels.clear();
|
|
490
614
|
if (currentSessionId) {
|
|
491
615
|
log("mind", `session "${session.name}": resume failed, starting fresh:`, err);
|
|
616
|
+
daemonNotice({
|
|
617
|
+
kind: "context_lost",
|
|
618
|
+
thread: session.name,
|
|
619
|
+
message:
|
|
620
|
+
"Your session couldn't be resumed after an error, so you're starting fresh. " +
|
|
621
|
+
"Recent context may be in memory/journal/.",
|
|
622
|
+
}).catch((e) => log("mind", `session "${session.name}": failed to record notice:`, e));
|
|
492
623
|
sessionStore.delete(session.name);
|
|
493
624
|
currentSessionId = undefined;
|
|
625
|
+
// We fell back to a truly empty session — don't tell the mind its
|
|
626
|
+
// conversation continued when the seeded transcript failed to resume,
|
|
627
|
+
// and don't let a half-entered rotation leak into the fresh session.
|
|
628
|
+
session.seeded = false;
|
|
629
|
+
session.rotationPhase = undefined;
|
|
630
|
+
session.rotationCut = null;
|
|
494
631
|
streamAbort = new AbortController();
|
|
495
632
|
session.channel = createMessageChannel();
|
|
496
633
|
try {
|
|
@@ -531,6 +668,11 @@ export function createMind(options: {
|
|
|
531
668
|
eventNoteFired: false,
|
|
532
669
|
contextTokens: 0,
|
|
533
670
|
lastActivityAt: Date.now(),
|
|
671
|
+
seeded: false,
|
|
672
|
+
seededArchivedAt: null,
|
|
673
|
+
seededCause: "restored",
|
|
674
|
+
rotationCut: null,
|
|
675
|
+
consecutiveRotations: 0,
|
|
534
676
|
};
|
|
535
677
|
sessions.set(name, session);
|
|
536
678
|
|
|
@@ -542,9 +684,41 @@ export function createMind(options: {
|
|
|
542
684
|
log("mind", `session "${name}": stored session ${savedSessionId} not found, starting fresh`);
|
|
543
685
|
sessionStore.delete(name);
|
|
544
686
|
savedSessionId = undefined;
|
|
687
|
+
// Worded so it stays true whether or not transcript seeding (below) partially
|
|
688
|
+
// restores context: the live session is gone either way.
|
|
689
|
+
daemonNotice({
|
|
690
|
+
kind: "context_lost",
|
|
691
|
+
thread: name,
|
|
692
|
+
message:
|
|
693
|
+
"Your previous session couldn't be restored (session file missing), so this " +
|
|
694
|
+
"thread was reset. Recent context may be in memory/journal/.",
|
|
695
|
+
}).catch((err) => log("mind", `session "${name}": failed to record notice:`, err));
|
|
545
696
|
}
|
|
546
697
|
if (savedSessionId) {
|
|
547
698
|
log("mind", `session "${name}": resuming ${savedSessionId}`);
|
|
699
|
+
} else if (!isEphemeral) {
|
|
700
|
+
// Fresh persistent session — seed it from the previous session's archived
|
|
701
|
+
// transcript so the mind experiences the conversation continuing rather
|
|
702
|
+
// than waking into an empty context. Ephemeral `new-*` sessions never seed.
|
|
703
|
+
const seeded = seedSession({
|
|
704
|
+
cwd: options.cwd,
|
|
705
|
+
sessionsDir: options.sessionsDir,
|
|
706
|
+
name,
|
|
707
|
+
seedTokens: options.seedTokens ?? DEFAULT_SEED_TOKENS,
|
|
708
|
+
});
|
|
709
|
+
if (seeded) {
|
|
710
|
+
sessionStore.save(name, seeded.sessionId);
|
|
711
|
+
savedSessionId = seeded.sessionId;
|
|
712
|
+
session.seeded = true;
|
|
713
|
+
session.seededArchivedAt = seeded.archivedAt;
|
|
714
|
+
session.seededCause = "restored";
|
|
715
|
+
log(
|
|
716
|
+
"mind",
|
|
717
|
+
`session "${name}": seeded from previous transcript, resuming ${seeded.sessionId}`,
|
|
718
|
+
);
|
|
719
|
+
} else {
|
|
720
|
+
log("mind", `session "${name}": starting fresh`);
|
|
721
|
+
}
|
|
548
722
|
} else {
|
|
549
723
|
log("mind", `session "${name}": starting fresh`);
|
|
550
724
|
}
|
|
@@ -565,7 +739,6 @@ export function createMind(options: {
|
|
|
565
739
|
// Delete first so a racing inbound message spins up a fresh resumed session
|
|
566
740
|
// instead of reusing the one we're tearing down.
|
|
567
741
|
sessions.delete(session.name);
|
|
568
|
-
compactionTriggered.delete(session.name);
|
|
569
742
|
// End the input iterable so the stream consumer unwinds (its finally block
|
|
570
743
|
// also deletes from the map, now a no-op), then await the SDK's graceful
|
|
571
744
|
// shutdown via query.return() — unlike the fire-and-forget close(), this
|
|
@@ -605,7 +778,12 @@ export function createMind(options: {
|
|
|
605
778
|
const reaper = setInterval(() => {
|
|
606
779
|
const now = Date.now();
|
|
607
780
|
const stale = [...sessions.values()].filter((s) =>
|
|
608
|
-
isSessionReapable(
|
|
781
|
+
isSessionReapable(
|
|
782
|
+
s,
|
|
783
|
+
now,
|
|
784
|
+
idleTimeoutMs,
|
|
785
|
+
(name) => sessions.get(name)?.rotationPhase !== undefined,
|
|
786
|
+
),
|
|
609
787
|
);
|
|
610
788
|
// Reaps run independently; each awaits its own subprocess exit internally.
|
|
611
789
|
for (const session of stale) {
|
|
@@ -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
|
}
|
|
@@ -43,9 +43,13 @@ export async function consumeStream(
|
|
|
43
43
|
callbacks: StreamCallbacks,
|
|
44
44
|
) {
|
|
45
45
|
emit(session, { type: "session_start" });
|
|
46
|
+
// How many queued message ids predate the current turn — see the pruning in
|
|
47
|
+
// the result handler (#700).
|
|
48
|
+
let preTurnPending = 0;
|
|
46
49
|
for await (const msg of stream) {
|
|
47
50
|
if (session.currentMessageId === undefined) {
|
|
48
51
|
session.currentMessageId = session.messageIds.shift();
|
|
52
|
+
preTurnPending = session.messageIds.length;
|
|
49
53
|
}
|
|
50
54
|
if ("session_id" in msg && msg.session_id) {
|
|
51
55
|
callbacks.onSessionId?.(msg.session_id as string);
|
|
@@ -119,6 +123,15 @@ export async function consumeStream(
|
|
|
119
123
|
if (session.currentMessageId) {
|
|
120
124
|
session.messageChannels.delete(session.currentMessageId);
|
|
121
125
|
}
|
|
126
|
+
// Prune every id folded into this turn — the ones pushed after it started
|
|
127
|
+
// — not just currentMessageId. The SDK folds mid-run arrivals into the
|
|
128
|
+
// active run (they never see a result of their own), and a stranded entry
|
|
129
|
+
// would be shifted in as the NEXT turn's id, tagging that turn's rows with
|
|
130
|
+
// the wrong channel (#700). Ids queued before the turn started stay: each
|
|
131
|
+
// still gets a run (and result) of its own.
|
|
132
|
+
for (const id of session.messageIds.splice(preTurnPending)) {
|
|
133
|
+
if (id !== undefined) session.messageChannels.delete(id);
|
|
134
|
+
}
|
|
122
135
|
log("mind", `session "${session.name}": turn done`);
|
|
123
136
|
// Log any error messages from the result
|
|
124
137
|
const resultMsg = msg as Record<string, unknown>;
|
|
@@ -20,7 +20,7 @@ if (config.model) log("server", `using model: ${config.model}`);
|
|
|
20
20
|
if (config.thinking) log("server", `thinking: ${JSON.stringify(config.thinking)}`);
|
|
21
21
|
if (config.effort) log("server", `effort: ${config.effort}`);
|
|
22
22
|
|
|
23
|
-
const systemPrompt = loadSystemPrompt();
|
|
23
|
+
const systemPrompt = loadSystemPrompt(config);
|
|
24
24
|
const sessionsDir = resolve(".mind/sessions");
|
|
25
25
|
|
|
26
26
|
const pkg = loadPackageInfo();
|
|
@@ -36,6 +36,7 @@ const mind = createMind({
|
|
|
36
36
|
compactionMessage: config.compactionMessage,
|
|
37
37
|
maxContextTokens: config.compaction?.maxContextTokens,
|
|
38
38
|
sessionIdleMinutes: config.sessionIdleMinutes,
|
|
39
|
+
seedTokens: config.continuity?.seedTokens,
|
|
39
40
|
subagents: config.subagents,
|
|
40
41
|
onIdentityReload: async () => {
|
|
41
42
|
log("server", "identity file changed — restarting to reload");
|
|
@@ -17,9 +17,9 @@ You can also reach out proactively — see the **volute-mind** skill.
|
|
|
17
17
|
|
|
18
18
|
Two-tier memory, both managed via file tools:
|
|
19
19
|
|
|
20
|
-
- **`MEMORY.md`** — Your
|
|
21
|
-
- **`memory/journal/YYYY-MM-DD.md
|
|
22
|
-
- Periodically consolidate
|
|
20
|
+
- **`MEMORY.md`** — Your core memory, always in context. Every token it holds is paid on every request, so keep it lean: identity, active context, key relationships, and an index of your `memory/` files.
|
|
21
|
+
- **`memory/`** — Everything else, read on demand: your daily journal (`memory/journal/YYYY-MM-DD.md` — a permanent record of what you're doing, thinking, and learning) plus any topic, channel, or project files you create.
|
|
22
|
+
- Periodically consolidate: distill detail out of `MEMORY.md` into `memory/` files, leaving one-line index entries behind.
|
|
23
23
|
|
|
24
24
|
See the **memory** skill for detailed guidance.
|
|
25
25
|
|