volute 0.4.0 → 0.6.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 +22 -22
  2. package/dist/agent-X7GJLBLW.js +79 -0
  3. package/dist/{agent-manager-AUCKMGPR.js → agent-manager-JDVXU3ON.js} +4 -4
  4. package/dist/channel-SMCNOIVQ.js +262 -0
  5. package/dist/chunk-AOKAQGO4.js +107 -0
  6. package/dist/{chunk-VRVVQIYY.js → chunk-AZEL2IEK.js} +1 -1
  7. package/dist/chunk-B3R6L2GW.js +24 -0
  8. package/dist/{chunk-MXUCNIBG.js → chunk-BX7KI4S3.js} +68 -3
  9. package/dist/{chunk-I6OHXCMV.js → chunk-G6ZNGLUX.js} +47 -9
  10. package/dist/{chunk-DNOXHLE5.js → chunk-H7AMDUIA.js} +1 -1
  11. package/dist/{chunk-YGFIWIOF.js → chunk-JR4UXCTO.js} +1 -1
  12. package/dist/{chunk-3C2XR4IY.js → chunk-UWHWAPGO.js} +120 -107
  13. package/dist/{chunk-SOZA2TLP.js → chunk-W76KWE23.js} +1 -1
  14. package/dist/{chunk-GSPKUPKU.js → chunk-XUA3JUFK.js} +2 -1
  15. package/dist/chunk-ZYGKG6VC.js +22 -0
  16. package/dist/chunk-ZZOOTYXK.js +583 -0
  17. package/dist/cli.js +83 -74
  18. package/dist/{connector-DKDJTLYZ.js → connector-Y7JPNROO.js} +11 -6
  19. package/dist/connectors/discord.js +34 -5
  20. package/dist/connectors/slack.js +36 -8
  21. package/dist/connectors/telegram.js +55 -6
  22. package/dist/create-G525LWEA.js +91 -0
  23. package/dist/{daemon-client-XR24PUJF.js → daemon-client-442IV43D.js} +2 -2
  24. package/dist/daemon.js +1273 -384
  25. package/dist/{delete-55MXCEY5.js → delete-2PH2CGDY.js} +7 -8
  26. package/dist/{down-3OB6UVAJ.js → down-FXWAN66A.js} +1 -1
  27. package/dist/{env-JB27UAC3.js → env-7GLUJCWS.js} +8 -5
  28. package/dist/{history-BKG74I43.js → history-H72ZUIBN.js} +3 -3
  29. package/dist/{import-4CI2ZUTJ.js → import-AVKQJDYC.js} +8 -8
  30. package/dist/{logs-NXFFGUKY.js → logs-EDGK26AK.js} +2 -2
  31. package/dist/message-SCOQDR3P.js +32 -0
  32. package/dist/{package-Z2SFO2SV.js → package-4DP4Y4UO.js} +1 -1
  33. package/dist/restart-O4ETYLJF.js +29 -0
  34. package/dist/{schedule-A35SH4HT.js → schedule-S6QVC5ON.js} +10 -5
  35. package/dist/send-G7PE4DOJ.js +72 -0
  36. package/dist/{setup-2FDVN7OF.js → setup-F4TCWVSP.js} +5 -5
  37. package/dist/{start-LDPMCMYT.js → start-VHQ7LNWM.js} +3 -3
  38. package/dist/{status-MVSQG54T.js → status-QAJWXKMZ.js} +3 -3
  39. package/dist/{stop-5PZTZCLL.js → stop-CAGCT5NI.js} +6 -7
  40. package/dist/{up-F7TMTLRE.js → up-CSX3ZUIU.js} +16 -4
  41. package/dist/update-XSIX3GGP.js +140 -0
  42. package/dist/update-check-5ZADDHCK.js +17 -0
  43. package/dist/{upgrade-6ZW2RD64.js → upgrade-YXKPWDRU.js} +16 -15
  44. package/dist/{variant-T64BKARF.js → variant-4Z6W3PP6.js} +15 -10
  45. package/dist/web-assets/assets/index-D5PzIndO.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 +1 -1
  51. package/templates/_base/.init/.config/hooks/startup-context.sh +19 -1
  52. package/templates/_base/.init/.config/scripts/session-reader.ts +59 -0
  53. package/templates/_base/_skills/sessions/SKILL.md +49 -0
  54. package/templates/_base/_skills/volute-agent/SKILL.md +114 -14
  55. package/templates/_base/home/.config/routes.json +10 -0
  56. package/templates/_base/home/VOLUTE.md +14 -35
  57. package/templates/_base/src/lib/format-prefix.ts +7 -1
  58. package/templates/_base/src/lib/router.ts +193 -19
  59. package/templates/_base/src/lib/routing.ts +55 -18
  60. package/templates/_base/src/lib/session-monitor.ts +400 -0
  61. package/templates/_base/src/lib/types.ts +5 -1
  62. package/templates/agent-sdk/.init/.config/routes.json +5 -0
  63. package/templates/agent-sdk/.init/CLAUDE.md +2 -2
  64. package/templates/agent-sdk/src/agent.ts +18 -1
  65. package/templates/agent-sdk/src/lib/hooks/session-context.ts +32 -0
  66. package/templates/agent-sdk/src/server.ts +8 -2
  67. package/templates/agent-sdk/volute-template.json +1 -1
  68. package/templates/pi/.init/.config/routes.json +5 -0
  69. package/templates/pi/.init/AGENTS.md +1 -1
  70. package/templates/pi/src/agent.ts +12 -4
  71. package/templates/pi/src/lib/session-context-extension.ts +33 -0
  72. package/templates/pi/src/server.ts +1 -1
  73. package/templates/pi/volute-template.json +1 -1
  74. package/dist/channel-DQ6UY7QB.js +0 -67
  75. package/dist/chunk-5OCWMTVS.js +0 -152
  76. package/dist/chunk-ZHCE4DPY.js +0 -110
  77. package/dist/create-ILVOG75A.js +0 -79
  78. package/dist/send-3U6OTKG7.js +0 -57
  79. package/dist/web-assets/assets/index-NS621maO.js +0 -296
  80. package/templates/agent-sdk/.init/.config/sessions.json +0 -4
  81. package/templates/pi/.init/.config/sessions.json +0 -1
  82. package/dist/{service-SA4TTMDU.js → service-HZNIDNJF.js} +3 -3
