volute 0.3.1 → 0.5.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 (82) hide show
  1. package/README.md +29 -29
  2. package/dist/agent-Z2B6EFEQ.js +75 -0
  3. package/dist/{agent-manager-AUCKMGPR.js → agent-manager-PXBKA2GK.js} +4 -4
  4. package/dist/channel-MK5OK2SI.js +113 -0
  5. package/dist/chunk-5X7HGB6L.js +107 -0
  6. package/dist/{chunk-YGFIWIOF.js → chunk-7L4AN5D4.js} +1 -1
  7. package/dist/{chunk-VRVVQIYY.js → chunk-AZEL2IEK.js} +1 -1
  8. package/dist/chunk-B3R6L2GW.js +24 -0
  9. package/dist/{chunk-DNOXHLE5.js → chunk-HE67X4T6.js} +1 -1
  10. package/dist/{chunk-I6OHXCMV.js → chunk-MW2KFO3B.js} +47 -9
  11. package/dist/chunk-MXUCNIBG.js +168 -0
  12. package/dist/chunk-SMISE4SV.js +226 -0
  13. package/dist/{chunk-SOZA2TLP.js → chunk-UAVD2AHX.js} +1 -1
  14. package/dist/{chunk-3C2XR4IY.js → chunk-UX25Z2ND.js} +113 -107
  15. package/dist/{chunk-GSPKUPKU.js → chunk-XUA3JUFK.js} +2 -1
  16. package/dist/chunk-ZYGKG6VC.js +22 -0
  17. package/dist/cli.js +98 -75
  18. package/dist/connector-LYEMXQEV.js +157 -0
  19. package/dist/connectors/discord.js +104 -161
  20. package/dist/connectors/slack.js +179 -0
  21. package/dist/connectors/telegram.js +175 -0
  22. package/dist/conversation-ERXEQZTY.js +163 -0
  23. package/dist/create-RVCZN6HE.js +91 -0
  24. package/dist/{daemon-client-XR24PUJF.js → daemon-client-ZY6UUN2M.js} +2 -2
  25. package/dist/daemon.js +824 -252
  26. package/dist/{delete-GQ7JEK2S.js → delete-3QH7VYIN.js} +8 -9
  27. package/dist/{down-3OB6UVAJ.js → down-O7IFZLVJ.js} +1 -1
  28. package/dist/{env-JB27UAC3.js → env-4D4REPJF.js} +8 -5
  29. package/dist/{history-3VRUBGGV.js → history-OEONB53Z.js} +5 -5
  30. package/dist/{import-K4MP2GX7.js → import-MXJB2EII.js} +23 -8
  31. package/dist/{logs-NXFFGUKY.js → logs-DF342W4M.js} +2 -2
  32. package/dist/message-ADHWFHSI.js +32 -0
  33. package/dist/package-VQOE7JNH.js +89 -0
  34. package/dist/{schedule-4I5TYHFH.js → schedule-NAG6F463.js} +12 -7
  35. package/dist/send-66QMKRUH.js +75 -0
  36. package/dist/{setup-SRS7AUAA.js → setup-RPRRGG2F.js} +6 -6
  37. package/dist/{start-LDPMCMYT.js → start-TUOXDSFL.js} +3 -3
  38. package/dist/{status-MVSQG54T.js → status-A36EHRO4.js} +3 -3
  39. package/dist/{stop-5PZTZCLL.js → stop-AOJZLQ5X.js} +6 -7
  40. package/dist/{up-UT3IMKCA.js → up-7ILD7GU7.js} +2 -2
  41. package/dist/update-LPSIAWQ2.js +140 -0
  42. package/dist/update-check-Y33QDCFL.js +17 -0
  43. package/dist/{upgrade-CDKECCGN.js → upgrade-FX2TKJ2S.js} +16 -15
  44. package/dist/{variant-CVYM3EQG.js → variant-LAB67OC2.js} +17 -12
  45. package/dist/web-assets/assets/index-BbRmoxoA.js +308 -0
  46. package/dist/web-assets/index.html +2 -2
  47. package/drizzle/0003_clean_ego.sql +12 -0
  48. package/drizzle/meta/0003_snapshot.json +417 -0
  49. package/drizzle/meta/_journal.json +7 -0
  50. package/package.json +3 -1
  51. package/templates/_base/.init/.config/hooks/startup-context.sh +19 -1
  52. package/templates/_base/_skills/volute-agent/SKILL.md +112 -16
  53. package/templates/_base/home/.config/routes.json +10 -0
  54. package/templates/_base/home/VOLUTE.md +19 -28
  55. package/templates/_base/src/lib/file-handler.ts +46 -0
  56. package/templates/_base/src/lib/format-prefix.ts +1 -1
  57. package/templates/_base/src/lib/router.ts +327 -0
  58. package/templates/_base/src/lib/routing.ts +137 -0
  59. package/templates/_base/src/lib/types.ts +16 -3
  60. package/templates/_base/src/lib/volute-server.ts +20 -48
  61. package/templates/agent-sdk/.init/.config/routes.json +5 -0
  62. package/templates/agent-sdk/.init/CLAUDE.md +2 -2
  63. package/templates/agent-sdk/src/agent.ts +269 -82
  64. package/templates/agent-sdk/src/server.ts +19 -4
  65. package/templates/agent-sdk/volute-template.json +1 -1
  66. package/templates/pi/.init/.config/routes.json +5 -0
  67. package/templates/pi/.init/AGENTS.md +1 -1
  68. package/templates/pi/src/agent.ts +279 -58
  69. package/templates/pi/src/server.ts +15 -4
  70. package/templates/pi/volute-template.json +1 -1
  71. package/dist/channel-7FZ6D25H.js +0 -90
  72. package/dist/chunk-N4YNKR3Q.js +0 -90
  73. package/dist/connector-TVJULIRT.js +0 -96
  74. package/dist/create-BRG2DBWI.js +0 -79
  75. package/dist/send-UK3JBZIB.js +0 -53
  76. package/dist/web-assets/assets/index-BC5eSqbY.js +0 -296
  77. package/templates/_base/src/lib/sessions.ts +0 -71
  78. package/templates/agent-sdk/.init/.config/sessions.json +0 -4
  79. package/templates/agent-sdk/src/lib/agent-sessions.ts +0 -204
  80. package/templates/pi/.init/.config/sessions.json +0 -1
  81. package/templates/pi/src/lib/agent-sessions.ts +0 -210
  82. package/dist/{service-SA4TTMDU.js → service-HZNIDNJF.js} +3 -3
