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
@@ -0,0 +1,38 @@
1
+ import { daemonSend } from "./daemon-client.js";
2
+ import { log } from "./logger.js";
3
+
4
+ export type MessageChannelInfo = { channel: string; autoReply: boolean };
5
+
6
+ export type AutoReplyTracker = {
7
+ accumulate(text: string): void;
8
+ flush(currentMessageId: string | undefined): void;
9
+ reset(): void;
10
+ };
11
+
12
+ export function createAutoReplyTracker(
13
+ messageChannels: Map<string, MessageChannelInfo>,
14
+ ): AutoReplyTracker {
15
+ let accumulator = "";
16
+
17
+ function flush(currentMessageId: string | undefined) {
18
+ const text = accumulator.trim();
19
+ accumulator = "";
20
+ if (!text) return;
21
+ const info = currentMessageId ? messageChannels.get(currentMessageId) : undefined;
22
+ if (info?.autoReply && info.channel) {
23
+ daemonSend(info.channel, text).catch((err) => {
24
+ log("agent", `auto-reply to ${info.channel} failed: ${err}`);
25
+ });
26
+ }
27
+ }
28
+
29
+ return {
30
+ accumulate(text: string) {
31
+ accumulator += text;
32
+ },
33
+ flush,
34
+ reset() {
35
+ accumulator = "";
36
+ },
37
+ };
38
+ }
@@ -0,0 +1,53 @@
1
+ const port = process.env.VOLUTE_DAEMON_PORT;
2
+ const agent = process.env.VOLUTE_AGENT;
3
+ const token = process.env.VOLUTE_DAEMON_TOKEN;
4
+
5
+ function headers(): Record<string, string> {
6
+ const h: Record<string, string> = { "Content-Type": "application/json" };
7
+ if (token) h.Authorization = `Bearer ${token}`;
8
+ // Origin header required for CSRF checks on mutation requests
9
+ if (port) h.Origin = `http://127.0.0.1:${port}`;
10
+ return h;
11
+ }
12
+
13
+ export async function daemonRestart(context?: {
14
+ type: string;
15
+ [k: string]: unknown;
16
+ }): Promise<void> {
17
+ if (!port || !agent) {
18
+ console.error("[volute] daemonRestart: VOLUTE_DAEMON_PORT or VOLUTE_AGENT not set");
19
+ return;
20
+ }
21
+ try {
22
+ await fetch(`http://127.0.0.1:${port}/api/agents/${encodeURIComponent(agent)}/restart`, {
23
+ method: "POST",
24
+ headers: headers(),
25
+ body: JSON.stringify({ context }),
26
+ });
27
+ } catch {
28
+ // Daemon may kill us before response arrives — expected
29
+ }
30
+ }
31
+
32
+ export async function daemonSend(channel: string, text: string): Promise<void> {
33
+ if (!port || !agent) {
34
+ console.error("[volute] daemonSend: VOLUTE_DAEMON_PORT or VOLUTE_AGENT not set");
35
+ return;
36
+ }
37
+ const res = await fetch(
38
+ `http://127.0.0.1:${port}/api/agents/${encodeURIComponent(agent)}/message`,
39
+ {
40
+ method: "POST",
41
+ headers: headers(),
42
+ body: JSON.stringify({
43
+ content: text,
44
+ channel,
45
+ sender: agent,
46
+ }),
47
+ },
48
+ );
49
+ if (!res.ok) {
50
+ const body = await res.text().catch(() => "");
51
+ throw new Error(`daemonSend failed (${res.status}): ${body}`);
52
+ }
53
+ }
@@ -1,6 +1,11 @@
1
1
  import { formatPrefix, formatTypingSuffix } from "./format-prefix.js";
2
2
  import { log, logMessage } from "./logger.js";
3
- import { type BatchConfig, loadRoutingConfig, resolveRoute } from "./routing.js";
3
+ import {
4
+ type BatchConfig,
5
+ loadRoutingConfig,
6
+ resolveRoute,
7
+ resolveSessionConfig,
8
+ } from "./routing.js";
4
9
  import type { ChannelMeta, HandlerResolver, Listener, VoluteContentPart } from "./types.js";