@@ -1,29 +1,34 @@
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 |
24
+ | `volute agent upgrade [--template <name>] [--continue]` | Upgrade your server code |
23
25
  | `volute connector connect <type>` | Enable a connector (discord, slack, etc.) |
24
26
  | `volute connector disconnect <type>` | Disable a connector |
25
27
  | `volute channel read <platform>:<id> [--limit N]` | Read channel history |
26
- | `volute channel send <platform>:<id> "msg"` | Send a message proactively |
28
+ | `volute channel send <platform>:<id> "msg"` | Send a message proactively (or pipe via stdin) |
29
+ | `volute channel list [<platform>]` | List conversations on a platform (or all platforms) |
30
+ | `volute channel users <platform>` | List users/contacts on a platform |
31
+ | `volute channel create <platform> --participants u1,u2 [--name "..."]` | Create a conversation on a platform |
27
32
  | `volute schedule add --cron "..." --message "..."` | Schedule a recurring message to yourself |
28
33
  | `volute schedule list` | List your schedules |
29
34
  | `volute schedule remove --id <id>` | Remove a schedule |
@@ -37,14 +42,27 @@ 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
+ ```
53
+
54
+ 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.
55
+
40
56
  ## Agent-to-Agent Messaging
41
57
 
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:
58
+ When you use `volute message send`, your agent name is automatically used as the sender. Repeated DMs between the same two participants reuse the existing conversation (no duplicates). The receiving agent can route agent messages to a specific session via their session routing config:
43
59
 
44
60
  ```json