@@ -1,32 +1,37 @@
1
1
  ---
2
2
  name: Volute CLI
3
- description: This skill should be used when working with the volute CLI, understanding variants, forking, merging, or managing the agent server. Covers "create variant", "merge variant", "send to variant", "fork", "volute CLI", "variant workflow", "agent server", "supervisor", "channel", "discord", "send message", "read messages", "history", "connector", "schedule", "agent-to-agent", "proactive", "initiative", "reach out".
3
+ description: This skill should be used when working with the volute CLI, understanding variants, forking, merging, or managing the agent server. Also covers routing config, batch settings, channel gating, and message flow. Covers "create variant", "merge variant", "send to variant", "fork", "volute CLI", "variant workflow", "agent server", "supervisor", "channel", "discord", "send message", "read messages", "history", "connector", "schedule", "agent-to-agent", "proactive", "initiative", "reach out", "conversation", "group chat", "participants", "invite", "routing", "routes.json", "batch", "debounce", "trigger", "gating", "gate".
4
4
  ---
5
5
 
6
6
  # Self-Management
7
7
 
8
- You manage yourself through the `volute` CLI. Commands that operate on "your" agent use `--agent` flag or auto-detect via `VOLUTE_AGENT` env var (which is set for you).
8
+ You manage yourself through the `volute` CLI. Your agent name is auto-detected via the `VOLUTE_AGENT` env var (which is set for you), so you never need to pass it explicitly.
9
9
 
10
10
  ## Commands
11
11
 
12
12
  | Command | Purpose |
13
13
  |---------|---------|
14
- | `volute status` | Check your status |
15
- | `volute logs [--follow] [-n N]` | Read your own logs |
16
- | `volute history [--channel <ch>] [--limit N]` | View your activity across all channels |
17
- | `volute send <other-agent> "msg"` | Send a message to another agent |
14
+ | `volute agent start` | Start your server |
15
+ | `volute agent stop` | Stop your server |
16
+ | `volute agent status` | Check your status |
17
+ | `volute agent logs [--follow] [-n N]` | Read your own logs |
18
+ | `volute message history [--channel <ch>] [--limit N]` | View your activity across all channels |
19
+ | `volute message send <other-agent> "msg"` | Send a message to another agent (or pipe via stdin) |
18
20
  | `volute variant create <name> [--soul "..."] [--port N]` | Create a variant to experiment with changes |
