volute 0.7.0 → 0.8.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 (77) hide show
  1. package/README.md +13 -13
  2. package/dist/{agent-7JF7MT73.js → agent-YORVRB6I.js} +10 -10
  3. package/dist/{agent-manager-IMZ7ZMBF.js → agent-manager-CMMH5KQQ.js} +4 -4
  4. package/dist/{channel-SMCNOIVQ.js → channel-RDGHBFSI.js} +16 -56
  5. package/dist/{chunk-JR4UXCTO.js → chunk-23L3MKEV.js} +1 -1
  6. package/dist/{chunk-5SKQ6J7T.js → chunk-5C5JWR2L.js} +15 -7
  7. package/dist/{chunk-UWHWAPGO.js → chunk-DP2DX4WV.js} +9 -1
  8. package/dist/{chunk-7ACDT3P2.js → chunk-ECPQXRLB.js} +1 -2
  9. package/dist/{chunk-LLJNZPCU.js → chunk-HZ5LTOEJ.js} +1 -1
  10. package/dist/{chunk-W76KWE23.js → chunk-IQXBMFZG.js} +6 -4
  11. package/dist/{chunk-ZZOOTYXK.js → chunk-LIPPXNIE.js} +60 -74
  12. package/dist/{chunk-BX7KI4S3.js → chunk-N6MLQ26B.js} +23 -96
  13. package/dist/{chunk-H7AMDUIA.js → chunk-QF22MYDJ.js} +6 -5
  14. package/dist/{chunk-NKXULRSW.js → chunk-RT6Y7AR3.js} +1 -1
  15. package/dist/{chunk-62X577Y7.js → chunk-W6TMWYU3.js} +126 -73
  16. package/dist/{chunk-EG45HBSJ.js → chunk-XSJ27WEM.js} +1 -1
  17. package/dist/cli.js +22 -20
  18. package/dist/{connector-Y7JPNROO.js → connector-ZP6MEFF4.js} +3 -3
  19. package/dist/connectors/discord.js +18 -59
  20. package/dist/connectors/slack.js +21 -38
  21. package/dist/connectors/telegram.js +31 -49
  22. package/dist/{create-G525LWEA.js → create-HGJHLABX.js} +22 -17
  23. package/dist/{daemon-client-442IV43D.js → daemon-client-54J3EIZD.js} +2 -2
  24. package/dist/{daemon-restart-4HVEKYFY.js → daemon-restart-CPBLMMRI.js} +3 -3
  25. package/dist/daemon.js +342 -402
  26. package/dist/{delete-UOU4AFQN.js → delete-45TGQC4N.js} +10 -5
  27. package/dist/{down-AZVH5TCD.js → down-O4EWZTVA.js} +2 -2
  28. package/dist/{env-7GLUJCWS.js → env-KMNYGVZ2.js} +7 -9
  29. package/dist/{history-H72ZUIBN.js → history-PXJVYLVY.js} +2 -2
  30. package/dist/{import-AVKQJDYC.js → import-CNEDF3TD.js} +6 -6
  31. package/dist/{logs-EDGK26AK.js → logs-TZB3MTLZ.js} +5 -4
  32. package/dist/{package-T2WAVJOU.js → package-5UCKNK6J.js} +1 -1
  33. package/dist/{restart-O4ETYLJF.js → restart-KVH3TK5N.js} +2 -2
  34. package/dist/{schedule-S6QVC5ON.js → schedule-HCUCBNQI.js} +2 -2
  35. package/dist/send-BNC2S5BY.js +162 -0
  36. package/dist/{service-HZNIDNJF.js → service-R4MCNBOA.js} +1 -1
  37. package/dist/{setup-F4TCWVSP.js → setup-JXDCJX7W.js} +25 -6
  38. package/dist/{start-VHQ7LNWM.js → start-QU73YTJW.js} +2 -2
  39. package/dist/{status-QAJWXKMZ.js → status-Q6ZQJXNI.js} +2 -2
  40. package/dist/{stop-CAGCT5NI.js → stop-N7U5N6A7.js} +2 -2
  41. package/dist/{up-RWZF6MLT.js → up-V6EAA7OZ.js} +2 -2
  42. package/dist/{update-F7QWV2LB.js → update-EUCZ7XGG.js} +3 -3
  43. package/dist/{update-check-B4J6IEQ4.js → update-check-SM4244SU.js} +2 -2
  44. package/dist/{upgrade-YXKPWDRU.js → upgrade-CZF6PN7Y.js} +4 -4
  45. package/dist/{variant-4Z6W3PP6.js → variant-RKXPN5DH.js} +20 -46
  46. package/dist/web-assets/assets/index-D-3zx6vs.js +307 -0
  47. package/dist/web-assets/index.html +1 -1
  48. package/drizzle/0004_magical_silverclaw.sql +1 -0
  49. package/drizzle/meta/0004_snapshot.json +410 -0
  50. package/drizzle/meta/_journal.json +7 -0
  51. package/package.json +1 -1
  52. package/templates/_base/_skills/volute-agent/SKILL.md +32 -16
  53. package/templates/_base/home/.config/routes.json +4 -8
  54. package/templates/_base/home/VOLUTE.md +16 -14
  55. package/templates/_base/src/lib/auto-reply.ts +38 -0
  56. package/templates/_base/src/lib/daemon-client.ts +53 -0
  57. package/templates/_base/src/lib/router.ts +66 -14
  58. package/templates/_base/src/lib/routing.ts +48 -9
  59. package/templates/_base/src/lib/startup.ts +1 -25
  60. package/templates/_base/src/lib/types.ts +2 -1
  61. package/templates/_base/src/lib/volute-server.ts +29 -14
  62. package/templates/agent-sdk/src/agent.ts +53 -111
  63. package/templates/agent-sdk/src/lib/content.ts +41 -0
  64. package/templates/agent-sdk/src/lib/session-store.ts +43 -0
  65. package/templates/agent-sdk/src/lib/stream-consumer.ts +66 -0
  66. package/templates/agent-sdk/src/server.ts +5 -13
  67. package/templates/pi/.init/AGENTS.md +5 -5
  68. package/templates/pi/src/agent.ts +32 -84
  69. package/templates/pi/src/lib/content.ts +15 -0
  70. package/templates/pi/src/lib/event-handler.ts +74 -0
  71. package/templates/pi/src/lib/resolve-model.ts +21 -0
  72. package/templates/pi/src/server.ts +3 -7
  73. package/dist/chunk-B3R6L2GW.js +0 -24
  74. package/dist/chunk-ZYGKG6VC.js +0 -22
  75. package/dist/message-SCOQDR3P.js +0 -32
  76. package/dist/send-G7PE4DOJ.js +0 -72
  77. package/dist/web-assets/assets/index-B1CqjUYD.js +0 -308