45
61
  { "channel": "agent", "sender": "your-name", "session": "your-name" }
46
62
  ```
47
63
 
64
+ For group conversations, use `volute channel create volute --participants agent-b,agent-c --name "Planning"` and then send messages with `volute channel send volute:<id> "msg"`.
65
+
48
66
  ## Configuration
49
67
 
50
68
  Your `.config/volute.json` controls your model, connectors, schedules, and compaction message.
@@ -59,7 +77,7 @@ Variants let you experiment safely — fork yourself, try changes, and merge bac
59
77
 
60
78
  1. `volute variant create experiment` — creates an isolated copy with its own server
61
79
  2. Make changes in the variant's worktree (at `../.variants/experiment/`)
62
- 3. Test: `volute send $VOLUTE_AGENT@experiment "hello"`
80
+ 3. Test: `volute message send $VOLUTE_AGENT@experiment "hello"`
63
81
  4. `volute variant merge experiment --summary "..." --memory "..."` — merges back after verification
64
82
 
65
83
  You can also fork with a different personality to explore a different version of yourself:
@@ -71,11 +89,11 @@ After a merge, you receive orientation context about what changed. Update your m
71
89
 
72
90
  ## Upgrade Workflow
73
91
 
74
- `volute upgrade` merges the latest template code into a testable variant:
92
+ `volute agent upgrade` merges the latest template code into a testable variant:
75
93
 
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"`
94
+ 1. `volute agent upgrade` — creates an `upgrade` variant
95
+ 2. Resolve any merge conflicts if prompted, then `volute agent upgrade --continue`
96
+ 3. Test: `volute message send $VOLUTE_AGENT@upgrade "hello"`
79
97
  4. `volute variant merge upgrade` — merge back
80
98
 
81
99
  ## Custom Skills
@@ -86,6 +104,88 @@ Create skills by writing `.claude/skills/<name>/SKILL.md` files in your `home/`
86
104
 
87
105
  Edit `home/.mcp.json` to configure MCP servers for your SDK session. This gives you access to additional tools and services.
88
106
 