19
21
  | `volute variant list` | List your variants |
20
22
  | `volute variant merge <name> [--summary "..." --memory "..."]` | Merge a variant back |
21
23
  | `volute variant delete <name>` | Delete a variant without merging |
22
- | `volute upgrade [--template <name>] [--continue]` | Upgrade your server code |
23
- | `volute connector connect <type>` | Enable a connector (e.g. discord) |
24
+ | `volute agent upgrade [--template <name>] [--continue]` | Upgrade your server code |
25
+ | `volute connector connect <type>` | Enable a connector (discord, slack, etc.) |
24
26
  | `volute connector disconnect <type>` | Disable a connector |
25
- | `volute channel read discord:<id> [--limit N]` | Read channel history |
26
- | `volute channel send discord:<id> "msg"` | Send a message proactively |
27
+ | `volute channel read <platform>:<id> [--limit N]` | Read channel history |
28
+ | `volute channel send <platform>:<id> "msg"` | Send a message proactively (or pipe via stdin) |
27
29
  | `volute schedule add --cron "..." --message "..."` | Schedule a recurring message to yourself |
28
30
  | `volute schedule list` | List your schedules |
29
31
  | `volute schedule remove --id <id>` | Remove a schedule |
32
+ | `volute conversation create --participants u1,a1` | Create a group conversation |
33
+ | `volute conversation list` | List your conversations |
34
+ | `volute conversation send <id> "msg"` | Send a message to a conversation (or pipe via stdin) |
30
35
 
31
36
  ## Schedules
32
37
 
@@ -37,9 +42,21 @@ volute schedule add --cron "0 9 * * *" --message "morning — review what's on y
37
42
  volute schedule add --cron "0 0 * * 0" --message "weekly — consolidate your memory and reflect on the past week"
38
43
  ```
39
44
 
45
+ ## Piping Messages via Stdin
46
+
47
+ All send commands accept the message from stdin instead of as an argument. This avoids shell escaping issues with quotes, special characters, and multiline content:
48
+
49
+ ```sh
50
+ echo "Hello, how's it going?" | volute message send other-agent
51
+ echo "Check out this $variable" | volute channel send discord:123456
52
+ echo "Update on the task" | volute conversation send conv-abc
53
+ ```
54
+
55
+ If both a positional argument and stdin are provided, the argument takes precedence. Stdin is only read when the message argument is omitted and stdin is not an interactive terminal.
56
+
40
57
  ## Agent-to-Agent Messaging
41
58
 
42
- When you use `volute send`, your agent name is automatically used as the sender and the channel is set to `agent`. The receiving agent can route agent messages to a specific session via their session routing config:
59
+ When you use `volute message send`, your agent name is automatically used as the sender and the channel is set to `agent`. The receiving agent can route agent messages to a specific session via their session routing config:
43
60
 
44
61
  ```json
45
62
  { "channel": "agent", "sender": "your-name", "session": "your-name" }
@@ -59,7 +76,7 @@ Variants let you experiment safely — fork yourself, try changes, and merge bac
59
76
 
60
77
  1. `volute variant create experiment` — creates an isolated copy with its own server
61
78
  2. Make changes in the variant's worktree (at `../.variants/experiment/`)
62
- 3. Test: `volute send $VOLUTE_AGENT@experiment "hello"`
79
+ 3. Test: `volute message send $VOLUTE_AGENT@experiment "hello"`
63
80
  4. `volute variant merge experiment --summary "..." --memory "..."` — merges back after verification
64
81
 
65
82
  You can also fork with a different personality to explore a different version of yourself:
@@ -71,11 +88,11 @@ After a merge, you receive orientation context about what changed. Update your m
71
88
 
72
89
  ## Upgrade Workflow
73
90
 
74
- `volute upgrade` merges the latest template code into a testable variant:
91
+ `volute agent upgrade` merges the latest template code into a testable variant:
75
92
 
76
- 1. `volute upgrade` — creates an `upgrade` variant
77
- 2. Resolve any merge conflicts if prompted, then `volute upgrade --continue`
78
- 3. Test: `volute send $VOLUTE_AGENT@upgrade "hello"`
93
+ 1. `volute agent upgrade` — creates an `upgrade` variant
94
+ 2. Resolve any merge conflicts if prompted, then `volute agent upgrade --continue`
95
+ 3. Test: `volute message send $VOLUTE_AGENT@upgrade "hello"`
79
96
  4. `volute variant merge upgrade` — merge back