@@ -1,13 +1,19 @@
1
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
- import { resolve as resolvePath } from "node:path";
3
1
  import type { HookCallback } from "@anthropic-ai/claude-agent-sdk";
4
2
  import { query } from "@anthropic-ai/claude-agent-sdk";
3
+ import {
4
+ type AutoReplyTracker,
5
+ createAutoReplyTracker,
6
+ type MessageChannelInfo,
7
+ } from "./lib/auto-reply.js";
8
+ import { toSDKContent } from "./lib/content.js";
5
9
  import { createAutoCommitHook } from "./lib/hooks/auto-commit.js";
6
10
  import { createIdentityReloadHook } from "./lib/hooks/identity-reload.js";
7
11
  import { createPreCompactHook } from "./lib/hooks/pre-compact.js";
8
12
  import { createSessionContextHook } from "./lib/hooks/session-context.js";
9
- import { log, logText, logThinking, logToolUse } from "./lib/logger.js";
13
+ import { log } from "./lib/logger.js";
10
14
  import { createMessageChannel } from "./lib/message-channel.js";
15
+ import { createSessionStore } from "./lib/session-store.js";
16
+ import { consumeStream } from "./lib/stream-consumer.js";
11
17
  import type {
12
18
  HandlerMeta,
13
19
  HandlerResolver,
@@ -17,18 +23,6 @@ import type {
17
23
  VoluteEvent,
18
24
  } from "./lib/types.js";
19
25
 
20
- type SDKContent = (
21
- | { type: "text"; text: string }
22
- | {
23
- type: "image";
24
- source: {
25
- type: "base64";
26
- media_type: "image/jpeg" | "image/png" | "image/gif" | "image/webp";
27
- data: string;
28
- };
29
- }
30
- )[];
31
-
32
26
  type Session = {
33
27
  name: string;
34
28
  channel: ReturnType<typeof createMessageChannel>;
@@ -36,35 +30,23 @@ type Session = {
36
30
  messageIds: (string | undefined)[];
37
31
  currentMessageId?: string;
38
32
  currentQuery?: ReturnType<typeof query>;
33
+ messageChannels: Map<string, MessageChannelInfo>;
34
+ autoReply: AutoReplyTracker;
39
35
  };
40
36
 
41
- function toSDKContent(content: VoluteContentPart[]): SDKContent {
42
- return content.map((part) => {
43
- if (part.type === "text") {
44
- return { type: "text" as const, text: part.text };
45
- }
46
- return {
47
- type: "image" as const,
48
- source: {
49
- type: "base64" as const,
50
- media_type: part.media_type as "image/jpeg" | "image/png" | "image/gif" | "image/webp",
51
- data: part.data,
52
- },
53
- };
54
- });
55
- }
56
-
57
37
  export function createAgent(options: {
58
38
  systemPrompt: string;
59
39
  cwd: string;
60
40
  abortController: AbortController;
61
41
  model?: string;
42
+ maxThinkingTokens?: number;
62
43
  sessionsDir: string;
63
44
  compactionMessage?: string;
64
45
  onIdentityReload?: () => Promise<void>;
65
46
  }): { resolve: HandlerResolver; waitForCommits: () => Promise<void> } {
66
47
  const autoCommit = createAutoCommitHook(options.cwd);
67
48
  const identityReload = createIdentityReloadHook(options.cwd);
49
+ const sessionStore = createSessionStore(options.sessionsDir);
68
50
  const postToolUseHooks: { matcher: string; hooks: HookCallback[] }[] = [
69
51
  { matcher: "Edit|Write", hooks: [autoCommit.hook, identityReload.hook] },
70
52
  ];
@@ -75,38 +57,6 @@ export function createAgent(options: {
75
57
  options.compactionMessage ??
76
58
  `Context is getting long — compaction is about to summarize this conversation. Before that happens, save anything important to files (MEMORY.md, memory/journal/${today}.md, etc.) since those survive compaction. Focus on: decisions made, open tasks, and anything you'd need to pick up where you left off.`;
77
59
 
78
- // --- Session persistence ---
79
-
80
- function sessionFilePath(sessionName: string): string {
81
- return resolvePath(options.sessionsDir, `${sessionName}.json`);
82
- }
83
-
84
- function loadSessionId(sessionName: string): string | undefined {
85
- try {
86
- const data = JSON.parse(readFileSync(sessionFilePath(sessionName), "utf-8"));
87
- return data.sessionId;
88
- } catch (err: any) {
89
- if (err?.code !== "ENOENT") {
90
- log("agent", `failed to load session file for "${sessionName}":`, err);
91
- }
92
- return undefined;
93
- }
94
- }
95
-
96
- function saveSessionId(sessionName: string, sessionId: string) {
97
- mkdirSync(options.sessionsDir, { recursive: true });
98
- writeFileSync(sessionFilePath(sessionName), JSON.stringify({ sessionId }));
99
- }
100
-
101
- function deleteSessionId(sessionName: string) {
102
- try {
103
- const path = sessionFilePath(sessionName);
104
- if (existsSync(path)) unlinkSync(path);
105
- } catch (err) {
106
- log("agent", `failed to delete session file for "${sessionName}":`, err);
107
- }
108
- }
109
-
110
60
  // --- Event broadcasting ---
111
61
 
112
62
  function broadcastToSession(session: Session, event: VoluteEvent) {
@@ -153,6 +103,7 @@ export function createAgent(options: {
153
103
  cwd: options.cwd,
154
104
  abortController: options.abortController,
155
105
  model: options.model,
106
+ maxThinkingTokens: options.maxThinkingTokens,
156
107
  resume,
157
108
  hooks: {
158
109
  PostToolUse: postToolUseHooks,
@@ -163,65 +114,45 @@ export function createAgent(options: {
163
114
  });
164
115
  }
165
116
 
166
- async function consumeStream(stream: ReturnType<typeof query>, session: Session) {
167
- for await (const msg of stream) {
168
- if (session.currentMessageId === undefined) {
169
- session.currentMessageId = session.messageIds.shift();
170
- }
171
- if ("session_id" in msg && msg.session_id) {
172
- if (!session.name.startsWith("new-")) {
173
- saveSessionId(session.name, msg.session_id as string);
174
- }
175
- }
176
- if (msg.type === "assistant") {
177
- for (const b of msg.message.content) {
178
- if (b.type === "thinking" && "thinking" in b && b.thinking) {
179
- logThinking(b.thinking as string);
180
- } else if (b.type === "text") {
181
- const text = (b as { text: string }).text;
182
- logText(text);
183
- broadcastToSession(session, { type: "text", content: text });
184
- } else if (b.type === "tool_use") {
185
- const tb = b as { name: string; input: unknown };
186
- logToolUse(tb.name, tb.input);
187
- broadcastToSession(session, { type: "tool_use", name: tb.name, input: tb.input });
188
- }
189
- }
190
- }
191
- if (msg.type === "result") {
192
- log("agent", `session "${session.name}": turn done`);
193
- const result = msg as { usage?: { input_tokens?: number; output_tokens?: number } };
194
- if (result.usage) {
195
- broadcastToSession(session, {
196
- type: "usage",
197
- input_tokens: result.usage.input_tokens ?? 0,
198
- output_tokens: result.usage.output_tokens ?? 0,
199
- });
200
- }
201
- broadcastToSession(session, { type: "done" });
202
- session.currentMessageId = undefined;
203
- if (identityReload.needsReload()) {
204
- options.onIdentityReload?.();
205
- }
206
- }
207
- }
208
- }
209
-
210
117
  function startSession(session: Session, savedSessionId?: string) {
211
118
  (async () => {
212
119
  log("agent", `session "${session.name}": stream consumer started`);
120
+ const callbacks = {
121
+ onSessionId: (id: string) => {
122
+ if (!session.name.startsWith("new-")) sessionStore.save(session.name, id);
123
+ },
124
+ broadcast: (event: VoluteEvent) => broadcastToSession(session, event),
125
+ onTurnEnd: () => {
126
+ if (identityReload.needsReload()) options.onIdentityReload?.();
127
+ },
128
+ };
213
129
  try {
214
130
  const q = createStream(session, savedSessionId);
215
131
  session.currentQuery = q;
216
- await consumeStream(q, session);
132
+ await consumeStream(q, session, callbacks);
133
+ // Stream ended — flush any pending auto-reply and broadcast done if no result was emitted
134
+ session.autoReply.flush(session.currentMessageId);
135
+ if (session.currentMessageId !== undefined) {
136
+ session.messageChannels.delete(session.currentMessageId);
137
+ broadcastToSession(session, { type: "done" });
138
+ session.currentMessageId = undefined;
139
+ }
217
140
  } catch (err) {
141
+ session.autoReply.reset();
142
+ session.messageChannels.clear();
218
143
  if (savedSessionId) {
219
144
  log("agent", `session "${session.name}": resume failed, starting fresh:`, err);
220
- deleteSessionId(session.name);
145
+ sessionStore.delete(session.name);
221
146
  try {
222
147
  const q = createStream(session);
223
148
  session.currentQuery = q;
224
- await consumeStream(q, session);
149
+ await consumeStream(q, session, callbacks);
150
+ session.autoReply.flush(session.currentMessageId);
151
+ if (session.currentMessageId !== undefined) {
152
+ session.messageChannels.delete(session.currentMessageId);
153
+ broadcastToSession(session, { type: "done" });
154
+ session.currentMessageId = undefined;
155
+ }
225
156
  } catch (retryErr) {
226
157
  log("agent", `session "${session.name}": stream consumer error:`, retryErr);
227
158
  broadcastToSession(session, { type: "done" });
@@ -241,16 +172,19 @@ export function createAgent(options: {
241
172
  const existing = sessions.get(name);
242
173
  if (existing) return existing;
243
174
 
175
+ const messageChannels = new Map<string, MessageChannelInfo>();
244
176
  const session: Session = {
245
177
  name,
246
178
  channel: createMessageChannel(),
247
179
  listeners: new Set(),
248
180
  messageIds: [],
181
+ messageChannels,
182
+ autoReply: createAutoReplyTracker(messageChannels),
249
183
  };
250
184
  sessions.set(name, session);
251
185
 
252
186
  const isEphemeral = name.startsWith("new-");
253
- const savedSessionId = isEphemeral ? undefined : loadSessionId(name);
187
+ const savedSessionId = isEphemeral ? undefined : sessionStore.load(name);
254
188
  if (savedSessionId) {
255
189
  log("agent", `session "${name}": resuming ${savedSessionId}`);
256
190
  } else {
@@ -274,6 +208,14 @@ export function createAgent(options: {
274
208
  };
275
209
  session.listeners.add(filteredListener);
276
210
 
211
+ // Track channel for auto-reply
212
+ if (meta.channel) {
213
+ session.messageChannels.set(meta.messageId, {
214
+ channel: meta.channel,
215
+ autoReply: meta.autoReply,
216
+ });
217
+ }
218
+
277
219
  // Interrupt if requested and session is mid-turn
278
220
  if (meta.interrupt && session.currentMessageId !== undefined && session.currentQuery) {
279
221
  log("agent", `session "${sessionName}": interrupting current turn`);
@@ -0,0 +1,41 @@
1
+ import type { VoluteContentPart } from "./types.js";
2
+
3
+ export type SDKContent = (
4
+ | { type: "text"; text: string }
5
+ | {
6
+ type: "image";
7
+ source: {
8
+ type: "base64";
9
+ media_type: "image/jpeg" | "image/png" | "image/gif" | "image/webp";
10
+ data: string;
11
+ };
12
+ }
13
+ )[];
14
+
15
+ type SupportedMediaType = "image/jpeg" | "image/png" | "image/gif" | "image/webp";
16
+
17
+ const SUPPORTED_MEDIA_TYPES: Set<string> = new Set([
18
+ "image/jpeg",
19
+ "image/png",
20
+ "image/gif",
21
+ "image/webp",
22
+ ]);
23
+
24
+ export function toSDKContent(content: VoluteContentPart[]): SDKContent {
25
+ return content.flatMap((part): SDKContent => {
26
+ if (part.type === "text") {
27
+ return [{ type: "text" as const, text: part.text }];
28
+ }
29
+ if (!SUPPORTED_MEDIA_TYPES.has(part.media_type)) return [];
30
+ return [
31
+ {
32
+ type: "image" as const,
33
+ source: {
34
+ type: "base64" as const,
35
+ media_type: part.media_type as SupportedMediaType,
36
+ data: part.data,
37
+ },
38
+ },
39
+ ];
40
+ });
41
+ }
@@ -0,0 +1,43 @@
1
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { resolve as resolvePath } from "node:path";
3
+ import { log } from "./logger.js";
4
+
5
+ export type SessionStore = {
6
+ load(name: string): string | undefined;
7
+ save(name: string, id: string): void;
8
+ delete(name: string): void;
9
+ };
10
+
11
+ export function createSessionStore(sessionsDir: string): SessionStore {
12
+ function filePath(name: string): string {
13
+ return resolvePath(sessionsDir, `${name}.json`);
14
+ }
15
+
16
+ return {
17
+ load(name: string): string | undefined {
18
+ try {
19
+ const data = JSON.parse(readFileSync(filePath(name), "utf-8"));
20
+ return typeof data.sessionId === "string" ? data.sessionId : undefined;
21
+ } catch (err: any) {
22
+ if (err?.code !== "ENOENT") {
23
+ log("agent", `failed to load session file for "${name}":`, err);
24
+ }
25
+ return undefined;
26
+ }
27
+ },
28
+
29
+ save(name: string, id: string) {
30
+ mkdirSync(sessionsDir, { recursive: true });
31
+ writeFileSync(filePath(name), JSON.stringify({ sessionId: id }));
32
+ },
33
+
34
+ delete(name: string) {
35
+ try {
36
+ const path = filePath(name);
37
+ if (existsSync(path)) unlinkSync(path);
38
+ } catch (err) {
39
+ log("agent", `failed to delete session file for "${name}":`, err);
40
+ }
41
+ },
42
+ };
43
+ }
@@ -0,0 +1,66 @@
1
+ import type { query } from "@anthropic-ai/claude-agent-sdk";
2
+ import type { AutoReplyTracker, MessageChannelInfo } from "./auto-reply.js";
3
+ import { log, logText, logThinking, logToolUse } from "./logger.js";
4
+ import type { VoluteEvent } from "./types.js";
5
+
6
+ export type StreamSession = {
7
+ name: string;
8
+ messageIds: (string | undefined)[];
9
+ currentMessageId?: string;
10
+ messageChannels: Map<string, MessageChannelInfo>;
11
+ autoReply: AutoReplyTracker;
12
+ };
13
+
14
+ export type StreamCallbacks = {
15
+ onSessionId?: (sessionId: string) => void;
16
+ broadcast: (event: VoluteEvent) => void;
17
+ onTurnEnd?: () => void;
18
+ };
19
+
20
+ export async function consumeStream(
21
+ stream: ReturnType<typeof query>,
22
+ session: StreamSession,
23
+ callbacks: StreamCallbacks,
24
+ ) {
25
+ for await (const msg of stream) {
26
+ if (session.currentMessageId === undefined) {
27
+ session.currentMessageId = session.messageIds.shift();
28
+ session.autoReply.reset();
29
+ }
30
+ if ("session_id" in msg && msg.session_id) {
31
+ callbacks.onSessionId?.(msg.session_id as string);
32
+ }
33
+ if (msg.type === "assistant") {
34
+ for (const b of msg.message.content) {
35
+ if (b.type === "thinking" && "thinking" in b && b.thinking) {
36
+ logThinking(b.thinking as string);
37
+ } else if (b.type === "text") {
38
+ logText((b as { text: string }).text);
39
+ session.autoReply.accumulate((b as { text: string }).text);
40
+ } else if (b.type === "tool_use") {
41
+ session.autoReply.flush(session.currentMessageId);
42
+ const tb = b as { name: string; input: unknown };
43
+ logToolUse(tb.name, tb.input);
44
+ }
45
+ }
46
+ }
47
+ if (msg.type === "result") {
48
+ session.autoReply.flush(session.currentMessageId);
49
+ if (session.currentMessageId) {
50
+ session.messageChannels.delete(session.currentMessageId);
51
+ }
52
+ log("agent", `session "${session.name}": turn done`);
53
+ const result = msg as { usage?: { input_tokens?: number; output_tokens?: number } };
54
+ if (result.usage) {
55
+ callbacks.broadcast({
56
+ type: "usage",
57
+ input_tokens: result.usage.input_tokens ?? 0,
58
+ output_tokens: result.usage.output_tokens ?? 0,
59
+ });
60
+ }
61
+ callbacks.broadcast({ type: "done" });
62
+ session.currentMessageId = undefined;
63
+ callbacks.onTurnEnd?.();
64
+ }
65
+ }
66
+ }
@@ -1,11 +1,11 @@
1
- import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, renameSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { createAgent } from "./agent.js";
4
+ import { daemonRestart } from "./lib/daemon-client.js";
4
5
  import { createFileHandlerResolver } from "./lib/file-handler.js";
5
6
  import { log } from "./lib/logger.js";
6
7
  import { createRouter } from "./lib/router.js";
7
8
  import {
8
- handleMergeContext,
9
9
  loadConfig,
10
10
  loadPackageInfo,
11
11
  loadSystemPrompt,
@@ -17,6 +17,7 @@ import { createVoluteServer } from "./lib/volute-server.js";
17
17
  const { port } = parseArgs();
18
18
  const config = loadConfig();
19
19
  if (config.model) log("server", `using model: ${config.model}`);
20
+ if (config.maxThinkingTokens) log("server", `max thinking tokens: ${config.maxThinkingTokens}`);
20
21
 
21
22
  const systemPrompt = loadSystemPrompt();
22
23
  const sessionsDir = resolve(".volute/sessions");
@@ -36,19 +37,13 @@ const agent = createAgent({
36
37
  cwd: resolve("home"),
37
38
  abortController,
38
39
  model: config.model,
40
+ maxThinkingTokens: config.maxThinkingTokens,
39
41
  sessionsDir,
40
42
  compactionMessage: config.compactionMessage,
41
43
  onIdentityReload: async () => {
42
44
  log("server", "identity file changed — restarting to reload");
43
45
  await agent.waitForCommits();
44
- // Signal daemon to restart immediately (bypasses crash backoff)
45
- try {
46
- writeFileSync(resolve(".volute/restart.json"), JSON.stringify({ action: "reload" }));
47
- } catch (err) {
48
- log("server", "failed to write restart signal:", err);
49
- }
50
- server.close();
51
- process.exit(0);
46
+ await daemonRestart({ type: "reload" });
52
47
  },
53
48
  });
54
49
 
@@ -69,9 +64,6 @@ server.listen(port, () => {
69
64
  const addr = server.address();
70
65
  const actualPort = typeof addr === "object" && addr ? addr.port : port;
71
66
  log("server", `listening on :${actualPort}`);
72
- handleMergeContext((content) =>
73
- router.route([{ type: "text", text: content }], { channel: "system" }),
74
- );
75
67
  });
76
68
 
77
69
  setupShutdown();
@@ -16,14 +16,14 @@ You can also reach out proactively — see the **volute-agent** skill.
16
16
  Two-tier memory, both managed via file tools:
17
17
 
18
18
  - **`MEMORY.md`** — Your long-term memory, always in context. Update as you grow — new understanding, changed perspectives, things that matter to you.
19
- - **`memory/YYYY-MM-DD.md`** — Your daily log. Write about what you're doing, thinking, and learning. The two most recent logs are included in your system prompt.
20
- - Periodically consolidate daily log entries into `MEMORY.md` to promote lasting insights.
19
+ - **`memory/journal/YYYY-MM-DD.md`** — Your daily journal. Write about what you're doing, thinking, and learning. Journals are permanent records.
20
+ - Periodically consolidate journal entries into `MEMORY.md` to promote lasting insights.
21
21
 
22
22
  See the **memory** skill for detailed guidance.
23
23
 
24
24
  ## Sessions
25
25
 
26
26
  - You may have **multiple named sessions** — each maintains its own conversation history. See `VOLUTE.md` for how to configure session routing via `.config/routes.json`.
27
- - Your conversation may be **resumed** from a previous session — orient yourself by reading recent daily logs if needed.
28
- - On a **fresh session**, read `MEMORY.md` and recent daily logs to remember where you left off.
29
- - On **compaction**, update today's daily log to preserve context before the conversation is trimmed.
27
+ - Your conversation may be **resumed** from a previous session — orient yourself by reading recent journal entries if needed.
28
+ - On a **fresh session**, read `MEMORY.md` and recent journal entries to remember where you left off.
29
+ - On **compaction**, update today's journal to preserve context before the conversation is trimmed.
@@ -1,5 +1,3 @@
1
- import type { ImageContent } from "@mariozechner/pi-ai";
2
- import { getModel, getModels } from "@mariozechner/pi-ai";
3
1
  import {
4
2
  AuthStorage,
5
3
  createAgentSession,
@@ -9,8 +7,15 @@ import {
9
7
  SessionManager,
10
8
  SettingsManager,
11
9
  } from "@mariozechner/pi-coding-agent";
12
- import { commitFileChange } from "./lib/auto-commit.js";
13
- import { log, logText, logThinking, logToolResult, logToolUse } from "./lib/logger.js";
10
+ import {
11
+ type AutoReplyTracker,
12
+ createAutoReplyTracker,
13
+ type MessageChannelInfo,
14
+ } from "./lib/auto-reply.js";
15
+ import { extractImages, extractText } from "./lib/content.js";
16
+ import { createEventHandler } from "./lib/event-handler.js";
17
+ import { log } from "./lib/logger.js";
18
+ import { resolveModel } from "./lib/resolve-model.js";
14
19
  import { createSessionContextExtension } from "./lib/session-context-extension.js";
15
20
  import type {
16
21
  HandlerMeta,
@@ -31,6 +36,8 @@ type PiSession = {
31
36
  unsubscribe?: () => void;
32
37
  messageIds: (string | undefined)[];
33
38
  currentMessageId?: string;
39
+ messageChannels: Map<string, MessageChannelInfo>;
40
+ autoReply: AutoReplyTracker;
34
41
  };
35
42
 
36
43
  function defaultCompactionMessage(): string {
@@ -38,43 +45,11 @@ function defaultCompactionMessage(): string {
38
45
  return `Context is getting long — compaction is about to summarize this conversation. Before that happens, save anything important to files (MEMORY.md, memory/journal/${today}.md, etc.) since those survive compaction. Focus on: decisions made, open tasks, and anything you'd need to pick up where you left off.`;
39
46
  }
40
47
 
41
- function resolveModel(modelStr: string) {
42
- const [provider, ...rest] = modelStr.split(":");
43
- const modelId = rest.join(":");
44
-
45
- // Try exact match first, then prefix match against available models
46
- let model = getModel(provider as any, modelId as any);
47
- if (!model) {
48
- const available = getModels(provider as any);
49
- const found = available.find((m) => m.id.startsWith(modelId));
50
- if (found) model = found;
51
- }
52
- if (!model) {
53
- const available = getModels(provider as any);
54
- throw new Error(
55
- `Model not found: ${modelStr}\nAvailable ${provider} models: ${available.map((m) => m.id).join(", ")}`,
56
- );
57
- }
58
- return model;
59
- }
60
-
61
- function extractText(content: VoluteContentPart[]): string {
62
- return content
63
- .filter((p): p is { type: "text"; text: string } => p.type === "text")
64
- .map((p) => p.text)
65
- .join("\n");
66
- }
67
-
68
- function extractImages(content: VoluteContentPart[]): ImageContent[] {
69
- return content
70
- .filter((p): p is { type: "image"; media_type: string; data: string } => p.type === "image")
71
- .map((p) => ({ type: "image" as const, mimeType: p.media_type, data: p.data }));
72
- }
73
-
74
48
  export function createAgent(options: {
75
49
  systemPrompt: string;
76
50
  cwd: string;
77
51
  model?: string;
52
+ thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
78
53
  compactionMessage?: string;
79
54
  }): { resolve: HandlerResolver } {
80
55
  const sessions = new Map<string, PiSession>();
@@ -92,16 +67,21 @@ export function createAgent(options: {
92
67
  const existing = sessions.get(name);
93
68
  if (existing) return existing;
94
69
 
70
+ const messageChannels = new Map<string, MessageChannelInfo>();
95
71
  const session: PiSession = {
96
72
  name,
97
73
  agentSession: null,
98
74
  ready: Promise.resolve(),
99
75
  listeners: new Set(),
100
76
  messageIds: [],
77
+ messageChannels,
78
+ autoReply: createAutoReplyTracker(messageChannels),
101
79
  };
102
80
  sessions.set(name, session);
103
81
 
104
82
  session.ready = initSession(session).catch((err) => {
83
+ session.autoReply.reset();
84
+ session.messageChannels.clear();
105
85
  log("agent", `session "${session.name}": init failed:`, err);
106
86
  });
107
87
  return session;
@@ -154,6 +134,7 @@ export function createAgent(options: {
154
134
  const { session: agentSession } = await createAgentSession({
155
135
  cwd: options.cwd,
156
136
  model,
137
+ thinkingLevel: options.thinkingLevel,
157
138
  authStorage,
158
139
  modelRegistry,
159
140
  sessionManager,
@@ -163,53 +144,12 @@ export function createAgent(options: {
163
144
 
164
145
  session.agentSession = agentSession;
165
146
 
166
- // Per-session event subscription
167
- const toolArgs = new Map<string, any>();
168
-
169
- session.unsubscribe = agentSession.subscribe((event) => {
170
- if (session.currentMessageId === undefined) {
171
- session.currentMessageId = session.messageIds.shift();
172
- }
173
-
174
- if (event.type === "message_update") {
175
- const ae = event.assistantMessageEvent;
176
- if (ae.type === "text_delta") {
177
- logText(ae.delta);
178
- broadcast(session, { type: "text", content: ae.delta });
179
- } else if (ae.type === "thinking_delta") {
180
- logThinking(ae.delta);
181
- }
182
- }
183
-
184
- if (event.type === "tool_execution_start") {
185
- toolArgs.set(event.toolCallId, event.args);
186
- logToolUse(event.toolName, event.args);
187
- broadcast(session, { type: "tool_use", name: event.toolName, input: event.args });
188
- }
189
-
190
- if (event.type === "tool_execution_end") {
191
- const output =
192
- typeof event.result === "string" ? event.result : JSON.stringify(event.result);
193
- logToolResult(event.toolName, output, event.isError);
194
- broadcast(session, { type: "tool_result", output, is_error: event.isError });
195
-
196
- // Auto-commit file changes in home/
197
- if ((event.toolName === "edit" || event.toolName === "write") && !event.isError) {
198
- const args = toolArgs.get(event.toolCallId);
199
- const filePath = (args as { path?: string })?.path;
200
- if (filePath) {
201
- commitFileChange(filePath, options.cwd);
202
- }
203
- }
204
- toolArgs.delete(event.toolCallId);
205
- }
206
-
207
- if (event.type === "agent_end") {
208
- log("agent", `session "${session.name}": turn done`);
209
- broadcast(session, { type: "done" });
210
- session.currentMessageId = undefined;
211
- }
212
- });
147
+ session.unsubscribe = agentSession.subscribe(
148
+ createEventHandler(session, {
149
+ cwd: options.cwd,
150
+ broadcast: (event) => broadcast(session, event),
151
+ }),
152
+ );
213
153
 
214
154
  log("agent", `session "${session.name}": ready`);
215
155
  }
@@ -250,6 +190,14 @@ export function createAgent(options: {
250
190
  };
251
191
  session.listeners.add(filteredListener);
252
192
 
193
+ // Track channel for auto-reply
194
+ if (meta.channel) {
195
+ session.messageChannels.set(meta.messageId, {
196
+ channel: meta.channel,
197
+ autoReply: meta.autoReply,
198
+ });
199
+ }
200
+
253
201
  // Track messageId (must be pushed before prompt)
254
202
  session.messageIds.push(meta.messageId);
255
203
 
@@ -0,0 +1,15 @@
1
+ import type { ImageContent } from "@mariozechner/pi-ai";
2
+ import type { VoluteContentPart } from "./types.js";
3
+
4
+ export function extractText(content: VoluteContentPart[]): string {
5
+ return content
6
+ .filter((p): p is { type: "text"; text: string } => p.type === "text")
7
+ .map((p) => p.text)
8
+ .join("\n");
9
+ }
10
+
11
+ export function extractImages(content: VoluteContentPart[]): ImageContent[] {
12
+ return content
13
+ .filter((p): p is { type: "image"; media_type: string; data: string } => p.type === "image")
14
+ .map((p) => ({ type: "image" as const, mimeType: p.media_type, data: p.data }));
15
+ }