107
+ ## Message Routing
108
+
109
+ 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"`).
110
+
111
+ ### Rule syntax
112
+
113
+ ```json
114
+ {
115
+ "rules": [
116
+ { "channel": "discord:*", "session": "discord", "batch": { "debounce": 20, "maxWait": 120, "triggers": ["@myagent"] } },
117
+ { "channel": "volute:*", "isDM": true, "session": "${sender}" },
118
+ { "channel": "volute:*", "isDM": false, "session": "${channel}", "batch": { "debounce": 20, "maxWait": 120 } },
119
+ { "sender": "alice", "session": "alice" },
120
+ { "channel": "system:*", "session": "$new" },
121
+ { "channel": "discord:logs", "destination": "file", "path": "inbox/log.md" }
122
+ ],
123
+ "default": "main",
124
+ "gateUnmatched": true
125
+ }
126
+ ```
127
+
128
+ ### Match criteria
129
+
130
+ | Field | Type | Description |
131
+ |-------|------|-------------|
132
+ | `channel` | glob string | Channel URI (e.g. `discord:*`, `volute:conv-*`) |
133
+ | `sender` | glob string | Sender name |
134
+ | `isDM` | boolean | Match DMs (`true`) or group channels (`false`) |
135
+ | `participants` | number | Match exact participant count |
136
+
137
+ ### Rule fields
138
+
139
+ | Field | Description |
140
+ |-------|-------------|
141
+ | `session` | Target session name. Supports `${sender}`, `${channel}` templates, or `$new` for a unique session per message |
142
+ | `destination` | `"agent"` (default) or `"file"` |
143
+ | `path` | File path when destination is `"file"` |
144
+ | `batch` | Batch config (see below) |
145
+ | `interrupt` | Whether to interrupt an in-progress turn (default: `true`) |
146
+
147
+ ### Batch config
148
+
149
+ Batch mode buffers messages and delivers them together. Configure with an object:
150
+
151
+ | Field | Type | Description |
152
+ |-------|------|-------------|
153
+ | `debounce` | seconds | Wait for quiet period before flushing — resets on each new message |
154
+ | `maxWait` | seconds | Maximum time before forced flush, even during continuous activity |
155
+ | `triggers` | string[] | Patterns that cause immediate flush (case-insensitive substring match) |
156
+
157
+ Examples:
158
+ - `{ "debounce": 20, "maxWait": 120 }` — flush after 20s of quiet, or 2 minutes max
159
+ - `{ "debounce": 20, "maxWait": 120, "triggers": ["@myagent"] }` — same, but flush immediately on @mention
160
+ - `{ "triggers": ["urgent"] }` — no timer, flush only on trigger (or immediately if no timers)
161
+
162
+ 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.
163
+
164
+ ## Channel Gating
165
+
166
+ When `gateUnmatched` is `true` (the default), messages from channels without a matching rule are held:
167
+
168
+ 1. First message from an unknown channel triggers a **[Channel Invite]** notification in your main session
169
+ 2. The notification includes channel details, a message preview, and a suggested routing rule
170
+ 3. Further messages are saved to `inbox/<channel>.md`
171
+ 4. To accept: add a routing rule to `.config/routes.json`
172
+ 5. To reject: delete the inbox file
173
+ 6. Set `gateUnmatched: false` to route all unmatched messages to the default session
174
+
175
+ ## Channel Commands
176
+
177
+ Channels are the universal interface for reading, sending, listing, and creating conversations across all platforms:
178
+
179
+ ```sh
180
+ volute channel read <uri> [--limit N] # Read recent messages
181
+ volute channel send <uri> "message" # Send a message
182
+ volute channel list [<platform>] # List conversations
183
+ volute channel users <platform> # List users/contacts
184
+ volute channel create <platform> --participants u1,u2 [--name ""] # Create a conversation
185
+ ```
186
+
187
+ Channel URIs use `platform:id` format (e.g. `discord:123456`, `volute:conv-abc`, `slack:C01234`). Supported platforms: `volute`, `discord`, `slack`, `telegram`.
188
+
89
189
  ## Git Introspection
90
190
 
91
191
  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,53 +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
- | CLI | Yes | Direct terminal via `volute send` |
11
- | Agent | Yes | Messages from other agents |
9
+ | Volute | Yes | Web UI, CLI, agent-to-agent |
12
10
  | System | No | Automated messages (schedules, upgrades) |
13
11
 
14
12
  Connector channels (Discord, Slack, etc.) show text responses only — no tool calls.
15
13
 
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.
14
+ ## Responding to Messages
17
15
 
18
- To reach out on your own initiative, use `volute channel send <uri> "message"`. See the **volute-agent** skill for details.
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
- ## Session Routing
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
- By default, all messages share a single conversation session. You can route messages to different sessions — or to files — by editing `.config/sessions.json`.
20
+ To reach out on your own initiative, use `volute channel send <uri> "message"`.
23
21
 
24
- ```json
25
- {
26
- "rules": [
27
- { "sender": "alice", "session": "alice" },
28
- { "channel": "discord:*", "session": "discord-${sender}" },
29
- { "channel": "discord:logs", "destination": "file", "path": "memory/discord-logs.md" },
30
- { "channel": "system:scheduler", "sender": "daily-report", "session": "daily-report" },
31
- { "channel": "system:scheduler", "sender": "cleanup", "session": "$new" }
32
- ],
33
- "default": "main"
34
- }
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>
35
25
  ```
36
26
 