80
97
 
81
98
  ## Custom Skills
@@ -86,6 +103,85 @@ Create skills by writing `.claude/skills/<name>/SKILL.md` files in your `home/`
86
103
 
87
104
  Edit `home/.mcp.json` to configure MCP servers for your SDK session. This gives you access to additional tools and services.
88
105
 
106
+ ## Message Routing
107
+
108
+ Messages are routed to sessions based on rules in `.config/routes.json`. Rules are evaluated in order; first match wins. Unmatched messages go to the `default` session (defaults to `"main"`).
109
+
110
+ ### Rule syntax
111
+
112
+ ```json
113
+ {
114
+ "rules": [
115
+ { "channel": "discord:*", "session": "discord", "batch": { "debounce": 20, "maxWait": 120, "triggers": ["@myagent"] } },
116
+ { "channel": "volute:*", "isDM": true, "session": "${sender}" },
117
+ { "channel": "volute:*", "isDM": false, "session": "${channel}", "batch": { "debounce": 20, "maxWait": 120 } },
118
+ { "sender": "alice", "session": "alice" },
119
+ { "channel": "system:*", "session": "$new" },
120
+ { "channel": "discord:logs", "destination": "file", "path": "inbox/log.md" }
121
+ ],
122
+ "default": "main",
123
+ "gateUnmatched": true
124
+ }
125
+ ```
126
+
127
+ ### Match criteria
128
+
129
+ | Field | Type | Description |
130
+ |-------|------|-------------|
131
+ | `channel` | glob string | Channel URI (e.g. `discord:*`, `volute:conv-*`) |
132
+ | `sender` | glob string | Sender name |
133
+ | `isDM` | boolean | Match DMs (`true`) or group channels (`false`) |
134
+ | `participants` | number | Match exact participant count |
135
+
136
+ ### Rule fields
137
+
138
+ | Field | Description |
139
+ |-------|-------------|
140
+ | `session` | Target session name. Supports `${sender}`, `${channel}` templates, or `$new` for a unique session per message |
141
+ | `destination` | `"agent"` (default) or `"file"` |
142
+ | `path` | File path when destination is `"file"` |
143
+ | `batch` | Batch config (see below) |
144
+ | `interrupt` | Whether to interrupt an in-progress turn (default: `true`) |
145
+
146
+ ### Batch config
147
+
148
+ Batch mode buffers messages and delivers them together. Configure with an object:
149
+
150
+ | Field | Type | Description |
151
+ |-------|------|-------------|
152
+ | `debounce` | seconds | Wait for quiet period before flushing — resets on each new message |
153
+ | `maxWait` | seconds | Maximum time before forced flush, even during continuous activity |
154
+ | `triggers` | string[] | Patterns that cause immediate flush (case-insensitive substring match) |
155
+
156
+ Examples:
157
+ - `{ "debounce": 20, "maxWait": 120 }` — flush after 20s of quiet, or 2 minutes max
158
+ - `{ "debounce": 20, "maxWait": 120, "triggers": ["@myagent"] }` — same, but flush immediately on @mention
159
+ - `{ "triggers": ["urgent"] }` — no timer, flush only on trigger (or immediately if no timers)
160
+
161
+ Batched messages arrive as a single message with a `[Batch: N messages — ...]` header showing the channel URI and message count, followed by individual messages with `[sender — time]` prefixes.
162
+
163
+ ## Channel Gating
164
+
165
+ When `gateUnmatched` is `true` (the default), messages from channels without a matching rule are held:
166
+
167
+ 1. First message from an unknown channel triggers a **[Channel Invite]** notification in your main session
168
+ 2. The notification includes channel details, a message preview, and a suggested routing rule
169
+ 3. Further messages are saved to `inbox/<channel>.md`
170
+ 4. To accept: add a routing rule to `.config/routes.json`
171
+ 5. To reject: delete the inbox file
172
+ 6. Set `gateUnmatched: false` to route all unmatched messages to the default session
173
+
174
+ ## Channel Commands
175
+
176
+ Read and send messages to any connected channel:
177
+
178
+ ```sh
179
+ volute channel read <uri> [--limit N] # Read recent messages
180
+ volute channel send <uri> "message" # Send a message
181
+ ```
182
+
183
+ Channel URIs use `platform:id` format (e.g. `discord:123456`, `volute:conv-abc`).
184
+
89
185
  ## Git Introspection
90
186
 
