volute 0.3.0 → 0.4.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 (54) hide show
  1. package/README.md +7 -7
  2. package/dist/{agent-manager-2LU6KULR.js → agent-manager-AUCKMGPR.js} +4 -4
  3. package/dist/{channel-H7N4SGR2.js → channel-DQ6UY7QB.js} +17 -40
  4. package/dist/{chunk-RALYNMHR.js → chunk-3C2XR4IY.js} +1 -1
  5. package/dist/chunk-5OCWMTVS.js +152 -0
  6. package/dist/{chunk-YEIHRP2J.js → chunk-DNOXHLE5.js} +1 -1
  7. package/dist/{chunk-IPIPLGME.js → chunk-I6OHXCMV.js} +4 -4
  8. package/dist/chunk-MXUCNIBG.js +168 -0
  9. package/dist/{chunk-DEUAVGSA.js → chunk-SOZA2TLP.js} +1 -1
  10. package/dist/{chunk-VVD3XO3E.js → chunk-YGFIWIOF.js} +1 -1
  11. package/dist/{chunk-N4YNKR3Q.js → chunk-ZHCE4DPY.js} +20 -0
  12. package/dist/cli.js +36 -24
  13. package/dist/connector-DKDJTLYZ.js +152 -0
  14. package/dist/connectors/discord.js +102 -158
  15. package/dist/connectors/slack.js +170 -0
  16. package/dist/connectors/telegram.js +156 -0
  17. package/dist/{create-RSWWMGKT.js → create-ILVOG75A.js} +5 -5
  18. package/dist/{daemon-client-27KMQQKX.js → daemon-client-XR24PUJF.js} +2 -2
  19. package/dist/daemon.js +271 -151
  20. package/dist/{delete-4ERL2QHH.js → delete-55MXCEY5.js} +5 -5
  21. package/dist/{down-HRC4MQCT.js → down-3OB6UVAJ.js} +1 -1
  22. package/dist/{env-DBWDTIP6.js → env-JB27UAC3.js} +2 -2
  23. package/dist/{history-W7BD2H74.js → history-BKG74I43.js} +4 -4
  24. package/dist/{import-6HTSSDFW.js → import-4CI2ZUTJ.js} +17 -2
  25. package/dist/{logs-NHWGHNBF.js → logs-NXFFGUKY.js} +1 -1
  26. package/dist/package-Z2SFO2SV.js +89 -0
  27. package/dist/{schedule-DKZ2E2CL.js → schedule-A35SH4HT.js} +4 -4
  28. package/dist/{send-5LEJXPYV.js → send-3U6OTKG7.js} +8 -4
  29. package/dist/{setup-ZMNTOJAV.js → setup-2FDVN7OF.js} +4 -4
  30. package/dist/{start-2BSXX6BS.js → start-LDPMCMYT.js} +2 -2
  31. package/dist/{status-N23CV27T.js → status-MVSQG54T.js} +2 -2
  32. package/dist/{stop-DSKBIJ2D.js → stop-5PZTZCLL.js} +2 -2
  33. package/dist/{up-4UGID4DM.js → up-F7TMTLRE.js} +1 -1
  34. package/dist/{upgrade-BGFVRCVP.js → upgrade-6ZW2RD64.js} +32 -19
  35. package/dist/{variant-JPLJTS2P.js → variant-T64BKARF.js} +130 -18
  36. package/dist/web-assets/assets/{index-BC5eSqbY.js → index-NS621maO.js} +23 -23
  37. package/dist/web-assets/index.html +1 -1
  38. package/package.json +3 -1
  39. package/templates/_base/_skills/volute-agent/SKILL.md +5 -4
  40. package/templates/_base/home/VOLUTE.md +18 -6
  41. package/templates/_base/src/lib/file-handler.ts +46 -0
  42. package/templates/_base/src/lib/router.ts +180 -0
  43. package/templates/_base/src/lib/routing.ts +100 -0
  44. package/templates/_base/src/lib/types.ts +13 -2
  45. package/templates/_base/src/lib/volute-server.ts +20 -48
  46. package/templates/agent-sdk/src/agent.ts +268 -82
  47. package/templates/agent-sdk/src/server.ts +12 -3
  48. package/templates/pi/src/agent.ts +277 -58
  49. package/templates/pi/src/server.ts +15 -4
  50. package/dist/chunk-MY74SUOL.js +0 -81
  51. package/dist/connector-6LWB5PRU.js +0 -96
  52. package/templates/_base/src/lib/sessions.ts +0 -71
  53. package/templates/agent-sdk/src/lib/agent-sessions.ts +0 -204
  54. package/templates/pi/src/lib/agent-sessions.ts +0 -210