37
- - Rules are evaluated top-to-bottom, first match wins
38
- - `channel` and `sender` are match criteria (AND'd together); `*` glob patterns work
39
- - `${sender}` and `${channel}` expand in session/path names
40
- - `$new` creates a fresh session every time
41
- - Scheduler messages use the schedule id as `sender`
27
+ ## Sessions
42
28
 
43
- ### Destinations
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".
44
30
 
45
- - **agent** (default) — routes to a conversation session
46
- - **file** — appends the message to a file (requires `path`); useful for logging channels to disk
31
+ ## Channel Gating
47
32
 
48
- ### Options
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.
49
34
 
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.
35
+ ## Reference
52
36
 
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".
54
-
55
- ## Skills
56
-
57
- - Use the **volute-agent** skill for CLI commands, variants, upgrades, and self-management.
58
- - 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.
@@ -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
@@ -22,3 +22,9 @@ export function formatPrefix(meta: ChannelMeta | undefined, time: string): strin
22
22
  meta.sessionName && meta.sessionName !== "main" ? ` — session: ${meta.sessionName}` : "";
23
23
  return parts.length > 0 ? `[${parts.join(": ")}${sessionPart} — ${time}]\n` : "";
24
24
  }
25
+
26
+ export function formatTypingSuffix(typing: string[] | undefined): string {
27
+ if (!typing || typing.length === 0) return "";
28
+ if (typing.length === 1) return `\n[${typing[0]} is typing]`;
29
+ return `\n[${typing.join(", ")} are typing]`;
30
+ }
@@ -1,6 +1,6 @@
1
- import { formatPrefix } from "./format-prefix.js";
1
+ import { formatPrefix, formatTypingSuffix } from "./format-prefix.js";
2
2
  import { log, logMessage } from "./logger.js";
3
- import { loadRoutingConfig, resolveRoute } from "./routing.js";
3
+ import { type BatchConfig, loadRoutingConfig, resolveRoute } from "./routing.js";
4
4
  import type { ChannelMeta, HandlerResolver, Listener, VoluteContentPart } from "./types.js";
5
5
 
6
6
  export type Router = {
@@ -17,14 +17,17 @@ type BufferedMessage = {
17
17
  sender?: string;
18
18
  channel?: string;
19
19
  channelName?: string;
20
- guildName?: string;
20
+ serverName?: string;
21
21
  timestamp: string;
22
+ typing?: string[];
22
23
  };
23
24
 
24
25
  type BatchBuffer = {
25
26
  messages: BufferedMessage[];
26
- timer: ReturnType<typeof setInterval>;
27
+ debounceTimer: ReturnType<typeof setTimeout> | null;
28
+ maxWaitTimer: ReturnType<typeof setTimeout> | null;
27
29
  sessionName: string;
30
+ config: BatchConfig;
28
31
  };
29
32
 
30
33
  function generateMessageId(): string {
@@ -49,35 +52,132 @@ function applyPrefix(content: VoluteContentPart[], meta: ChannelMeta): VoluteCon
49
52
  });
50
53
  }
51
54
 