91
187
  Your cwd is `home/`, so use `git -C ..` for project-level operations:
@@ -0,0 +1,10 @@
1
+ {
2
+ "rules": [
3
+ {
4
+ "channel": "volute:*",
5
+ "isDM": false,
6
+ "session": "${channel}",
7
+ "batch": { "debounce": 20, "maxWait": 120, "triggers": ["@{{name}}"] }
8
+ }
9
+ ]
10
+ }
@@ -6,41 +6,32 @@ You are a volute agent — a persistent being that communicates across multiple
6
6
 
7
7
  | Channel | Shows tool calls | Notes |
8
8
  |---------|------------------|-------|
9
- | Web UI | Yes | Full detail including tool calls |
10
- | Discord | No | Text responses only |
11
- | CLI | Yes | Direct terminal via `volute send` |
12
- | System | No | Automated messages (upgrades, health checks) |
9
+ | Volute | Yes | Web UI, CLI, agent-to-agent |
10
+ | System | No | Automated messages (schedules, upgrades) |
13
11
 
14
- 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.
12
+ Connector channels (Discord, Slack, etc.) show text responses only no tool calls.
15
13
 
16
- To reach out on your own initiative, use `volute channel send <uri> "message"`. See the **volute-agent** skill for details.
14
+ ## Responding to Messages
17
15
 
18
- ## Session Routing
16
+ For **direct messages**, respond normally — your response routes back to the source automatically. Do not use `volute channel send` to reply; that would send a duplicate.
19
17
 
20
- By default, all messages share a single conversation session. You can route messages to different sessions by editing `.config/sessions.json`.
18
+ For **batched channels** (group chats, high-volume sources), your text response stays in the session as internal processing — it doesn't get sent anywhere. Use `volute channel send <uri> "message"` to deliberately send to the channel. This lets you read the room, think about what's happening, and choose when and whether to speak up.
21
19
 
22
- ```json
23
- {
24
- "rules": [
25
- { "sender": "alice", "session": "alice" },
26
- { "channel": "discord:*", "session": "discord-${sender}" },
27
- { "channel": "system:scheduler", "sender": "daily-report", "session": "daily-report" },
28
- { "channel": "system:scheduler", "sender": "cleanup", "session": "$new" }
29
- ],
30
- "default": "main"
31
- }
20
+ To reach out on your own initiative, use `volute channel send <uri> "message"`.
21
+
22
+ All send commands also accept the message from stdin, which avoids shell escaping issues:
23
+ ```sh
24
+ echo "message with 'quotes' and $special chars" | volute channel send <uri>
32
25
  ```
33
26
 
34
- - 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
- - `$new` creates a fresh session every time
39
- - Scheduler messages use the schedule id as `sender`
27
+ ## Sessions
28
+
29
+ Messages are routed to named sessions based on rules in `.config/routes.json`. Each session has its own conversation history. Without config, everything goes to "main". Your session name appears in the message prefix (e.g. `— session: alice —`) unless it's "main".
30
+
31
+ ## Channel Gating
40
32
 
41
- 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".
33
+ Messages from unrecognized channels are held until you add a routing rule. You'll receive a **[Channel Invite]** notification in your main session with the channel details, a message preview, and instructions for accepting or rejecting.
42
34
 
43
- ## Skills
35
+ ## Reference
44
36
 
45
- - Use the **volute-agent** skill for CLI commands, variants, upgrades, and self-management.
46
- - Use the **memory** skill for detailed memory management and consolidation.
37
+ See the **volute-agent** skill for routing config syntax, batch options, channel management, and all CLI commands.
@@ -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
+ }
@@ -14,7 +14,7 @@ export function formatPrefix(meta: ChannelMeta | undefined, time: string): strin
14
14
  sender += " in DM";
15
15
  } else if (meta.channelName) {
16
16
  sender += ` in #${meta.channelName}`;
17
- if (meta.guildName) sender += ` in ${meta.guildName}`;
17
+ if (meta.serverName) sender += ` in ${meta.serverName}`;
18
18
  }
19
19
  const parts = [platform, sender].filter(Boolean);
20
20
  // Include session name if not the default
