volute 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/api.d.ts +4294 -0
  2. package/dist/chunk-G5KRTU2F.js +76 -0
  3. package/dist/chunk-ISWZ6QUK.js +2691 -0
  4. package/dist/{chunk-J5A3DF2U.js → chunk-JG4CCJOA.js} +1 -1
  5. package/dist/{chunk-IPJXU366.js → chunk-JTDFJWI2.js} +1 -0
  6. package/dist/{chunk-7LPTHFIL.js → chunk-M5CNKH4J.js} +55 -5
  7. package/dist/{chunk-L3LHXZD7.js → chunk-PHHKNGA3.js} +1 -1
  8. package/dist/{chunk-Q7AITQ44.js → chunk-QIXPN3OO.js} +1 -1
  9. package/dist/{chunk-PC6R6UUW.js → chunk-RK627D57.js} +36 -59
  10. package/dist/{chunk-5462YKWP.js → chunk-TFS25FIM.js} +1 -1
  11. package/dist/{chunk-QUJUKM4U.js → chunk-VT5QODNE.js} +1 -1
  12. package/dist/chunk-XLC342FO.js +29 -0
  13. package/dist/cli.js +10 -10
  14. package/dist/cloud-sync-PI47U2LT.js +96 -0
  15. package/dist/{daemon-restart-BH67ZOTE.js → daemon-restart-RMGOOGPE.js} +4 -4
  16. package/dist/daemon.js +1216 -1822
  17. package/dist/{down-LIOQ5JDH.js → down-WSUASL5E.js} +3 -3
  18. package/dist/{import-E433B4KG.js → import-EAXTHHXL.js} +2 -1
  19. package/dist/message-delivery-FHV4NO2F.js +23 -0
  20. package/dist/{mind-BIDOF65R.js → mind-BTXR5B3C.js} +13 -5
  21. package/dist/{mind-manager-3V2NXX4I.js → mind-manager-KMY4GA2J.js} +1 -1
  22. package/dist/mind-sleep-FWRBIFBS.js +41 -0
  23. package/dist/mind-wake-LJK2YU5X.js +36 -0
  24. package/dist/{package-HQR52XSG.js → package-CUBJ4PKS.js} +10 -1
  25. package/dist/{pages-KQBR5TAZ.js → pages-YSTRWJR4.js} +1 -1
  26. package/dist/{publish-OJ4QMXVZ.js → publish-BZNHKUUK.js} +2 -2
  27. package/dist/{service-TVNEORO7.js → service-7BFXDI6J.js} +4 -4
  28. package/dist/{setup-OZDYCKDI.js → setup-SSIIXQMI.js} +2 -2
  29. package/dist/sleep-manager-2TMQ65E4.js +27 -0
  30. package/dist/{sprout-6Z6C42YM.js → sprout-UKCYBGHK.js} +2 -2
  31. package/dist/{status-Z7NAFMBI.js → status-H2MKDN6L.js} +2 -2
  32. package/dist/{up-7BGDMFRT.js → up-Z5JRG2M2.js} +3 -3
  33. package/dist/{update-4WT7VWHW.js → update-ELC6MEUT.js} +2 -2
  34. package/dist/{upgrade-ZEC2GGFO.js → upgrade-GXW2EQY3.js} +11 -2
  35. package/dist/{version-notify-TFS2U5CF.js → version-notify-LKABEJSA.js} +11 -3
  36. package/dist/web-assets/assets/index-CZ26vsyY.js +69 -0
  37. package/dist/web-assets/assets/index-DyyAvJwW.css +1 -0
  38. package/dist/web-assets/index.html +2 -2
  39. package/package.json +10 -1
  40. package/templates/_base/.init/.config/prompts.json +1 -0
  41. package/templates/_base/home/.config/config.json.tmpl +4 -1
  42. package/templates/_base/src/lib/file-handler.ts +6 -1
  43. package/templates/_base/src/lib/logger.ts +68 -23
  44. package/templates/_base/src/lib/startup.ts +12 -3
  45. package/templates/claude/src/agent.ts +150 -29
  46. package/templates/claude/src/lib/hooks/pre-compact.ts +18 -4
  47. package/templates/claude/src/lib/message-channel.ts +6 -0
  48. package/templates/claude/src/lib/stream-consumer.ts +17 -1
  49. package/templates/claude/src/server.ts +3 -1
  50. package/templates/pi/home/.config/config.json.tmpl +4 -1
  51. package/templates/pi/src/agent.ts +87 -0
  52. package/templates/pi/src/lib/content.ts +18 -3
  53. package/templates/pi/src/lib/event-handler.ts +22 -2
  54. package/templates/pi/src/server.ts +3 -1
  55. package/dist/chunk-OGZYB5GL.js +0 -847
  56. package/dist/web-assets/assets/index-BR3gtK3E.css +0 -1
  57. package/dist/web-assets/assets/index-CWmrZRQd.js +0 -64
  58. /package/dist/{shared-DCQ2UXOM.js → shared-2OGT3NSL.js} +0 -0