55
+ function appendTypingSuffix(
56
+ content: VoluteContentPart[],
57
+ typing: string[] | undefined,
58
+ ): VoluteContentPart[] {
59
+ const suffix = formatTypingSuffix(typing);
60
+ if (!suffix) return content;
61
+ let lastTextIdx = -1;
62
+ for (let i = content.length - 1; i >= 0; i--) {
63
+ if (content[i].type === "text") {
64
+ lastTextIdx = i;
65
+ break;
66
+ }
67
+ }
68
+ if (lastTextIdx === -1) return [...content, { type: "text", text: suffix.trimStart() }];
69
+ return content.map((part, i) => {
70
+ if (i === lastTextIdx) return { type: "text", text: (part as { text: string }).text + suffix };
71
+ return part;
72
+ });
73
+ }
74
+
75
+ function sanitizeChannelPath(channel: string): string {
76
+ return channel
77
+ .replace(/[/\\:]/g, "-")
78
+ .replace(/\.\./g, "-")
79
+ .replace(/\0/g, "")
80
+ .slice(0, 100);
81
+ }
82
+
83
+ /** Check if message text matches any trigger patterns (case-insensitive substring match). */
84
+ function matchesTrigger(text: string, triggers: string[]): boolean {
85
+ const lower = text.toLowerCase();
86
+ return triggers.some((t) => lower.includes(t.toLowerCase()));
87
+ }
88
+
89
+ function formatInviteNotification(
90
+ meta: ChannelMeta,
91
+ filePath: string,
92
+ messageText: string,
93
+ ): string {
94
+ const time = new Date().toLocaleString();
95
+ const lines = ["[Channel Invite]"];
96
+ if (meta.channel) lines.push(`Channel: ${meta.channel}`);
97
+ if (meta.sender) lines.push(`Sender: ${meta.sender}`);
98
+ if (meta.platform) lines.push(`Platform: ${meta.platform}`);
99
+ if (meta.serverName) lines.push(`Server: ${meta.serverName}`);
100
+ if (meta.channelName) lines.push(`Channel name: ${meta.channelName}`);
101
+ if (meta.participants && meta.participants.length > 0)
102
+ lines.push(`Participants: ${meta.participants.join(", ")}`);
103
+ lines.push("");
104
+ const preview = messageText.length > 200 ? `${messageText.slice(0, 200)}...` : messageText;
105
+ lines.push(`[${meta.sender ?? "unknown"} — ${time}]`);
106
+ lines.push(preview);
107
+ lines.push("");
108
+ lines.push(`Further messages will be saved to ${filePath}`);
109
+ lines.push("");
110
+ lines.push("To accept, add a routing rule to .config/routes.json:");
111
+ const suggestedSession = sanitizeChannelPath(meta.channel ?? "unknown");
112
+ const otherCount = (meta.participantCount ?? 1) - 1;
113
+ if (otherCount > 1) {
114
+ lines.push(
115
+ ` { "channel": "${meta.channel}", "session": "${suggestedSession}", "batch": { "debounce": 20, "maxWait": 120 } }`,
116
+ );
117
+ lines.push(
118
+ `(batch recommended — ${otherCount} other participants may generate frequent messages)`,
119
+ );
120
+ } else {
121
+ lines.push(` { "channel": "${meta.channel}", "session": "${suggestedSession}" }`);
122
+ }
123
+ lines.push(`To respond, use: volute channel send ${meta.channel ?? "unknown"} "your message"`);
124
+ lines.push(`To reject, delete ${filePath}`);
125
+ return lines.join("\n");
126
+ }
127
+
52
128
  export function createRouter(options: {
53
129
  configPath?: string;
54
130
  agentHandler: HandlerResolver;
55
131
  fileHandler?: HandlerResolver;
56
132
  }): Router {
57
133
  const batchBuffers = new Map<string, BatchBuffer>();
134
+ const pendingChannels = new Set<string>();
58
135
 
59
136
  function flushBatch(key: string) {
60
137
  const buffer = batchBuffers.get(key);
61
138
  if (!buffer || buffer.messages.length === 0) return;
62
139
 
140
+ // Clear both timers
141
+ if (buffer.debounceTimer) clearTimeout(buffer.debounceTimer);
142
+ if (buffer.maxWaitTimer) clearTimeout(buffer.maxWaitTimer);
143
+ buffer.debounceTimer = null;
144
+ buffer.maxWaitTimer = null;
145
+
63
146
  const messages = buffer.messages.splice(0);
64
147
 
65
- // Group by channel for header summary
148
+ // Group by channel URI for header summary
66
149
  const channelCounts = new Map<string, number>();
67
150
  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);
151
+ const uri = msg.channel ?? "unknown";
152
+ channelCounts.set(uri, (channelCounts.get(uri) ?? 0) + 1);
72
153
  }
73
- const summary = [...channelCounts.entries()].map(([ch, n]) => `${n} from ${ch}`).join(", ");
154
+ const channelLabels = [...channelCounts.entries()].map(([uri, n]) => {
155
+ const msg = messages.find((m) => m.channel === uri);
156
+ const display = msg?.channelName
157
+ ? `#${msg.channelName}${msg.serverName ? ` in ${msg.serverName}` : ""} (${uri})`
158
+ : uri;
159
+ return `${n} from ${display}`;
160
+ });
161
+ const summary = channelLabels.join(", ");
74
162
 