@@ -0,0 +1,327 @@
1
+ import { formatPrefix } from "./format-prefix.js";
2
+ import { log, logMessage } from "./logger.js";
3
+ import { type BatchConfig, 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
+ serverName?: string;
21
+ timestamp: string;
22
+ };
23
+
24
+ type BatchBuffer = {
25
+ messages: BufferedMessage[];
26
+ debounceTimer: ReturnType<typeof setTimeout> | null;
27
+ maxWaitTimer: ReturnType<typeof setTimeout> | null;
28
+ sessionName: string;
29
+ config: BatchConfig;
30
+ };
31
+
32
+ function generateMessageId(): string {
33
+ return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
34
+ }
35
+
36
+ function applyPrefix(content: VoluteContentPart[], meta: ChannelMeta): VoluteContentPart[] {
37
+ const time = new Date().toLocaleString();
38
+ const prefix = formatPrefix(meta, time);
39
+ if (!prefix) return content;
40
+
41
+ const firstTextIdx = content.findIndex((p) => p.type === "text");
42
+ if (firstTextIdx === -1) {
43
+ return [{ type: "text", text: prefix.trimEnd() }, ...content];
44
+ }
45
+
46
+ return content.map((part, i) => {
47
+ if (i === firstTextIdx) {
48
+ return { type: "text" as const, text: prefix + (part as { text: string }).text };
49
+ }
50
+ return part;
51
+ });
52
+ }
53
+
54
+ function sanitizeChannelPath(channel: string): string {
55
+ return channel
56
+ .replace(/[/\\:]/g, "-")
57
+ .replace(/\.\./g, "-")
58
+ .replace(/\0/g, "")
59
+ .slice(0, 100);
60
+ }
61
+
62
+ /** Check if message text matches any trigger patterns (case-insensitive substring match). */
63
+ function matchesTrigger(text: string, triggers: string[]): boolean {
64
+ const lower = text.toLowerCase();
65
+ return triggers.some((t) => lower.includes(t.toLowerCase()));
66
+ }
67
+
68
+ function formatInviteNotification(
69
+ meta: ChannelMeta,
70
+ filePath: string,
71
+ messageText: string,
72
+ ): string {
73
+ const time = new Date().toLocaleString();
74
+ const lines = ["[Channel Invite]"];
75
+ if (meta.channel) lines.push(`Channel: ${meta.channel}`);
76
+ if (meta.sender) lines.push(`Sender: ${meta.sender}`);
77
+ if (meta.platform) lines.push(`Platform: ${meta.platform}`);
78
+ if (meta.serverName) lines.push(`Server: ${meta.serverName}`);
79
+ if (meta.channelName) lines.push(`Channel name: ${meta.channelName}`);
80
+ if (meta.participants && meta.participants.length > 0)
81
+ lines.push(`Participants: ${meta.participants.join(", ")}`);
82
+ lines.push("");
83
+ const preview = messageText.length > 200 ? `${messageText.slice(0, 200)}...` : messageText;
84
+ lines.push(`[${meta.sender ?? "unknown"} — ${time}]`);
85
+ lines.push(preview);
86
+ lines.push("");
87
+ lines.push(`Further messages will be saved to ${filePath}`);
88
+ lines.push("");
89
+ lines.push("To accept, add a routing rule to .config/routes.json:");
90
+ const suggestedSession = sanitizeChannelPath(meta.channel ?? "unknown");
91
+ const otherCount = (meta.participantCount ?? 1) - 1;
92
+ if (otherCount > 1) {
93
+ lines.push(
94
+ ` { "channel": "${meta.channel}", "session": "${suggestedSession}", "batch": { "debounce": 20, "maxWait": 120 } }`,
95
+ );
96
+ lines.push(
97
+ `(batch recommended — ${otherCount} other participants may generate frequent messages)`,
98
+ );
99
+ } else {
100
+ lines.push(` { "channel": "${meta.channel}", "session": "${suggestedSession}" }`);
101
+ }
102
+ lines.push(`To respond, use: volute channel send ${meta.channel ?? "unknown"} "your message"`);
103
+ lines.push(`To reject, delete ${filePath}`);
104
+ return lines.join("\n");
105
+ }
106
+
107
+ export function createRouter(options: {
108
+ configPath?: string;
109
+ agentHandler: HandlerResolver;
110
+ fileHandler?: HandlerResolver;
111
+ }): Router {
112
+ const batchBuffers = new Map<string, BatchBuffer>();
113
+ const pendingChannels = new Set<string>();
114
+
115
+ function flushBatch(key: string) {
116
+ const buffer = batchBuffers.get(key);
117
+ if (!buffer || buffer.messages.length === 0) return;
118
+
119
+ // Clear both timers
120
+ if (buffer.debounceTimer) clearTimeout(buffer.debounceTimer);
121
+ if (buffer.maxWaitTimer) clearTimeout(buffer.maxWaitTimer);
122
+ buffer.debounceTimer = null;
123
+ buffer.maxWaitTimer = null;
124
+
125
+ const messages = buffer.messages.splice(0);
126
+
127
+ // Group by channel URI for header summary
128
+ const channelCounts = new Map<string, number>();
129
+ for (const msg of messages) {
130
+ const uri = msg.channel ?? "unknown";
131
+ channelCounts.set(uri, (channelCounts.get(uri) ?? 0) + 1);
132
+ }
133
+ const channelLabels = [...channelCounts.entries()].map(([uri, n]) => {
134
+ const msg = messages.find((m) => m.channel === uri);
135
+ const display = msg?.channelName
136
+ ? `#${msg.channelName}${msg.serverName ? ` in ${msg.serverName}` : ""} (${uri})`
137
+ : uri;
138
+ return `${n} from ${display}`;
139
+ });
140
+ const summary = channelLabels.join(", ");
141
+
142
+ const header = `[Batch: ${messages.length} message${messages.length === 1 ? "" : "s"} — ${summary}]`;
143
+ // Include channel URI per message when batch spans multiple channels
144
+ const multiChannel = channelCounts.size > 1;
145
+ const body = messages
146
+ .map((m) => {
147
+ const prefix =
148
+ multiChannel && m.channel
149
+ ? `[${m.sender ?? "unknown"} in ${m.channel} — ${m.timestamp}]`
150
+ : `[${m.sender ?? "unknown"} — ${m.timestamp}]`;
151
+ return `${prefix}\n${m.text}`;
152
+ })
153
+ .join("\n\n");
154
+
155
+ const content: VoluteContentPart[] = [{ type: "text", text: `${header}\n\n${body}` }];
156
+ const messageId = generateMessageId();
157
+ const handler = options.agentHandler(buffer.sessionName);
158
+
159
+ // Batch flushes are fire-and-forget — no HTTP response is waiting, so listener is a noop
160
+ try {
161
+ handler.handle(content, { sessionName: buffer.sessionName, messageId }, () => {});
162
+ } catch (err) {
163
+ log("router", `error flushing batch for session ${buffer.sessionName}:`, err);
164
+ return;
165
+ }
166
+ log("router", `flushed batch for session ${buffer.sessionName}: ${messages.length} messages`);
167
+ }
168
+
169
+ function scheduleBatchTimers(key: string) {
170
+ const buffer = batchBuffers.get(key);
171
+ if (!buffer) return;
172
+ const { config } = buffer;
173
+
174
+ // Reset debounce timer
175
+ if (buffer.debounceTimer) clearTimeout(buffer.debounceTimer);
176
+ if (config.debounce != null) {
177
+ buffer.debounceTimer = setTimeout(() => flushBatch(key), config.debounce * 1000);
178
+ buffer.debounceTimer.unref();
179
+ }
180
+
181
+ // Start maxWait timer if not already running
182
+ if (!buffer.maxWaitTimer && config.maxWait != null) {
183
+ buffer.maxWaitTimer = setTimeout(() => flushBatch(key), config.maxWait * 1000);
184
+ buffer.maxWaitTimer.unref();
185
+ }
186
+
187
+ // If neither timer is configured, flush immediately (shouldn't happen in practice)
188
+ if (config.debounce == null && config.maxWait == null) {
189
+ flushBatch(key);
190
+ }
191
+ }
192
+
193
+ function route(
194
+ content: VoluteContentPart[],
195
+ meta: ChannelMeta,
196
+ listener?: Listener,
197
+ ): { messageId: string; unsubscribe: () => void } {
198
+ // Log incoming message
199
+ const text = content
200
+ .filter((p): p is { type: "text"; text: string } => p.type === "text")
201
+ .map((p) => p.text)
202
+ .join(" ");
203
+ logMessage("in", text, meta.channel);
204
+
205
+ // Resolve route from config (re-read on each request for hot-reload)
206
+ const config = options.configPath ? loadRoutingConfig(options.configPath) : {};
207
+ const resolved = resolveRoute(config, {
208
+ channel: meta.channel,
209
+ sender: meta.sender,
210
+ isDM: meta.isDM,
211
+ participantCount: meta.participantCount,
212
+ });
213
+
214
+ const messageId = generateMessageId();
215
+ const noop = () => {};
216
+ const safeListener = listener ?? noop;
217
+
218
+ // Gate unmatched channels (default: gate unless explicitly disabled)
219
+ if (!resolved.matched && config.gateUnmatched !== false) {
220
+ const channelKey = meta.channel ?? "unknown";
221
+ const sanitized = sanitizeChannelPath(channelKey);
222
+ const filePath = `inbox/${sanitized}.md`;
223
+
224
+ // Save message to file
225
+ if (options.fileHandler) {
226
+ const formatted = applyPrefix(content, meta);
227
+ const fileHandler = options.fileHandler(filePath);
228
+ fileHandler.handle(formatted, { ...meta, messageId }, noop);
229
+ }
230
+
231
+ // First message from this channel — send invite notification
232
+ if (!pendingChannels.has(channelKey)) {
233
+ pendingChannels.add(channelKey);
234
+ const notification = formatInviteNotification(meta, filePath, text);
235
+ const notifContent: VoluteContentPart[] = [{ type: "text", text: notification }];
236
+ const handler = options.agentHandler("main");
237
+ handler.handle(
238
+ notifContent,
239
+ { sessionName: "main", messageId: generateMessageId(), interrupt: true },
240
+ noop,
241
+ );
242
+ }
243
+
244
+ queueMicrotask(() => safeListener({ type: "done", messageId }));
245
+ return { messageId, unsubscribe: noop };
246
+ }
247
+
248
+ // File destination
249
+ if (resolved.destination === "file") {
250
+ if (options.fileHandler) {
251
+ const formatted = applyPrefix(content, meta);
252
+ const handler = options.fileHandler(resolved.path);
253
+ const unsubscribe = handler.handle(formatted, { ...meta, messageId }, safeListener);
254
+ return { messageId, unsubscribe };
255
+ }
256
+ // No file handler configured — emit done and discard
257
+ log("router", `no file handler configured — discarding file-destined message`);
258
+ queueMicrotask(() => safeListener({ type: "done", messageId }));
259
+ return { messageId, unsubscribe: noop };
260
+ }
261
+
262
+ // Agent destination
263
+ let sessionName = resolved.session;
264
+ if (sessionName === "$new") {
265
+ sessionName = `new-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
266
+ }
267
+
268
+ // Batch mode: buffer the message and return immediate done
269
+ if (resolved.batch != null) {
270
+ const batchKey = `batch:${sessionName}`;
271
+ const batchConfig = resolved.batch;
272
+
273
+ if (!batchBuffers.has(batchKey)) {
274
+ batchBuffers.set(batchKey, {
275
+ messages: [],
276
+ debounceTimer: null,
277
+ maxWaitTimer: null,
278
+ sessionName,
279
+ config: batchConfig,
280
+ });
281
+ }
282
+
283
+ batchBuffers.get(batchKey)!.messages.push({
284
+ text,
285
+ sender: meta.sender,
286
+ channel: meta.channel,
287
+ channelName: meta.channelName,
288
+ serverName: meta.serverName,
289
+ timestamp: new Date().toLocaleTimeString("en-US", {
290
+ hour: "numeric",
291
+ minute: "2-digit",
292
+ }),
293
+ });
294
+
295
+ // Check triggers — flush immediately if matched
296
+ if (batchConfig.triggers?.length && matchesTrigger(text, batchConfig.triggers)) {
297
+ flushBatch(batchKey);
298
+ } else {
299
+ scheduleBatchTimers(batchKey);
300
+ }
301
+
302
+ queueMicrotask(() => safeListener({ type: "done", messageId }));
303
+ return { messageId, unsubscribe: noop };
304
+ }
305
+
306
+ // Direct dispatch to agent
307
+ const formatted = applyPrefix(content, { ...meta, sessionName });
308
+ const handler = options.agentHandler(sessionName);
309
+ const unsubscribe = handler.handle(
310
+ formatted,
311
+ { ...meta, sessionName, messageId, interrupt: resolved.interrupt },
312
+ safeListener,
313
+ );
314
+ return { messageId, unsubscribe };
315
+ }
316
+
317
+ function close() {
318
+ for (const [key, buffer] of batchBuffers) {
319
+ if (buffer.debounceTimer) clearTimeout(buffer.debounceTimer);
320
+ if (buffer.maxWaitTimer) clearTimeout(buffer.maxWaitTimer);
321
+ flushBatch(key);
322
+ }
323
+ batchBuffers.clear();
324
+ }
325
+
326
+ return { route, close };
327
+ }