5
10
 
6
11
  export type Router = {
@@ -72,6 +77,24 @@ function appendTypingSuffix(
72
77
  });
73
78
  }
74
79
 
80
+ function prependInstructions(
81
+ content: VoluteContentPart[],
82
+ instructions: string | undefined,
83
+ ): VoluteContentPart[] {
84
+ if (!instructions) return content;
85
+ const prefix = `[Session instructions: ${instructions}]\n\n`;
86
+ const firstTextIdx = content.findIndex((p) => p.type === "text");
87
+ if (firstTextIdx === -1) {
88
+ return [{ type: "text", text: prefix.trimEnd() }, ...content];
89
+ }
90
+ return content.map((part, i) => {
91
+ if (i === firstTextIdx) {
92
+ return { type: "text" as const, text: prefix + (part as { text: string }).text };
93
+ }
94
+ return part;
95
+ });
96
+ }
97
+
75
98
  function sanitizeChannelPath(channel: string): string {
76
99
  return channel
77
100
  .replace(/[/\\:]/g, "-")
@@ -107,20 +130,21 @@ function formatInviteNotification(
107
130
  lines.push("");
108
131
  lines.push(`Further messages will be saved to ${filePath}`);
109
132
  lines.push("");
110
- lines.push("To accept, add a routing rule to .config/routes.json:");
133
+ lines.push("To accept, add to .config/routes.json:");
111
134
  const suggestedSession = sanitizeChannelPath(meta.channel ?? "unknown");
112
135
  const otherCount = (meta.participantCount ?? 1) - 1;
113
136
  if (otherCount > 1) {
137
+ lines.push(` Rule: { "channel": "${meta.channel}", "session": "${suggestedSession}" }`);
114
138
  lines.push(
115
- ` { "channel": "${meta.channel}", "session": "${suggestedSession}", "batch": { "debounce": 20, "maxWait": 120 } }`,
139
+ ` Session config: "${suggestedSession}": { "batch": { "debounce": 20, "maxWait": 120 } }`,
116
140
  );
117
141
  lines.push(
118
142
  `(batch recommended — ${otherCount} other participants may generate frequent messages)`,
119
143
  );
120
144
  } else {
121
- lines.push(` { "channel": "${meta.channel}", "session": "${suggestedSession}" }`);
145
+ lines.push(` Rule: { "channel": "${meta.channel}", "session": "${suggestedSession}" }`);
122
146
  }
123
- lines.push(`To respond, use: volute channel send ${meta.channel ?? "unknown"} "your message"`);
147
+ lines.push(`To respond, use: volute send ${meta.channel ?? "unknown"} "your message"`);
124
148
  lines.push(`To reject, delete ${filePath}`);
125
149
  return lines.join("\n");
126
150
  }
@@ -175,15 +199,25 @@ export function createRouter(options: {
175
199
 
176
200
  const lastTyping = messages[messages.length - 1]?.typing;
177
201
  const typingSuffix = formatTypingSuffix(lastTyping);
178
- const content: VoluteContentPart[] = [
202
+ let content: VoluteContentPart[] = [
179
203
  { type: "text", text: `${header}\n\n${body}${typingSuffix}` },
180
204
  ];
205
+
206
+ // Resolve session config for instructions
207
+ const config = options.configPath ? loadRoutingConfig(options.configPath) : {};
208
+ const sessionConfig = resolveSessionConfig(config, buffer.sessionName);
209
+ content = prependInstructions(content, sessionConfig.instructions);
210
+
181
211
  const messageId = generateMessageId();
182
212
  const handler = options.agentHandler(buffer.sessionName);
183
213
 
184
214
  // Batch flushes are fire-and-forget — no HTTP response is waiting, so listener is a noop
185
215
  try {
186
- handler.handle(content, { sessionName: buffer.sessionName, messageId }, () => {});
216
+ handler.handle(
217
+ content,
218
+ { sessionName: buffer.sessionName, messageId, autoReply: false },
219
+ () => {},
220
+ );
187
221
  } catch (err) {
188
222
  log("router", `error flushing batch for session ${buffer.sessionName}:`, err);
189
223
  return;
@@ -250,7 +284,7 @@ export function createRouter(options: {
250
284
  if (options.fileHandler) {
251
285
  const formatted = applyPrefix(content, meta);
252
286
  const fileHandler = options.fileHandler(filePath);
253
- fileHandler.handle(formatted, { ...meta, messageId }, noop);
287
+ fileHandler.handle(formatted, { ...meta, messageId, autoReply: false }, noop);
254
288
  }
255
289
 
256
290
  // First message from this channel — send invite notification
@@ -261,7 +295,12 @@ export function createRouter(options: {
261
295
  const handler = options.agentHandler("main");
262
296
  handler.handle(
263
297
  notifContent,
264
- { sessionName: "main", messageId: generateMessageId(), interrupt: true },
298
+ {
299
+ sessionName: "main",
300
+ messageId: generateMessageId(),
301
+ interrupt: true,
302
+ autoReply: false,
303
+ },
265
304
  noop,
266
305
  );
267
306
  }
@@ -275,7 +314,11 @@ export function createRouter(options: {
275
314
  if (options.fileHandler) {
276
315
  const formatted = applyPrefix(content, meta);
277
316
  const handler = options.fileHandler(resolved.path);
278
- const unsubscribe = handler.handle(formatted, { ...meta, messageId }, safeListener);
317
+ const unsubscribe = handler.handle(
318
+ formatted,
319
+ { ...meta, messageId, autoReply: false },
320
+ safeListener,
321
+ );
279
322
  return { messageId, unsubscribe };
280
323
  }
281
324
  // No file handler configured — emit done and discard
@@ -290,10 +333,12 @@ export function createRouter(options: {
290
333
  sessionName = `new-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
291
334
  }
292
335
 
336
+ const sessionConfig = resolveSessionConfig(config, sessionName);
337
+
293
338
  // Batch mode: buffer the message and return immediate done
294
- if (resolved.batch != null) {
339
+ if (sessionConfig.batch != null) {
295
340
  const batchKey = `batch:${sessionName}`;
296
- const batchConfig = resolved.batch;
341
+ const batchConfig = sessionConfig.batch;
297
342
 
298
343
  if (!batchBuffers.has(batchKey)) {
299
344
  batchBuffers.set(batchKey, {
@@ -332,10 +377,17 @@ export function createRouter(options: {
332
377
  // Direct dispatch to agent
333
378
  const formatted = applyPrefix(content, { ...meta, sessionName });
334
379
  const withTyping = appendTypingSuffix(formatted, meta.typing);
380
+ const withInstructions = prependInstructions(withTyping, sessionConfig.instructions);
335
381
  const handler = options.agentHandler(sessionName);
336
382
  const unsubscribe = handler.handle(
337
- withTyping,
338
- { ...meta, sessionName, messageId, interrupt: resolved.interrupt },
383
+ withInstructions,
384
+ {
385
+ ...meta,
386
+ sessionName,
387
+ messageId,
388
+ interrupt: sessionConfig.interrupt,
389
+ autoReply: sessionConfig.autoReply,
390
+ },
339
391
  safeListener,
340
392
  );
341
393
  return { messageId, unsubscribe };
@@ -11,16 +11,29 @@ export type RoutingRule = {
11
11
  session?: string;
12
12
  destination?: "agent" | "file";
13
13
  path?: string; // file path for file destination
14
- interrupt?: boolean; // interrupt in-progress agent turn (default: true for agent)
15
- batch?: number | BatchConfig; // number = minutes (legacy), object = fine-grained control
16
14
  channel?: string;
17
15
  sender?: string;
18
16
  isDM?: boolean; // match on isDM metadata
19
17
  participants?: number; // match on participant count (e.g. 2 = DM)
20
18
  };
21
19
 
20
+ export type SessionConfig = {
21
+ autoReply?: boolean;
22
+ batch?: number | BatchConfig;
23
+ interrupt?: boolean;
24
+ instructions?: string;
25
+ };
26
+
27
+ export type ResolvedSessionConfig = {
28
+ autoReply: boolean;
29
+ batch?: BatchConfig;
30
+ interrupt: boolean;
31
+ instructions?: string;
32
+ };
33
+
22
34
  export type RoutingConfig = {
23
35
  rules?: RoutingRule[];
36
+ sessions?: Record<string, SessionConfig>;
24
37
  default?: string;
25
38
  gateUnmatched?: boolean;
26
39
  };
@@ -29,8 +42,6 @@ export type ResolvedRoute =
29
42
  | {
30
43
  destination: "agent";
31
44
  session: string;
32
- interrupt: boolean;
33
- batch?: BatchConfig;
34
45
  matched: boolean;
35
46
  }
36
47
  | { destination: "file"; path: string; matched: boolean };
@@ -63,7 +74,7 @@ function globMatch(pattern: string, value: string): boolean {
63
74
  }
64
75
 
65
76
  const GLOB_MATCH_KEYS = new Set(["channel", "sender"]);
66
- const NON_MATCH_KEYS = new Set(["session", "batch", "destination", "path", "interrupt"]);
77
+ const NON_MATCH_KEYS = new Set(["session", "destination", "path"]);
67
78
 
68
79
  type MatchMeta = { channel?: string; sender?: string; isDM?: boolean; participantCount?: number };
69
80
 
@@ -107,7 +118,7 @@ export function resolveRoute(config: RoutingConfig, meta: MatchMeta): ResolvedRo
107
118
  const fallback = config.default ?? "main";
108
119
 
109
120
  if (!config.rules) {
110
- return { destination: "agent", session: fallback, interrupt: true, matched: false };
121
+ return { destination: "agent", session: fallback, matched: false };
111
122
  }
112
123
 
113
124
  for (const rule of config.rules) {
@@ -122,14 +133,42 @@ export function resolveRoute(config: RoutingConfig, meta: MatchMeta): ResolvedRo
122
133
  return {
123
134
  destination: "agent",
124
135
  session: sanitizeSessionName(expandTemplate(rule.session ?? fallback, meta)),
125
- interrupt: rule.interrupt ?? true,
126
- batch: rule.batch != null ? normalizeBatch(rule.batch) : undefined,
127
136
  matched: true,
128
137
  };
129
138
  }
130
139
  }
131
140
 
132
- return { destination: "agent", session: fallback, interrupt: true, matched: false };
141
+ return { destination: "agent", session: fallback, matched: false };
142
+ }
143
+
144
+ /**
145
+ * Resolve session config by matching session name against glob-pattern keys in config.sessions.
146
+ * First match wins. Returns defaults if no match.
147
+ */
148
+ export function resolveSessionConfig(
149
+ config: RoutingConfig,
150
+ sessionName: string,
151
+ ): ResolvedSessionConfig {
152
+ const defaults: ResolvedSessionConfig = { autoReply: false, interrupt: true };
153
+
154
+ if (!config.sessions) return defaults;
155
+
156
+ for (const [pattern, sessionConfig] of Object.entries(config.sessions)) {
157
+ if (globMatch(pattern, sessionName)) {
158
+ const batch = sessionConfig.batch != null ? normalizeBatch(sessionConfig.batch) : undefined;
159
+ if (sessionConfig.autoReply && batch != null) {
160
+ log("routing", `autoReply is not supported with batch mode — autoReply will be ignored`);
161
+ }
162
+ return {
163
+ autoReply: batch != null ? false : (sessionConfig.autoReply ?? false),
164
+ batch,
165
+ interrupt: sessionConfig.interrupt ?? true,
166
+ instructions: sessionConfig.instructions,
167
+ };
168
+ }
169
+ }
170
+
171
+ return defaults;
133
172
  }
134
173
 
135
174
  function sanitizeSessionName(name: string): string {
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { existsSync, readFileSync, unlinkSync } from "node:fs";
2
+ import { existsSync, readFileSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
4
  import { log } from "./logger.js";
5
5
 
@@ -61,30 +61,6 @@ export function loadPackageInfo(): { name: string; version: string } {
61
61
  }
62
62
  }
63
63
 
64
- export function handleMergeContext(sendMessage: (content: string) => void): boolean {
65
- const mergedPath = resolve(".volute/merged.json");
66
- if (!existsSync(mergedPath)) return false;
67
-
68
- try {
69
- const merged = JSON.parse(readFileSync(mergedPath, "utf-8"));
70
- unlinkSync(mergedPath);
71
-
72
- const parts = [
73
- `[system] Variant "${merged.name}" has been merged and you have been restarted.`,
74
- ];
75
- if (merged.summary) parts.push(`Changes: ${merged.summary}`);
76
- if (merged.justification) parts.push(`Why: ${merged.justification}`);
77
- if (merged.memory) parts.push(`Context: ${merged.memory}`);
78
-
79
- sendMessage(parts.join("\n"));
80
- log("server", `sent post-merge orientation for variant: ${merged.name}`);
81
- return true;
82
- } catch (e) {
83
- log("server", "failed to process merged.json:", e);
84
- return false;
85
- }
86
- }
87
-
88
64
  export async function handleStartupContext(sendMessage: (content: string) => void): Promise<void> {
89
65
  const scriptPath = resolve("home/.config/hooks/startup-context.sh");
90
66
  if (!existsSync(scriptPath)) return;
@@ -19,6 +19,7 @@ export type ChannelMeta = {
19
19
  export type HandlerMeta = ChannelMeta & {
20
20
  messageId: string;
21
21
  interrupt?: boolean;
22
+ autoReply: boolean;
22
23
  };
23
24
 
24
25
  export type VoluteRequest = {
@@ -37,7 +38,7 @@ export type VoluteEvent = { messageId?: string } & (
37
38
 
38
39
  export type Listener = (event: VoluteEvent) => void;
39
40
 
40
- /** A handler that processes a single routed message and streams events to a listener. */
41
+ /** A handler that processes a single routed message and emits events to a listener callback. */
41
42
  export type MessageHandler = {
42
43
  handle(content: VoluteContentPart[], meta: HandlerMeta, listener: Listener): () => void; // returns unsubscribe
43
44
  };
@@ -33,25 +33,40 @@ export function createVoluteServer(options: {
33
33
  try {
34
34
  const body = JSON.parse(await readBody(req)) as VoluteRequest;
35
35
 
36
- res.writeHead(200, {
37
- "Content-Type": "application/x-ndjson",
38
- "Cache-Control": "no-cache",
39
- Connection: "keep-alive",
40
- });
36
+ let usage: { input_tokens: number; output_tokens: number } | undefined;
37
+ let done = false;
41
38
 
42
39
  const { unsubscribe } = router.route(body.content, body, (event) => {
43
- try {
44
- res.write(`${JSON.stringify(event)}\n`);
45
- if (event.type === "done") {
46
- res.end();
47
- }
48
- } catch (err) {
49
- log("server", "write error, disconnecting:", err);
50
- unsubscribe();
40
+ if (event.type === "usage") {
41
+ usage = { input_tokens: event.input_tokens, output_tokens: event.output_tokens };
42
+ }
43
+ if (event.type === "done") {
44
+ done = true;
45
+ clearTimeout(timeout);
46
+ const response: { ok: true; usage?: { input_tokens: number; output_tokens: number } } =
47
+ { ok: true };
48
+ if (usage) response.usage = usage;
49
+ res.writeHead(200, { "Content-Type": "application/json" });
50
+ res.end(JSON.stringify(response));
51
51
  }
52
52
  });
53
53
 
54
- res.on("close", () => unsubscribe());
54
+ const timeout = setTimeout(
55
+ () => {
56
+ if (!done) {
57
+ done = true;
58
+ unsubscribe();
59
+ res.writeHead(504, { "Content-Type": "application/json" });
60
+ res.end(JSON.stringify({ ok: false, error: "Agent processing timed out" }));
61
+ }
62
+ },
63
+ 5 * 60 * 1000,
64
+ );
65
+
66
+ res.on("close", () => {
67
+ clearTimeout(timeout);
68
+ if (!done) unsubscribe();
69
+ });
55
70
  } catch (err) {
56
71
  if (err instanceof SyntaxError) {
57
72
  res.writeHead(400);