75
163
  const header = `[Batch: ${messages.length} message${messages.length === 1 ? "" : "s"} — ${summary}]`;
164
+ // Include channel URI per message when batch spans multiple channels
165
+ const multiChannel = channelCounts.size > 1;
76
166
  const body = messages
77
- .map((m) => `[${m.sender ?? "unknown"} — ${m.timestamp}]\n${m.text}`)
167
+ .map((m) => {
168
+ const prefix =
169
+ multiChannel && m.channel
170
+ ? `[${m.sender ?? "unknown"} in ${m.channel} — ${m.timestamp}]`
171
+ : `[${m.sender ?? "unknown"} — ${m.timestamp}]`;
172
+ return `${prefix}\n${m.text}`;
173
+ })
78
174
  .join("\n\n");
79
175
 
80
- const content: VoluteContentPart[] = [{ type: "text", text: `${header}\n\n${body}` }];
176
+ const lastTyping = messages[messages.length - 1]?.typing;
177
+ const typingSuffix = formatTypingSuffix(lastTyping);
178
+ const content: VoluteContentPart[] = [
179
+ { type: "text", text: `${header}\n\n${body}${typingSuffix}` },
180
+ ];
81
181
  const messageId = generateMessageId();
82
182
  const handler = options.agentHandler(buffer.sessionName);
83
183
 