@@ -1,14 +1,28 @@
1
- import type { HookCallback } from "@anthropic-ai/claude-agent-sdk";
1
+ import type { HookCallback, PreCompactHookInput } from "@anthropic-ai/claude-agent-sdk";
2
2
  import { log } from "../logger.js";
3
3
 
4
4
  export function createPreCompactHook(onCompact: () => void) {
5
5
  let compactBlocked = false;
6
6
 
7
- const hook: HookCallback = async () => {
7
+ const hook: HookCallback = async (input) => {
8
+ const { trigger, custom_instructions } = input as PreCompactHookInput;
9
+
10
+ // Our custom compaction (via /compact with instructions) — allow through without the two-pass block
11
+ if (trigger === "manual" && custom_instructions) {
12
+ log("mind", "allowing manual compaction with custom instructions");
13
+ return {};
14
+ }
15
+
16
+ // Auto-compaction: two-pass block (first pass warns mind, second pass allows)
8
17
  if (!compactBlocked) {
9
- compactBlocked = true;
10
18
  log("mind", "blocking compaction — asking mind to update daily log first");
11
- onCompact();
19
+ try {
20
+ onCompact();
21
+ compactBlocked = true;
22
+ } catch (err) {
23
+ log("mind", "onCompact callback failed, allowing compaction:", err);
24
+ return {};
25
+ }
12
26
  return { decision: "block" };
13
27
  }
14
28
  compactBlocked = false;
@@ -2,6 +2,7 @@ import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
2
2
 
3
3
  export type MessageChannel = {
4
4
  push: (msg: SDKUserMessage) => void;
5
+ drain: () => SDKUserMessage[];
5
6
  iterable: AsyncIterable<SDKUserMessage>;
6
7
  };
7
8
 
@@ -19,6 +20,11 @@ export function createMessageChannel(): MessageChannel {
19
20
  queue.push(msg);
20
21
  }
21
22
  },
23
+ drain() {
24
+ // Clear any pending iterator wait so it doesn't consume a message after drain
25
+ resolve = null;
26
+ return queue.splice(0);
27
+ },
22
28
  iterable: {
23
29
  [Symbol.asyncIterator]() {
24
30
  return {
@@ -1,6 +1,6 @@
1
1
  import type { query } from "@anthropic-ai/claude-agent-sdk";
2
2
  import { daemonEmit, type EventType } from "./daemon-client.js";
3
- import { log, logText, logThinking, logToolUse } from "./logger.js";
3
+ import { log, logText, logThinking, logToolUse, warn } from "./logger.js";
4
4
  import { filterEvent, loadTransparencyPreset } from "./transparency.js";
5
5
  import type { VoluteEvent } from "./types.js";
6
6
 
@@ -15,6 +15,7 @@ export type StreamCallbacks = {
15
15
  onSessionId?: (sessionId: string) => void;
16
16
  broadcast: (event: VoluteEvent) => void;
17
17
  onTurnEnd?: () => void;
18
+ onContextTokens?: (tokens: number) => void;
18
19
  };
19
20
 
20
21
  // Loaded once at startup — mind restarts on config changes
@@ -50,6 +51,12 @@ export async function consumeStream(
50
51
  callbacks.onSessionId?.(msg.session_id as string);
51
52
  }
52
53
  if (msg.type === "assistant") {
54
+ const usage = msg.message.usage as unknown as Record<string, unknown> | undefined;
55
+ const inputTokens = (usage?.input_tokens as number) ?? 0;
56
+ const cacheCreation = (usage?.cache_creation_input_tokens as number) ?? 0;
57
+ const cacheRead = (usage?.cache_read_input_tokens as number) ?? 0;
58
+ const contextTokens = inputTokens + cacheCreation + cacheRead;
59
+ if (contextTokens) callbacks.onContextTokens?.(contextTokens);
53
60
  for (const b of msg.message.content) {
54
61
  if (b.type === "thinking" && "thinking" in b && b.thinking) {
55
62
  const text = b.thinking as string;
@@ -75,6 +82,15 @@ export async function consumeStream(
75
82
  session.messageChannels.delete(session.currentMessageId);
76
83
  }
77
84
  log("mind", `session "${session.name}": turn done`);
85
+ // Log any error messages from the result
86
+ const resultMsg = msg as Record<string, unknown>;
87
+ if (Array.isArray(resultMsg.messages)) {
88
+ for (const m of resultMsg.messages) {
89
+ if (m && typeof m === "object" && "errorMessage" in m && m.errorMessage) {
90
+ warn("mind", `session "${session.name}": agent error: ${m.errorMessage}`);
91
+ }
92
+ }
93
+ }
78
94
  const result = msg as { usage?: { input_tokens?: number; output_tokens?: number } };
79
95
  if (result.usage) {
80
96
  const usage = {
@@ -3,7 +3,7 @@ import { resolve } from "node:path";
3
3
  import { createMind } from "./agent.js";
4
4
  import { daemonRestart } from "./lib/daemon-client.js";
5
5
  import { createFileHandlerResolver } from "./lib/file-handler.js";
6
- import { log } from "./lib/logger.js";
6
+ import { log, setLevel } from "./lib/logger.js";
7
7
  import { createRouter } from "./lib/router.js";
8
8
  import {
9
9
  loadConfig,
@@ -16,6 +16,7 @@ import { createVoluteServer } from "./lib/volute-server.js";
16
16
 
17
17
  const { port } = parseArgs();
18
18
  const config = loadConfig();
19
+ if (config.logLevel) setLevel(config.logLevel);
19
20
  if (config.model) log("server", `using model: ${config.model}`);
20
21
  if (config.maxThinkingTokens) log("server", `max thinking tokens: ${config.maxThinkingTokens}`);
21
22
 
@@ -40,6 +41,7 @@ const mind = createMind({
40
41
  maxThinkingTokens: config.maxThinkingTokens,
41
42
  sessionsDir,
42
43
  compactionMessage: config.compactionMessage,
44
+ maxContextTokens: config.compaction?.maxContextTokens,
43
45
  onIdentityReload: async () => {
44
46
  log("server", "identity file changed — restarting to reload");
45
47
  await mind.waitForCommits();
@@ -1,3 +1,6 @@
1
1
  {
2
- "model": "openrouter:moonshotai/kimi-k2.5"
2
+ "model": "openrouter:moonshotai/kimi-k2.5",
3
+ "compaction": {
4
+ "maxContextTokens": 150000
5
+ }
3
6
  }
@@ -43,12 +43,19 @@ export function createMind(options: {
43
43
  model?: string;
44
44
  thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
45
45
  compactionMessage?: string;
46
+ maxContextTokens?: number;
46
47
  }): { resolve: HandlerResolver } {
47
48
  const sessions = new Map<string, PiSession>();
48
49
  const prompts = loadPrompts();
49
50
  const today = new Date().toLocaleDateString("en-CA");
50
51
  const compactionMessage =
51
52
  options.compactionMessage ?? prompts.compaction_warning.replace("${date}", today);
53
+ const compactionInstructions = prompts.compaction_instructions;
54
+ const maxContextTokens = options.maxContextTokens;
55
+
56
+ if (maxContextTokens) {
57
+ log("mind", `compaction threshold: ${maxContextTokens} tokens`);
58
+ }
52
59
 
53
60
  // Shared setup (created once)
54
61
  const modelStr = options.model || process.env.PI_MODEL || "anthropic:claude-sonnet-4-20250514";
@@ -88,9 +95,35 @@ export function createMind(options: {
88
95
 
89
96
  log("mind", `session "${session.name}": ${isEphemeral ? "ephemeral" : "persistent"}`);
90
97
 
98
+ // Compaction state machine:
99
+ // 1. onContextTokens sets compactionTriggered=true and sends warning
100
+ // 2. onTurnEnd (after warning turn): compactionTriggered -> compactOnNextTurnEnd
101
+ // 3. onTurnEnd (after mind's save turn): compactOnNextTurnEnd -> call compact()
91
102
  let compactBlocked = false;
103
+ let manualCompactPending = false;
104
+ let compactionTriggered = false;
105
+ let compactOnNextTurnEnd = false;
106
+ let compactionInProgress = false;
107
+
108
+ function resetCompactionState() {
109
+ compactionTriggered = false;
110
+ compactOnNextTurnEnd = false;
111
+ compactionInProgress = false;
112
+ }
113
+
92
114
  const preCompactExtension: ExtensionFactory = (pi) => {
93
115
  pi.on("session_before_compact", () => {
116
+ // Our programmatic compact() call (triggered by token threshold) — allow through
117
+ if (manualCompactPending) {
118
+ manualCompactPending = false;
119
+ log(
120
+ "mind",
121
+ `session "${session.name}": allowing manual compaction with custom instructions`,
122
+ );
123
+ return;
124
+ }
125
+
126
+ // Auto-compaction: two-pass block (first pass warns mind, second pass allows)
94
127
  if (!compactBlocked) {
95
128
  compactBlocked = true;
96
129
  log(
@@ -146,6 +179,60 @@ export function createMind(options: {
146
179
  createEventHandler(session, {
147
180
  cwd: options.cwd,
148
181
  broadcast: (event) => broadcast(session, event),
182
+ onContextTokens: maxContextTokens
183
+ ? (tokens: number) => {
184
+ if (tokens >= maxContextTokens && !compactionTriggered && !compactionInProgress) {
185
+ if (!session.agentSession) {
186
+ log(
187
+ "mind",
188
+ `session "${session.name}": compaction threshold hit but session not ready`,
189
+ );
190
+ return;
191
+ }
192
+ compactionTriggered = true;
193
+ log(
194
+ "mind",
195
+ `session "${session.name}": ${tokens} tokens >= ${maxContextTokens} — triggering compaction`,
196
+ );
197
+ // Send compaction warning; compaction will follow after the mind finishes its response turn
198
+ session.messageIds.push(undefined);
199
+ session.agentSession.prompt(compactionMessage, {
200
+ streamingBehavior: "followUp",
201
+ });
202
+ }
203
+ }
204
+ : undefined,
205
+ onTurnEnd: maxContextTokens
206
+ ? () => {
207
+ try {
208
+ // Compact on the turn AFTER the warning was sent (so the mind gets a turn to save state)
209
+ if (compactOnNextTurnEnd) {
210
+ compactOnNextTurnEnd = false;
211
+ manualCompactPending = true;
212
+ compactionInProgress = true;
213
+ log("mind", `session "${session.name}": compacting with custom instructions`);
214
+ Promise.resolve(session.agentSession?.compact(compactionInstructions))
215
+ .catch((err) =>
216
+ log("mind", `session "${session.name}": compact() failed:`, err),
217
+ )
218
+ .finally(() => {
219
+ compactionInProgress = false;
220
+ });
221
+ }
222
+ if (compactionTriggered) {
223
+ compactionTriggered = false;
224
+ compactOnNextTurnEnd = true;
225
+ }
226
+ } catch (err) {
227
+ log(
228
+ "mind",
229
+ `session "${session.name}": onTurnEnd error, resetting compaction state:`,
230
+ err,
231
+ );
232
+ resetCompactionState();
233
+ }
234
+ }
235
+ : undefined,
149
236
  }),
150
237
  );
151
238
 
@@ -1,14 +1,29 @@
1
1
  import type { ImageContent } from "@mariozechner/pi-ai";
2
- import type { VoluteContentPart } from "./types.js";
2
+ import { warn } from "./logger.js";
3
3
 
4
- export function extractText(content: VoluteContentPart[]): string {
4
+ export function extractText(content: unknown): string {
5
+ if (typeof content === "string") return content;
6
+ if (!Array.isArray(content)) {
7
+ warn(
8
+ "mind",
9
+ `extractText received unexpected ${typeof content} instead of VoluteContentPart[]`,
10
+ );
11
+ return JSON.stringify(content);
12
+ }
5
13
  return content
6
14
  .filter((p): p is { type: "text"; text: string } => p.type === "text")
7
15
  .map((p) => p.text)
8
16
  .join("\n");
9
17
  }
10
18
 
11
- export function extractImages(content: VoluteContentPart[]): ImageContent[] {
19
+ export function extractImages(content: unknown): ImageContent[] {
20
+ if (!Array.isArray(content)) {
21
+ warn(
22
+ "mind",
23
+ `extractImages received non-array content (${typeof content}) — images cannot be extracted`,
24
+ );
25
+ return [];
26
+ }
12
27
  return content
13
28
  .filter((p): p is { type: "image"; media_type: string; data: string } => p.type === "image")
14
29
  .map((p) => ({ type: "image" as const, mimeType: p.media_type, data: p.data }));
@@ -1,6 +1,6 @@
1
1
  import { commitFileChange } from "./auto-commit.js";
2
2
  import { daemonEmit, type EventType } from "./daemon-client.js";
3
- import { log, logText, logThinking, logToolResult, logToolUse } from "./logger.js";
3
+ import { log, logText, logThinking, logToolResult, logToolUse, warn } from "./logger.js";
4
4
  import { filterEvent, loadTransparencyPreset } from "./transparency.js";
5
5
  import type { VoluteEvent } from "./types.js";
6
6
 
@@ -14,6 +14,8 @@ export type EventSession = {
14
14
  export type EventHandlerOptions = {
15
15
  cwd: string;
16
16
  broadcast: (event: VoluteEvent) => void;
17
+ onContextTokens?: (tokens: number) => void;
18
+ onTurnEnd?: () => void;
17
19
  };
18
20
 
19
21
  // Loaded once at startup — mind restarts on config changes
@@ -133,14 +135,28 @@ export function createEventHandler(session: EventSession, options: EventHandlerO
133
135
  session.messageChannels.delete(session.currentMessageId);
134
136
  }
135
137
  log("mind", `session "${session.name}": turn done`);
136
- // Sum usage from assistant messages
138
+ // Log any error messages from the agent
139
+ if (event.messages) {
140
+ for (const msg of event.messages as any[]) {
141
+ if (msg.errorMessage) {
142
+ warn("mind", `session "${session.name}": agent error: ${msg.errorMessage}`);
143
+ }
144
+ }
145
+ }
146
+ // Sum usage from assistant messages. The last assistant message's input tokens
147
+ // approximate current context size (it includes the full conversation up to that point).
137
148
  if (event.messages) {
138
149
  let inputTokens = 0;
139
150
  let outputTokens = 0;
151
+ let lastInputTokens = 0;
140
152
  for (const msg of event.messages as any[]) {
141
153
  if (msg.role === "assistant" && msg.usage) {
142
154
  inputTokens += msg.usage.input ?? 0;
143
155
  outputTokens += msg.usage.output ?? 0;
156
+ const cacheWrite = msg.usage.cacheWrite ?? msg.usage.cache_creation ?? 0;
157
+ const cacheRead = msg.usage.cacheRead ?? msg.usage.cache_read ?? 0;
158
+ const contextTokens = (msg.usage.input ?? 0) + cacheWrite + cacheRead;
159
+ if (contextTokens) lastInputTokens = contextTokens;
144
160
  }
145
161
  }
146
162
  if (inputTokens > 0 || outputTokens > 0) {
@@ -148,10 +164,14 @@ export function createEventHandler(session: EventSession, options: EventHandlerO
148
164
  options.broadcast({ type: "usage", ...usage });
149
165
  emit(session, { type: "usage", metadata: usage });
150
166
  }
167
+ if (lastInputTokens > 0) {
168
+ options.onContextTokens?.(lastInputTokens);
169
+ }
151
170
  }
152
171
  options.broadcast({ type: "done" });
153
172
  emit(session, { type: "done" });
154
173
  session.currentMessageId = undefined;
174
+ options.onTurnEnd?.();
155
175
  }
156
176
  } catch (err) {
157
177
  log("mind", `session "${session.name}": event handler error (${event?.type}):`, err);
@@ -1,7 +1,7 @@
1
1
  import { resolve } from "node:path";
2
2
  import { createMind } from "./agent.js";
3
3
  import { createFileHandlerResolver } from "./lib/file-handler.js";
4
- import { log } from "./lib/logger.js";
4
+ import { log, setLevel } from "./lib/logger.js";
5
5
  import { createRouter } from "./lib/router.js";
6
6
  import {
7
7
  handleStartupContext,
@@ -15,6 +15,7 @@ import { createVoluteServer } from "./lib/volute-server.js";
15
15
 
16
16
  const { port } = parseArgs();
17
17
  const config = loadConfig();
18
+ if (config.logLevel) setLevel(config.logLevel);
18
19
  if (config.model) log("server", `using model: ${config.model}`);
19
20
  if (config.thinkingLevel) log("server", `thinking level: ${config.thinkingLevel}`);
20
21
 
@@ -29,6 +30,7 @@ const mind = createMind({
29
30
  model: config.model,
30
31
  thinkingLevel: config.thinkingLevel,
31
32
  compactionMessage: config.compactionMessage,
33
+ maxContextTokens: config.compaction?.maxContextTokens,
32
34
  });
33
35
 
34
36
  const router = createRouter({