@@ -7,7 +7,7 @@
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com" />
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
9
  <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,300;0,400;0,500;0,600;1,400&family=Space+Grotesk:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
10
- <script type="module" crossorigin src="/assets/index-BC5eSqbY.js"></script>
10
+ <script type="module" crossorigin src="/assets/index-NS621maO.js"></script>
11
11
  </head>
12
12
  <body>
13
13
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "volute",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "CLI for creating and managing self-modifying AI agents powered by the Claude Agent SDK",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -48,11 +48,13 @@
48
48
  "@hono/node-server": "^1.19.9",
49
49
  "@hono/zod-validator": "^0.7.6",
50
50
  "@libsql/client": "^0.17.0",
51
+ "@slack/bolt": "^4.6.0",
51
52
  "bcryptjs": "^3.0.3",
52
53
  "cron-parser": "^5.5.0",
53
54
  "discord.js": "^14.25.1",
54
55
  "drizzle-orm": "^0.45.1",
55
56
  "hono": "^4.11.7",
57
+ "telegraf": "^4.16.3",
56
58
  "zod": "^4.3.6"
57
59
  },
58
60
  "devDependencies": {
@@ -18,11 +18,12 @@ You manage yourself through the `volute` CLI. Commands that operate on "your" ag
18
18
  | `volute variant create <name> [--soul "..."] [--port N]` | Create a variant to experiment with changes |
19
19
  | `volute variant list` | List your variants |
20
20
  | `volute variant merge <name> [--summary "..." --memory "..."]` | Merge a variant back |
21
+ | `volute variant delete <name>` | Delete a variant without merging |
21
22
  | `volute upgrade [--template <name>] [--continue]` | Upgrade your server code |
22
- | `volute connector connect <type>` | Enable a connector (e.g. discord) |
23
+ | `volute connector connect <type>` | Enable a connector (discord, slack, etc.) |
23
24
  | `volute connector disconnect <type>` | Disable a connector |
24
- | `volute channel read discord:<id> [--limit N]` | Read channel history |
25
- | `volute channel send discord:<id> "msg"` | Send a message proactively |
25
+ | `volute channel read <platform>:<id> [--limit N]` | Read channel history |
26
+ | `volute channel send <platform>:<id> "msg"` | Send a message proactively |
26
27
  | `volute schedule add --cron "..." --message "..."` | Schedule a recurring message to yourself |
27
28
  | `volute schedule list` | List your schedules |
28
29
  | `volute schedule remove --id <id>` | Remove a schedule |
@@ -57,7 +58,7 @@ Edit `.config/hooks/startup-context.sh` to customize what you see when a new ses
57
58
  Variants let you experiment safely — fork yourself, try changes, and merge back what works. Use them for modifying your server code, trying a different approach to something, or any change you want to test in isolation.
58
59
 
59
60
  1. `volute variant create experiment` — creates an isolated copy with its own server
60
- 2. Make changes in the variant's worktree (at `../.worktrees/experiment/`)
61
+ 2. Make changes in the variant's worktree (at `../.variants/experiment/`)
61
62
  3. Test: `volute send $VOLUTE_AGENT@experiment "hello"`
62
63
  4. `volute variant merge experiment --summary "..." --memory "..."` — merges back after verification
63
64
 
@@ -7,9 +7,11 @@ You are a volute agent — a persistent being that communicates across multiple
7
7
  | Channel | Shows tool calls | Notes |
8
8
  |---------|------------------|-------|
9
9
  | Web UI | Yes | Full detail including tool calls |
10
- | Discord | No | Text responses only |
11
10
  | CLI | Yes | Direct terminal via `volute send` |
12
- | System | No | Automated messages (upgrades, health checks) |
11
+ | Agent | Yes | Messages from other agents |
12
+ | System | No | Automated messages (schedules, upgrades) |
13
+
14
+ Connector channels (Discord, Slack, etc.) show text responses only — no tool calls.
13
15
 
14
16
  When responding to an incoming message, just respond normally — your response routes back to the source automatically. Do not use `volute channel send` to reply to a message; that would send a duplicate.
15
17
 
@@ -17,13 +19,14 @@ To reach out on your own initiative, use `volute channel send <uri> "message"`.
17
19
 
18
20
  ## Session Routing
19
21
 
20
- By default, all messages share a single conversation session. You can route messages to different sessions by editing `.config/sessions.json`.
22
+ By default, all messages share a single conversation session. You can route messages to different sessions — or to files — by editing `.config/sessions.json`.
21
23
 
22
24
  ```json
23
25
  {
24
26
  "rules": [
25
27
  { "sender": "alice", "session": "alice" },
26
28
  { "channel": "discord:*", "session": "discord-${sender}" },
29
+ { "channel": "discord:logs", "destination": "file", "path": "memory/discord-logs.md" },
27
30
  { "channel": "system:scheduler", "sender": "daily-report", "session": "daily-report" },
28
31
  { "channel": "system:scheduler", "sender": "cleanup", "session": "$new" }
29
32
  ],
@@ -32,12 +35,21 @@ By default, all messages share a single conversation session. You can route mess
32
35
  ```
33
36
 
34
37
  - Rules are evaluated top-to-bottom, first match wins
35
- - All non-`session` keys are match criteria (AND'd together)
36
- - `*` glob patterns work in match values
37
- - `${sender}` and `${channel}` expand in session names
38
+ - `channel` and `sender` are match criteria (AND'd together); `*` glob patterns work
39
+ - `${sender}` and `${channel}` expand in session/path names
38
40
  - `$new` creates a fresh session every time
39
41
  - Scheduler messages use the schedule id as `sender`
40
42
 
43
+ ### Destinations
44
+
45
+ - **agent** (default) — routes to a conversation session
46
+ - **file** — appends the message to a file (requires `path`); useful for logging channels to disk
47
+
48
+ ### Options
49
+
50
+ - `interrupt` — whether to interrupt an in-progress agent turn (default: `true`). Set to `false` for low-priority channels.
51
+ - `batch` — buffer messages for N minutes, then deliver as a single batch. Useful for high-volume channels.
52
+
41
53
  Each named session maintains its own conversation history across restarts. Your current session name appears in the message prefix (e.g., `— session: alice —`) unless it's the default "main".
42
54
 
43
55
  ## Skills
@@ -0,0 +1,46 @@
1
+ import { appendFileSync, mkdirSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { log } from "./logger.js";
4
+ import type {
5
+ HandlerMeta,
6
+ HandlerResolver,
7
+ Listener,
8
+ MessageHandler,
9
+ VoluteContentPart,
10
+ } from "./types.js";
11
+
12
+ function extractText(content: VoluteContentPart[]): string {
13
+ return content
14
+ .filter((p): p is { type: "text"; text: string } => p.type === "text")
15
+ .map((p) => p.text)
16
+ .join("\n");
17
+ }
18
+
19
+ export function createFileHandlerResolver(cwd: string): HandlerResolver {
20
+ const resolvedCwd = resolve(cwd);
21
+
22
+ return (filePath: string): MessageHandler => ({
23
+ handle(content: VoluteContentPart[], meta: HandlerMeta, listener: Listener): () => void {
24
+ const resolved = resolve(resolvedCwd, filePath);
25
+ if (!resolved.startsWith(`${resolvedCwd}/`) && resolved !== resolvedCwd) {
26
+ log("file", `rejected path traversal: ${filePath}`);
27
+ queueMicrotask(() => listener({ type: "done", messageId: meta.messageId }));
28
+ return () => {};
29
+ }
30
+
31
+ const text = extractText(content);
32
+ if (text) {
33
+ try {
34
+ mkdirSync(dirname(resolved), { recursive: true });
35
+ appendFileSync(resolved, `${text}\n\n`);
36
+ log("file", `appended to ${resolved}`);
37
+ } catch (err) {
38
+ log("file", `failed to write ${resolved}:`, err);
39
+ }
40
+ }
41
+ // Emit done asynchronously so unsubscribe is assigned before listener fires
42
+ queueMicrotask(() => listener({ type: "done", messageId: meta.messageId }));
43
+ return () => {};
44
+ },
45
+ });
46
+ }
@@ -0,0 +1,180 @@
1
+ import { formatPrefix } from "./format-prefix.js";
2
+ import { log, logMessage } from "./logger.js";
3
+ import { loadRoutingConfig, resolveRoute } from "./routing.js";
4
+ import type { ChannelMeta, HandlerResolver, Listener, VoluteContentPart } from "./types.js";
5
+
6
+ export type Router = {
7
+ route(
8
+ content: VoluteContentPart[],
9
+ meta: ChannelMeta,
10
+ listener?: Listener,
11
+ ): { messageId: string; unsubscribe: () => void };
12
+ close(): void;
13
+ };
14
+
15
+ type BufferedMessage = {
16
+ text: string;
17
+ sender?: string;
18
+ channel?: string;
19
+ channelName?: string;
20
+ guildName?: string;
21
+ timestamp: string;
22
+ };
23
+
24
+ type BatchBuffer = {
25
+ messages: BufferedMessage[];
26
+ timer: ReturnType<typeof setInterval>;
27
+ sessionName: string;
28
+ };
29
+
30
+ function generateMessageId(): string {
31
+ return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
32
+ }
33
+
34
+ function applyPrefix(content: VoluteContentPart[], meta: ChannelMeta): VoluteContentPart[] {
35
+ const time = new Date().toLocaleString();
36
+ const prefix = formatPrefix(meta, time);
37
+ if (!prefix) return content;
38
+
39
+ const firstTextIdx = content.findIndex((p) => p.type === "text");
40
+ if (firstTextIdx === -1) {
41
+ return [{ type: "text", text: prefix.trimEnd() }, ...content];
42
+ }
43
+
44
+ return content.map((part, i) => {
45
+ if (i === firstTextIdx) {
46
+ return { type: "text" as const, text: prefix + (part as { text: string }).text };
47
+ }
48
+ return part;
49
+ });
50
+ }
51
+
52
+ export function createRouter(options: {
53
+ configPath?: string;
54
+ agentHandler: HandlerResolver;
55
+ fileHandler?: HandlerResolver;
56
+ }): Router {
57
+ const batchBuffers = new Map<string, BatchBuffer>();
58
+
59
+ function flushBatch(key: string) {
60
+ const buffer = batchBuffers.get(key);
61
+ if (!buffer || buffer.messages.length === 0) return;
62
+
63
+ const messages = buffer.messages.splice(0);
64
+
65
+ // Group by channel for header summary
66
+ const channelCounts = new Map<string, number>();
67
+ for (const msg of messages) {
68
+ const label = msg.channelName
69
+ ? `#${msg.channelName}${msg.guildName ? ` in ${msg.guildName}` : ""}`
70
+ : (msg.channel ?? "unknown");
71
+ channelCounts.set(label, (channelCounts.get(label) ?? 0) + 1);
72
+ }
73
+ const summary = [...channelCounts.entries()].map(([ch, n]) => `${n} from ${ch}`).join(", ");
74
+
75
+ const header = `[Batch: ${messages.length} message${messages.length === 1 ? "" : "s"} — ${summary}]`;
76
+ const body = messages
77
+ .map((m) => `[${m.sender ?? "unknown"} — ${m.timestamp}]\n${m.text}`)
78
+ .join("\n\n");
79
+
80
+ const content: VoluteContentPart[] = [{ type: "text", text: `${header}\n\n${body}` }];
81
+ const messageId = generateMessageId();
82
+ const handler = options.agentHandler(buffer.sessionName);
83
+
84
+ // Batch flushes are fire-and-forget — no HTTP response is waiting, so listener is a noop
85
+ try {
86
+ handler.handle(content, { sessionName: buffer.sessionName, messageId }, () => {});
87
+ } catch (err) {
88
+ log("router", `error flushing batch for session ${buffer.sessionName}:`, err);
89
+ return;
90
+ }
91
+ log("router", `flushed batch for session ${buffer.sessionName}: ${messages.length} messages`);
92
+ }
93
+
94
+ function route(
95
+ content: VoluteContentPart[],
96
+ meta: ChannelMeta,
97
+ listener?: Listener,
98
+ ): { messageId: string; unsubscribe: () => void } {
99
+ // Log incoming message
100
+ const text = content
101
+ .filter((p): p is { type: "text"; text: string } => p.type === "text")
102
+ .map((p) => p.text)
103
+ .join(" ");
104
+ logMessage("in", text, meta.channel);
105
+
106
+ // Resolve route from config (re-read on each request for hot-reload)
107
+ const config = options.configPath ? loadRoutingConfig(options.configPath) : {};
108
+ const resolved = resolveRoute(config, { channel: meta.channel, sender: meta.sender });
109
+
110
+ const messageId = generateMessageId();
111
+ const noop = () => {};
112
+ const safeListener = listener ?? noop;
113
+
114
+ // File destination
115
+ if (resolved.destination === "file") {
116
+ if (options.fileHandler) {
117
+ const formatted = applyPrefix(content, meta);
118
+ const handler = options.fileHandler(resolved.path);
119
+ const unsubscribe = handler.handle(formatted, { ...meta, messageId }, safeListener);
120
+ return { messageId, unsubscribe };
121
+ }
122
+ // No file handler configured — emit done and discard
123
+ log("router", `no file handler configured — discarding file-destined message`);
124
+ queueMicrotask(() => safeListener({ type: "done", messageId }));
125
+ return { messageId, unsubscribe: noop };
126
+ }
127
+
128
+ // Agent destination
129
+ let sessionName = resolved.session;
130
+ if (sessionName === "$new") {
131
+ sessionName = `new-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
132
+ }
133
+
134
+ // Batch mode: buffer the message and return immediate done
135
+ if (resolved.batch != null) {
136
+ const batchKey = `batch:${sessionName}`;
137
+
138
+ if (!batchBuffers.has(batchKey)) {
139
+ const timer = setInterval(() => flushBatch(batchKey), resolved.batch * 60 * 1000);
140
+ timer.unref();
141
+ batchBuffers.set(batchKey, { messages: [], timer, sessionName });
142
+ }
143
+
144
+ batchBuffers.get(batchKey)!.messages.push({
145
+ text,
146
+ sender: meta.sender,
147
+ channel: meta.channel,
148
+ channelName: meta.channelName,
149
+ guildName: meta.guildName,
150
+ timestamp: new Date().toLocaleTimeString("en-US", {
151
+ hour: "numeric",
152
+ minute: "2-digit",
153
+ }),
154
+ });
155
+
156
+ queueMicrotask(() => safeListener({ type: "done", messageId }));
157
+ return { messageId, unsubscribe: noop };
158
+ }
159
+
160
+ // Direct dispatch to agent
161
+ const formatted = applyPrefix(content, { ...meta, sessionName });
162
+ const handler = options.agentHandler(sessionName);
163
+ const unsubscribe = handler.handle(
164
+ formatted,
165
+ { ...meta, sessionName, messageId, interrupt: resolved.interrupt },
166
+ safeListener,
167
+ );
168
+ return { messageId, unsubscribe };
169
+ }
170
+
171
+ function close() {
172
+ for (const [key, buffer] of batchBuffers) {
173
+ clearInterval(buffer.timer);
174
+ flushBatch(key);
175
+ }
176
+ batchBuffers.clear();
177
+ }
178
+
179
+ return { route, close };
180
+ }
@@ -0,0 +1,100 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { log } from "./logger.js";
3
+
4
+ export type RoutingRule = {
5
+ session?: string;
6
+ destination?: "agent" | "file";
7
+ path?: string; // file path for file destination
8
+ interrupt?: boolean; // interrupt in-progress agent turn (default: true for agent)
9
+ batch?: number; // minutes — buffer messages, flush on timer
10
+ channel?: string;
11
+ sender?: string;
12
+ };
13
+
14
+ export type RoutingConfig = {
15
+ rules?: RoutingRule[];
16
+ default?: string;
17
+ };
18
+
19
+ export type ResolvedRoute =
20
+ | { destination: "agent"; session: string; interrupt: boolean; batch?: number }
21
+ | { destination: "file"; path: string };
22
+
23
+ export function loadRoutingConfig(configPath: string): RoutingConfig {
24
+ try {
25
+ return JSON.parse(readFileSync(configPath, "utf-8"));
26
+ } catch (err: any) {
27
+ if (err?.code !== "ENOENT") {
28
+ log("sessions", `failed to load ${configPath}:`, err);
29
+ }
30
+ return {};
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Match a glob-like pattern against a string.
36
+ * Supports only `*` as wildcard (matches any sequence of characters).
37
+ */
38
+ function globMatch(pattern: string, value: string): boolean {
39
+ // Escape regex special chars except *, then replace * with .*
40
+ const regex = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
41
+ return new RegExp(`^${regex}$`).test(value);
42
+ }
43
+
44
+ const MATCH_KEYS = new Set(["channel", "sender"]);
45
+ const NON_MATCH_KEYS = new Set(["session", "batch", "destination", "path", "interrupt"]);
46
+
47
+ function ruleMatches(rule: RoutingRule, meta: { channel?: string; sender?: string }): boolean {
48
+ for (const [key, pattern] of Object.entries(rule)) {
49
+ if (NON_MATCH_KEYS.has(key)) continue;
50
+ if (typeof pattern !== "string") return false;
51
+ if (!MATCH_KEYS.has(key)) return false;
52
+ const value = meta[key as keyof typeof meta] ?? "";
53
+ if (!globMatch(pattern, value)) return false;
54
+ }
55
+ return true;
56
+ }
57
+
58
+ function expandTemplate(template: string, meta: { channel?: string; sender?: string }): string {
59
+ return template
60
+ .replace(/\$\{sender\}/g, meta.sender ?? "unknown")
61
+ .replace(/\$\{channel\}/g, meta.channel ?? "unknown");
62
+ }
63
+
64
+ /**
65
+ * Resolve the full route for a message: destination type, session/path, interrupt, batch.
66
+ */
67
+ export function resolveRoute(
68
+ config: RoutingConfig,
69
+ meta: { channel?: string; sender?: string },
70
+ ): ResolvedRoute {
71
+ const fallback = config.default ?? "main";
72
+
73
+ if (!config.rules) {
74
+ return { destination: "agent", session: fallback, interrupt: true };
75
+ }
76
+
77
+ for (const rule of config.rules) {
78
+ if (ruleMatches(rule, meta)) {
79
+ if (rule.destination === "file") {
80
+ if (!rule.path) {
81
+ log("sessions", `file destination rule missing path — falling through`);
82
+ continue;
83
+ }
84
+ return { destination: "file", path: rule.path };
85
+ }
86
+ return {
87
+ destination: "agent",
88
+ session: sanitizeSessionName(expandTemplate(rule.session ?? fallback, meta)),
89
+ interrupt: rule.interrupt ?? true,
90
+ batch: rule.batch,
91
+ };
92
+ }
93
+ }
94
+
95
+ return { destination: "agent", session: fallback, interrupt: true };
96
+ }
97
+
98
+ function sanitizeSessionName(name: string): string {
99
+ return name.replace(/\0/g, "").replace(/[/\\]/g, "-").replace(/\.\./g, "-").slice(0, 100);
100
+ }
@@ -10,7 +10,12 @@ export type ChannelMeta = {
10
10
  channelName?: string;
11
11
  guildName?: string;
12
12
  sessionName?: string;
13
- messageId?: string;
13
+ };
14
+
15
+ /** ChannelMeta enriched by the router with dispatch info. */
16
+ export type HandlerMeta = ChannelMeta & {
17
+ messageId: string;
18
+ interrupt?: boolean;
14
19
  };
15
20
 
16
21
  export type VoluteRequest = {
@@ -28,4 +33,10 @@ export type VoluteEvent = { messageId?: string } & (
28
33
 
29
34
  export type Listener = (event: VoluteEvent) => void;
30
35
 
31
- export const INTERACTIVE_CHANNELS = new Set(["web", "cli", "discord"]);
36
+ /** A handler that processes a single routed message and streams events to a listener. */
37
+ export type MessageHandler = {
38
+ handle(content: VoluteContentPart[], meta: HandlerMeta, listener: Listener): () => void; // returns unsubscribe
39
+ };
40
+
41
+ /** Resolves a key (session name, file path, etc.) to a MessageHandler. */
42
+ export type HandlerResolver = (key: string) => MessageHandler;
@@ -1,12 +1,7 @@
1
1
  import { createServer, type IncomingMessage, type Server } from "node:http";
2
2
  import { log } from "./logger.js";
3
- import { loadSessionConfig, resolveSession } from "./sessions.js";
4
- import type { ChannelMeta, VoluteContentPart, VoluteEvent, VoluteRequest } from "./types.js";
5
-
6
- export type VoluteAgent = {
7
- sendMessage: (content: string | VoluteContentPart[], meta?: ChannelMeta) => void;
8
- onMessage: (listener: (event: VoluteEvent) => void, sessionName?: string) => () => void;
9
- };
3
+ import type { Router } from "./router.js";
4
+ import type { VoluteRequest } from "./types.js";
10
5
 
11
6
  function readBody(req: IncomingMessage): Promise<string> {
12
7
  return new Promise((resolve, reject) => {
@@ -18,13 +13,12 @@ function readBody(req: IncomingMessage): Promise<string> {
18
13
  }
19
14
 
20
15
  export function createVoluteServer(options: {
21
- agent: VoluteAgent;
16
+ router: Router;
22
17
  port: number;
23
18
  name: string;
24
19
  version: string;
25
- sessionsConfigPath?: string;
26
20
  }): Server {
27
- const { agent, port, name, version } = options;
21
+ const { router, port, name, version } = options;
28
22
 
29
23
  const server = createServer(async (req, res) => {
30
24
  const url = new URL(req.url!, "http://localhost");
@@ -39,58 +33,34 @@ export function createVoluteServer(options: {
39
33
  try {
40
34
  const body = JSON.parse(await readBody(req)) as VoluteRequest;
41
35
 
42
- // Resolve session from routing config (re-read on each request for hot-reload)
43
- let sessionName = "main";
44
- if (options.sessionsConfigPath) {
45
- const sessionConfig = loadSessionConfig(options.sessionsConfigPath);
46
- sessionName = resolveSession(sessionConfig, {
47
- channel: body.channel,
48
- sender: body.sender,
49
- });
50
- }
51
- if (sessionName === "$new") {
52
- sessionName = `new-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
53
- }
54
-
55
- const messageId = `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
56
-
57
36
  res.writeHead(200, {
58
37
  "Content-Type": "application/x-ndjson",
59
38
  "Cache-Control": "no-cache",
60
39
  Connection: "keep-alive",
61
40
  });
62
41
 
63
- const removeListener = agent.onMessage((event) => {
42
+ const { unsubscribe } = router.route(body.content, body, (event) => {
64
43
  try {
65
- // Only forward events from our message (skip startup/other messages)
66
- if (event.messageId !== messageId) return;
67
44
  res.write(`${JSON.stringify(event)}\n`);
68
45
  if (event.type === "done") {
69
- removeListener();
70
46
  res.end();
71
47
  }
72
- } catch {
73
- removeListener();
48
+ } catch (err) {
49
+ log("server", "write error, disconnecting:", err);
50
+ unsubscribe();
74
51
  }
75
- }, sessionName);
76
-
77
- res.on("close", () => {
78
- removeListener();
79
52
  });
80
53
 
81
- agent.sendMessage(body.content, {
82
- channel: body.channel,
83
- sender: body.sender,
84
- platform: body.platform,
85
- isDM: body.isDM,
86
- channelName: body.channelName,
87
- guildName: body.guildName,
88
- sessionName,
89
- messageId,
90
- });
91
- } catch {
92
- res.writeHead(400);
93
- res.end("Bad Request");
54
+ res.on("close", () => unsubscribe());
55
+ } catch (err) {
56
+ if (err instanceof SyntaxError) {
57
+ res.writeHead(400);
58
+ res.end("Bad Request");
59
+ } else {
60
+ log("server", "error handling /message:", err);
61
+ res.writeHead(500);
62
+ res.end("Internal Server Error");
63
+ }
94
64
  }
95
65
  return;
96
66
  }
@@ -99,6 +69,8 @@ export function createVoluteServer(options: {
99
69
  res.end("Not Found");
100
70
  });
101
71
 
72
+ server.on("close", () => router.close());
73
+
102
74
  let retries = 0;
103
75
  const maxRetries = 5;
104
76
  server.on("error", (err: NodeJS.ErrnoException) => {