@@ -91,6 +191,30 @@ export function createRouter(options: {
91
191
  log("router", `flushed batch for session ${buffer.sessionName}: ${messages.length} messages`);
92
192
  }
93
193
 
194
+ function scheduleBatchTimers(key: string) {
195
+ const buffer = batchBuffers.get(key);
196
+ if (!buffer) return;
197
+ const { config } = buffer;
198
+
199
+ // Reset debounce timer
200
+ if (buffer.debounceTimer) clearTimeout(buffer.debounceTimer);
201
+ if (config.debounce != null) {
202
+ buffer.debounceTimer = setTimeout(() => flushBatch(key), config.debounce * 1000);
203
+ buffer.debounceTimer.unref();
204
+ }
205
+
206
+ // Start maxWait timer if not already running
207
+ if (!buffer.maxWaitTimer && config.maxWait != null) {
208
+ buffer.maxWaitTimer = setTimeout(() => flushBatch(key), config.maxWait * 1000);
209
+ buffer.maxWaitTimer.unref();
210
+ }
211
+
212
+ // If neither timer is configured, flush immediately (shouldn't happen in practice)
213
+ if (config.debounce == null && config.maxWait == null) {
214
+ flushBatch(key);
215
+ }
216
+ }
217
+
94
218
  function route(
95
219
  content: VoluteContentPart[],
96
220
  meta: ChannelMeta,
@@ -105,12 +229,47 @@ export function createRouter(options: {
105
229
 
106
230
  // Resolve route from config (re-read on each request for hot-reload)
107
231
  const config = options.configPath ? loadRoutingConfig(options.configPath) : {};
108
- const resolved = resolveRoute(config, { channel: meta.channel, sender: meta.sender });
232
+ const resolved = resolveRoute(config, {
233
+ channel: meta.channel,
234
+ sender: meta.sender,
235
+ isDM: meta.isDM,
236
+ participantCount: meta.participantCount,
237
+ });
109
238
 
110
239
  const messageId = generateMessageId();
111
240
  const noop = () => {};
112
241
  const safeListener = listener ?? noop;
113
242
 
243
+ // Gate unmatched channels (default: gate unless explicitly disabled)
244
+ if (!resolved.matched && config.gateUnmatched !== false) {
245
+ const channelKey = meta.channel ?? "unknown";
246
+ const sanitized = sanitizeChannelPath(channelKey);
247
+ const filePath = `inbox/${sanitized}.md`;
248
+
249
+ // Save message to file
250
+ if (options.fileHandler) {
251
+ const formatted = applyPrefix(content, meta);
252
+ const fileHandler = options.fileHandler(filePath);
253
+ fileHandler.handle(formatted, { ...meta, messageId }, noop);
254
+ }
255
+
256
+ // First message from this channel — send invite notification
257
+ if (!pendingChannels.has(channelKey)) {
258
+ pendingChannels.add(channelKey);
259
+ const notification = formatInviteNotification(meta, filePath, text);
260
+ const notifContent: VoluteContentPart[] = [{ type: "text", text: notification }];
261
+ const handler = options.agentHandler("main");
262
+ handler.handle(
263
+ notifContent,
264
+ { sessionName: "main", messageId: generateMessageId(), interrupt: true },
265
+ noop,
266
+ );
267
+ }
268
+
269
+ queueMicrotask(() => safeListener({ type: "done", messageId }));
270
+ return { messageId, unsubscribe: noop };
271
+ }
272
+
114
273
  // File destination
115
274
  if (resolved.destination === "file") {
116
275
  if (options.fileHandler) {
@@ -134,11 +293,16 @@ export function createRouter(options: {
134
293
  // Batch mode: buffer the message and return immediate done
135
294
  if (resolved.batch != null) {
136
295
  const batchKey = `batch:${sessionName}`;
296
+ const batchConfig = resolved.batch;
137
297
 
138
298
  if (!batchBuffers.has(batchKey)) {
139
- const timer = setInterval(() => flushBatch(batchKey), resolved.batch * 60 * 1000);
140
- timer.unref();
141
- batchBuffers.set(batchKey, { messages: [], timer, sessionName });
299
+ batchBuffers.set(batchKey, {
300
+ messages: [],
301
+ debounceTimer: null,
302
+ maxWaitTimer: null,
303
+ sessionName,
304
+ config: batchConfig,
305
+ });
142
306
  }
143
307
 
144
308
  batchBuffers.get(batchKey)!.messages.push({
@@ -146,22 +310,31 @@ export function createRouter(options: {
146
310
  sender: meta.sender,
147
311
  channel: meta.channel,
148
312
  channelName: meta.channelName,
149
- guildName: meta.guildName,
313
+ serverName: meta.serverName,
150
314
  timestamp: new Date().toLocaleTimeString("en-US", {
151
315
  hour: "numeric",
152
316
  minute: "2-digit",
153
317
  }),
318
+ typing: meta.typing,
154
319
  });
155
320
 
321
+ // Check triggers — flush immediately if matched
322
+ if (batchConfig.triggers?.length && matchesTrigger(text, batchConfig.triggers)) {
323
+ flushBatch(batchKey);
324
+ } else {
325
+ scheduleBatchTimers(batchKey);
326
+ }
327
+
156
328
  queueMicrotask(() => safeListener({ type: "done", messageId }));
157
329
  return { messageId, unsubscribe: noop };
158
330
  }
159
331
 
160
332
  // Direct dispatch to agent
161
333
  const formatted = applyPrefix(content, { ...meta, sessionName });
334
+ const withTyping = appendTypingSuffix(formatted, meta.typing);
162
335
  const handler = options.agentHandler(sessionName);
163
336
  const unsubscribe = handler.handle(
164
- formatted,
337
+ withTyping,
165
338
  { ...meta, sessionName, messageId, interrupt: resolved.interrupt },
166
339
  safeListener,
167
340
  );
@@ -170,7 +343,8 @@ export function createRouter(options: {
170
343
 
171
344
  function close() {
172
345
  for (const [key, buffer] of batchBuffers) {
173
- clearInterval(buffer.timer);
346
+ if (buffer.debounceTimer) clearTimeout(buffer.debounceTimer);
347
+ if (buffer.maxWaitTimer) clearTimeout(buffer.maxWaitTimer);
174
348
  flushBatch(key);
175
349
  }
176
350
  batchBuffers.clear();