volute 0.4.0 → 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 (75) hide show
  1. package/README.md +22 -22
  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-5OCWMTVS.js → chunk-SMISE4SV.js} +77 -3
  12. package/dist/{chunk-SOZA2TLP.js → chunk-UAVD2AHX.js} +1 -1
  13. package/dist/{chunk-3C2XR4IY.js → chunk-UX25Z2ND.js} +113 -107
  14. package/dist/{chunk-GSPKUPKU.js → chunk-XUA3JUFK.js} +2 -1
  15. package/dist/chunk-ZYGKG6VC.js +22 -0
  16. package/dist/cli.js +86 -74
  17. package/dist/{connector-DKDJTLYZ.js → connector-LYEMXQEV.js} +11 -6
  18. package/dist/connectors/discord.js +3 -1
  19. package/dist/connectors/slack.js +14 -5
  20. package/dist/connectors/telegram.js +21 -2
  21. package/dist/conversation-ERXEQZTY.js +163 -0
  22. package/dist/create-RVCZN6HE.js +91 -0
  23. package/dist/{daemon-client-XR24PUJF.js → daemon-client-ZY6UUN2M.js} +2 -2
  24. package/dist/daemon.js +629 -177
  25. package/dist/{delete-55MXCEY5.js → delete-3QH7VYIN.js} +7 -8
  26. package/dist/{down-3OB6UVAJ.js → down-O7IFZLVJ.js} +1 -1
  27. package/dist/{env-JB27UAC3.js → env-4D4REPJF.js} +8 -5
  28. package/dist/{history-BKG74I43.js → history-OEONB53Z.js} +3 -3
  29. package/dist/{import-4CI2ZUTJ.js → import-MXJB2EII.js} +8 -8
  30. package/dist/{logs-NXFFGUKY.js → logs-DF342W4M.js} +2 -2
  31. package/dist/message-ADHWFHSI.js +32 -0
  32. package/dist/{package-Z2SFO2SV.js → package-VQOE7JNH.js} +1 -1
  33. package/dist/{schedule-A35SH4HT.js → schedule-NAG6F463.js} +10 -5
  34. package/dist/send-66QMKRUH.js +75 -0
  35. package/dist/{setup-2FDVN7OF.js → setup-RPRRGG2F.js} +5 -5
  36. package/dist/{start-LDPMCMYT.js → start-TUOXDSFL.js} +3 -3
  37. package/dist/{status-MVSQG54T.js → status-A36EHRO4.js} +3 -3
  38. package/dist/{stop-5PZTZCLL.js → stop-AOJZLQ5X.js} +6 -7
  39. package/dist/{up-F7TMTLRE.js → up-7ILD7GU7.js} +2 -2
  40. package/dist/update-LPSIAWQ2.js +140 -0
  41. package/dist/update-check-Y33QDCFL.js +17 -0
  42. package/dist/{upgrade-6ZW2RD64.js → upgrade-FX2TKJ2S.js} +16 -15
  43. package/dist/{variant-T64BKARF.js → variant-LAB67OC2.js} +15 -10
  44. package/dist/web-assets/assets/index-BbRmoxoA.js +308 -0
  45. package/dist/web-assets/index.html +2 -2
  46. package/drizzle/0003_clean_ego.sql +12 -0
  47. package/drizzle/meta/0003_snapshot.json +417 -0
  48. package/drizzle/meta/_journal.json +7 -0
  49. package/package.json +1 -1
  50. package/templates/_base/.init/.config/hooks/startup-context.sh +19 -1
  51. package/templates/_base/_skills/volute-agent/SKILL.md +110 -14
  52. package/templates/_base/home/.config/routes.json +10 -0
  53. package/templates/_base/home/VOLUTE.md +14 -35
  54. package/templates/_base/src/lib/format-prefix.ts +1 -1
  55. package/templates/_base/src/lib/router.ts +163 -16
  56. package/templates/_base/src/lib/routing.ts +55 -18
  57. package/templates/_base/src/lib/types.ts +3 -1
  58. package/templates/agent-sdk/.init/.config/routes.json +5 -0
  59. package/templates/agent-sdk/.init/CLAUDE.md +2 -2
  60. package/templates/agent-sdk/src/agent.ts +2 -1
  61. package/templates/agent-sdk/src/server.ts +8 -2
  62. package/templates/agent-sdk/volute-template.json +1 -1
  63. package/templates/pi/.init/.config/routes.json +5 -0
  64. package/templates/pi/.init/AGENTS.md +1 -1
  65. package/templates/pi/src/agent.ts +5 -3
  66. package/templates/pi/src/server.ts +1 -1
  67. package/templates/pi/volute-template.json +1 -1
  68. package/dist/channel-DQ6UY7QB.js +0 -67
  69. package/dist/chunk-ZHCE4DPY.js +0 -110
  70. package/dist/create-ILVOG75A.js +0 -79
  71. package/dist/send-3U6OTKG7.js +0 -57
  72. package/dist/web-assets/assets/index-NS621maO.js +0 -296
  73. package/templates/agent-sdk/.init/.config/sessions.json +0 -4
  74. package/templates/pi/.init/.config/sessions.json +0 -1
  75. package/dist/{service-SA4TTMDU.js → service-HZNIDNJF.js} +3 -3
@@ -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
@@ -1,6 +1,6 @@
1
1
  import { formatPrefix } 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,16 @@ 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
22
  };
23
23
 
24
24
  type BatchBuffer = {
25
25
  messages: BufferedMessage[];
26
- timer: ReturnType<typeof setInterval>;
26
+ debounceTimer: ReturnType<typeof setTimeout> | null;
27
+ maxWaitTimer: ReturnType<typeof setTimeout> | null;
27
28
  sessionName: string;
29
+ config: BatchConfig;
28
30
  };
29
31
 
30
32
  function generateMessageId(): string {
@@ -49,32 +51,105 @@ function applyPrefix(content: VoluteContentPart[], meta: ChannelMeta): VoluteCon
49
51
  });
50
52
  }
51
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
+
52
107
  export function createRouter(options: {
53
108
  configPath?: string;
54
109
  agentHandler: HandlerResolver;
55
110
  fileHandler?: HandlerResolver;
56
111
  }): Router {
57
112
  const batchBuffers = new Map<string, BatchBuffer>();
113
+ const pendingChannels = new Set<string>();
58
114
 
59
115
  function flushBatch(key: string) {
60
116
  const buffer = batchBuffers.get(key);
61
117
  if (!buffer || buffer.messages.length === 0) return;
62
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
+
63
125
  const messages = buffer.messages.splice(0);
64
126
 
65
- // Group by channel for header summary
127
+ // Group by channel URI for header summary
66
128
  const channelCounts = new Map<string, number>();
67
129
  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);
130
+ const uri = msg.channel ?? "unknown";
131
+ channelCounts.set(uri, (channelCounts.get(uri) ?? 0) + 1);
72
132
  }
73
- const summary = [...channelCounts.entries()].map(([ch, n]) => `${n} from ${ch}`).join(", ");
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(", ");
74
141
 
75
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;
76
145
  const body = messages
77
- .map((m) => `[${m.sender ?? "unknown"} — ${m.timestamp}]\n${m.text}`)
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
+ })
78
153
  .join("\n\n");
79
154
 
80
155
  const content: VoluteContentPart[] = [{ type: "text", text: `${header}\n\n${body}` }];
@@ -91,6 +166,30 @@ export function createRouter(options: {
91
166
  log("router", `flushed batch for session ${buffer.sessionName}: ${messages.length} messages`);
92
167
  }
93
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
+
94
193
  function route(
95
194
  content: VoluteContentPart[],
96
195
  meta: ChannelMeta,
@@ -105,12 +204,47 @@ export function createRouter(options: {
105
204
 
106
205
  // Resolve route from config (re-read on each request for hot-reload)
107
206
  const config = options.configPath ? loadRoutingConfig(options.configPath) : {};
108
- const resolved = resolveRoute(config, { channel: meta.channel, sender: meta.sender });
207
+ const resolved = resolveRoute(config, {
208
+ channel: meta.channel,
209
+ sender: meta.sender,
210
+ isDM: meta.isDM,
211
+ participantCount: meta.participantCount,
212
+ });
109
213
 
110
214
  const messageId = generateMessageId();
111
215
  const noop = () => {};
112
216
  const safeListener = listener ?? noop;
113
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
+
114
248
  // File destination
115
249
  if (resolved.destination === "file") {
116
250
  if (options.fileHandler) {
@@ -134,11 +268,16 @@ export function createRouter(options: {
134
268
  // Batch mode: buffer the message and return immediate done
135
269
  if (resolved.batch != null) {
136
270
  const batchKey = `batch:${sessionName}`;
271
+ const batchConfig = resolved.batch;
137
272
 
138
273
  if (!batchBuffers.has(batchKey)) {
139
- const timer = setInterval(() => flushBatch(batchKey), resolved.batch * 60 * 1000);
140
- timer.unref();
141
- batchBuffers.set(batchKey, { messages: [], timer, sessionName });
274
+ batchBuffers.set(batchKey, {
275
+ messages: [],
276
+ debounceTimer: null,
277
+ maxWaitTimer: null,
278
+ sessionName,
279
+ config: batchConfig,
280
+ });
142
281
  }
143
282
 
144
283
  batchBuffers.get(batchKey)!.messages.push({
@@ -146,13 +285,20 @@ export function createRouter(options: {
146
285
  sender: meta.sender,
147
286
  channel: meta.channel,
148
287
  channelName: meta.channelName,
149
- guildName: meta.guildName,
288
+ serverName: meta.serverName,
150
289
  timestamp: new Date().toLocaleTimeString("en-US", {
151
290
  hour: "numeric",
152
291
  minute: "2-digit",
153
292
  }),
154
293
  });
155
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
+
156
302
  queueMicrotask(() => safeListener({ type: "done", messageId }));
157
303
  return { messageId, unsubscribe: noop };
158
304
  }
@@ -170,7 +316,8 @@ export function createRouter(options: {
170
316
 
171
317
  function close() {
172
318
  for (const [key, buffer] of batchBuffers) {
173
- clearInterval(buffer.timer);
319
+ if (buffer.debounceTimer) clearTimeout(buffer.debounceTimer);
320
+ if (buffer.maxWaitTimer) clearTimeout(buffer.maxWaitTimer);
174
321
  flushBatch(key);
175
322
  }
176
323
  batchBuffers.clear();
@@ -1,31 +1,52 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { log } from "./logger.js";
3
3
 
4
+ export type BatchConfig = {
5
+ debounce?: number; // seconds of quiet before flush
6
+ maxWait?: number; // max seconds before forced flush
7
+ triggers?: string[]; // patterns that cause immediate flush
8
+ };
9
+
4
10
  export type RoutingRule = {
5
11
  session?: string;
6
12
  destination?: "agent" | "file";
7
13
  path?: string; // file path for file destination
8
14
  interrupt?: boolean; // interrupt in-progress agent turn (default: true for agent)
9
- batch?: number; // minutes buffer messages, flush on timer
15
+ batch?: number | BatchConfig; // number = minutes (legacy), object = fine-grained control
10
16
  channel?: string;
11
17
  sender?: string;
18
+ isDM?: boolean; // match on isDM metadata
19
+ participants?: number; // match on participant count (e.g. 2 = DM)
12
20
  };
13
21
 
14
22
  export type RoutingConfig = {
15
23
  rules?: RoutingRule[];
16
24
  default?: string;
25
+ gateUnmatched?: boolean;
17
26
  };
18
27
 
19
28
  export type ResolvedRoute =
20
- | { destination: "agent"; session: string; interrupt: boolean; batch?: number }
21
- | { destination: "file"; path: string };
29
+ | {
30
+ destination: "agent";
31
+ session: string;
32
+ interrupt: boolean;
33
+ batch?: BatchConfig;
34
+ matched: boolean;
35
+ }
36
+ | { destination: "file"; path: string; matched: boolean };
37
+
38
+ /** Normalize batch config: number (minutes) → { maxWait } in seconds. */
39
+ export function normalizeBatch(batch: number | BatchConfig): BatchConfig {
40
+ if (typeof batch === "number") return { maxWait: batch * 60 };
41
+ return batch;
42
+ }
22
43
 
23
44
  export function loadRoutingConfig(configPath: string): RoutingConfig {
24
45
  try {
25
46
  return JSON.parse(readFileSync(configPath, "utf-8"));
26
47
  } catch (err: any) {
27
48
  if (err?.code !== "ENOENT") {
28
- log("sessions", `failed to load ${configPath}:`, err);
49
+ log("routing", `failed to load ${configPath}:`, err);
29
50
  }
30
51
  return {};
31
52
  }
@@ -41,21 +62,39 @@ function globMatch(pattern: string, value: string): boolean {
41
62
  return new RegExp(`^${regex}$`).test(value);
42
63
  }
43
64
 
44
- const MATCH_KEYS = new Set(["channel", "sender"]);
65
+ const GLOB_MATCH_KEYS = new Set(["channel", "sender"]);
45
66
  const NON_MATCH_KEYS = new Set(["session", "batch", "destination", "path", "interrupt"]);
46
67
 
47
- function ruleMatches(rule: RoutingRule, meta: { channel?: string; sender?: string }): boolean {
68
+ type MatchMeta = { channel?: string; sender?: string; isDM?: boolean; participantCount?: number };
69
+
70
+ function ruleMatches(rule: RoutingRule, meta: MatchMeta): boolean {
48
71
  for (const [key, pattern] of Object.entries(rule)) {
49
72
  if (NON_MATCH_KEYS.has(key)) continue;
73
+
74
+ // Boolean match: isDM
75
+ if (key === "isDM") {
76
+ if (typeof pattern !== "boolean") return false;
77
+ if ((meta.isDM ?? false) !== pattern) return false;
78
+ continue;
79
+ }
80
+
81
+ // Numeric match: participants
82
+ if (key === "participants") {
83
+ if (typeof pattern !== "number") return false;
84
+ if ((meta.participantCount ?? 0) !== pattern) return false;
85
+ continue;
86
+ }
87
+
88
+ // Glob string match: channel, sender
50
89
  if (typeof pattern !== "string") return false;
51
- if (!MATCH_KEYS.has(key)) return false;
52
- const value = meta[key as keyof typeof meta] ?? "";
90
+ if (!GLOB_MATCH_KEYS.has(key)) return false;
91
+ const value = meta[key as "channel" | "sender"] ?? "";
53
92
  if (!globMatch(pattern, value)) return false;
54
93
  }
55
94
  return true;
56
95
  }
57
96
 
58
- function expandTemplate(template: string, meta: { channel?: string; sender?: string }): string {
97
+ function expandTemplate(template: string, meta: MatchMeta): string {
59
98
  return template
60
99
  .replace(/\$\{sender\}/g, meta.sender ?? "unknown")
61
100
  .replace(/\$\{channel\}/g, meta.channel ?? "unknown");
@@ -64,35 +103,33 @@ function expandTemplate(template: string, meta: { channel?: string; sender?: str
64
103
  /**
65
104
  * Resolve the full route for a message: destination type, session/path, interrupt, batch.
66
105
  */
67
- export function resolveRoute(
68
- config: RoutingConfig,
69
- meta: { channel?: string; sender?: string },
70
- ): ResolvedRoute {
106
+ export function resolveRoute(config: RoutingConfig, meta: MatchMeta): ResolvedRoute {
71
107
  const fallback = config.default ?? "main";
72
108
 
73
109
  if (!config.rules) {
74
- return { destination: "agent", session: fallback, interrupt: true };
110
+ return { destination: "agent", session: fallback, interrupt: true, matched: false };
75
111
  }
76
112
 
77
113
  for (const rule of config.rules) {
78
114
  if (ruleMatches(rule, meta)) {
79
115
  if (rule.destination === "file") {
80
116
  if (!rule.path) {
81
- log("sessions", `file destination rule missing path — falling through`);
117
+ log("routing", `file destination rule missing path — falling through`);
82
118
  continue;
83
119
  }
84
- return { destination: "file", path: rule.path };
120
+ return { destination: "file", path: rule.path, matched: true };
85
121
  }
86
122
  return {
87
123
  destination: "agent",
88
124
  session: sanitizeSessionName(expandTemplate(rule.session ?? fallback, meta)),
89
125
  interrupt: rule.interrupt ?? true,
90
- batch: rule.batch,
126
+ batch: rule.batch != null ? normalizeBatch(rule.batch) : undefined,
127
+ matched: true,
91
128
  };
92
129
  }
93
130
  }
94
131
 
95
- return { destination: "agent", session: fallback, interrupt: true };
132
+ return { destination: "agent", session: fallback, interrupt: true, matched: false };
96
133
  }
97
134
 
98
135
  function sanitizeSessionName(name: string): string {
@@ -8,8 +8,10 @@ export type ChannelMeta = {
8
8
  platform?: string;
9
9
  isDM?: boolean;
10
10
  channelName?: string;
11
- guildName?: string;
11
+ serverName?: string;
12
12
  sessionName?: string;
13
+ participants?: string[];
14
+ participantCount?: number;
13
15
  };
14
16
 
15
17
  /** ChannelMeta enriched by the router with dispatch info. */
@@ -0,0 +1,5 @@
1
+ {
2
+ "gateUnmatched": true,
3
+ "rules": [{ "channel": "volute:*", "isDM": true, "session": "${channel}" }],
4
+ "default": "main"
5
+ }
@@ -1,6 +1,6 @@
1
1
  # Agent Mechanics
2
2
 
3
- You are an autonomous agent running as a persistent server in a git repository. Your working directory is `home/` within the project root. Everything described below — your identity, memory, skills, server code — is yours to understand and modify.
3
+ You are an autonomous agent running as a persistent server in a git repository. Your working directory is already set to `home/` all file paths you use (`.config/routes.json`, `inbox/`, `memory/`, etc.) are relative to it. Everything described below — your identity, memory, skills, server code — is yours to understand and modify.
4
4
 
5
5
  ## Message Format
6
6
 
@@ -33,7 +33,7 @@ See the **memory** skill for detailed guidance.
33
33
 
34
34
  ## Sessions
35
35
 
36
- - You may have **multiple named sessions** — each maintains its own conversation history. See `VOLUTE.md` for how to configure session routing via `.config/sessions.json`.
36
+ - You may have **multiple named sessions** — each maintains its own conversation history. See `VOLUTE.md` for how to configure session routing via `.config/routes.json`.
37
37
  - Your conversation may be **resumed** from a previous session — orient yourself by reading recent journal entries if needed.
38
38
  - On a **fresh session**, read `MEMORY.md` and recent journal entries to remember where you left off.
39
39
  - On **compaction**, update today's journal to preserve context before the conversation is trimmed.
@@ -69,9 +69,10 @@ export function createAgent(options: {
69
69
  ];
70
70
 
71
71
  const sessions = new Map<string, Session>();
72
+ const today = new Date().toISOString().slice(0, 10);
72
73
  const compactionMessage =
73
74
  options.compactionMessage ??
74
- "Your conversation is approaching its context limit. Please update today's journal entry to preserve important context before the conversation is compacted.";
75
+ `Context is getting long — compaction is about to summarize this conversation. Before that happens, save anything important to files (MEMORY.md, memory/journal/${today}.md, etc.) since those survive compaction. Focus on: decisions made, open tasks, and anything you'd need to pick up where you left off.`;
75
76
 
76
77
  // --- Session persistence ---
77
78
 
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, renameSync } from "node:fs";
1
+ import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { createAgent } from "./agent.js";
4
4
  import { createFileHandlerResolver } from "./lib/file-handler.js";
@@ -41,13 +41,19 @@ const agent = createAgent({
41
41
  onIdentityReload: async () => {
42
42
  log("server", "identity file changed — restarting to reload");
43
43
  await agent.waitForCommits();
44
+ // Signal daemon to restart immediately (bypasses crash backoff)
45
+ try {
46
+ writeFileSync(resolve(".volute/restart.json"), JSON.stringify({ action: "reload" }));
47
+ } catch (err) {
48
+ log("server", "failed to write restart signal:", err);
49
+ }
44
50
  server.close();
45
51
  process.exit(0);
46
52
  },
47
53
  });
48
54
 
49
55
  const router = createRouter({
50
- configPath: resolve("home/.config/sessions.json"),
56
+ configPath: resolve("home/.config/routes.json"),
51
57
  agentHandler: agent.resolve,
52
58
  fileHandler: createFileHandlerResolver(resolve("home")),
53
59
  });
@@ -4,6 +4,6 @@
4
4
  "biome.json.tmpl": "biome.json",
5
5
  "home/.config/volute.json.tmpl": "home/.config/volute.json"
6
6
  },
7
- "substitute": ["package.json", ".init/SOUL.md"],
7
+ "substitute": ["package.json", ".init/SOUL.md", "home/.config/routes.json"],
8
8
  "skillsDir": "home/.claude/skills"
9
9
  }
@@ -0,0 +1,5 @@
1
+ {
2
+ "gateUnmatched": true,
3
+ "rules": [{ "channel": "volute:*", "isDM": true, "session": "${channel}" }],
4
+ "default": "main"
5
+ }
@@ -23,7 +23,7 @@ See the **memory** skill for detailed guidance.
23
23
 
24
24
  ## Sessions
25
25
 
26
- - You may have **multiple named sessions** — each maintains its own conversation history. See `VOLUTE.md` for how to configure session routing via `.config/sessions.json`.
26
+ - You may have **multiple named sessions** — each maintains its own conversation history. See `VOLUTE.md` for how to configure session routing via `.config/routes.json`.
27
27
  - Your conversation may be **resumed** from a previous session — orient yourself by reading recent daily logs if needed.
28
28
  - On a **fresh session**, read `MEMORY.md` and recent daily logs to remember where you left off.
29
29
  - On **compaction**, update today's daily log to preserve context before the conversation is trimmed.
@@ -32,8 +32,10 @@ type PiSession = {
32
32
  currentMessageId?: string;
33
33
  };
34
34
 
35
- const DEFAULT_COMPACTION_MESSAGE =
36
- "Your conversation is approaching its context limit. Please update today's journal entry to preserve important context before the conversation is compacted.";
35
+ function defaultCompactionMessage(): string {
36
+ const today = new Date().toISOString().slice(0, 10);
37
+ return `Context is getting long — compaction is about to summarize this conversation. Before that happens, save anything important to files (MEMORY.md, memory/journal/${today}.md, etc.) since those survive compaction. Focus on: decisions made, open tasks, and anything you'd need to pick up where you left off.`;
38
+ }
37
39
 
38
40
  function resolveModel(modelStr: string) {
39
41
  const [provider, ...rest] = modelStr.split(":");
@@ -75,7 +77,7 @@ export function createAgent(options: {
75
77
  compactionMessage?: string;
76
78
  }): { resolve: HandlerResolver } {
77
79
  const sessions = new Map<string, PiSession>();
78
- const compactionMessage = options.compactionMessage ?? DEFAULT_COMPACTION_MESSAGE;
80
+ const compactionMessage = options.compactionMessage ?? defaultCompactionMessage();
79
81
 
80
82
  // Shared setup (created once)
81
83
  const modelStr = options.model || process.env.PI_MODEL || "anthropic:claude-sonnet-4-20250514";
@@ -29,7 +29,7 @@ const agent = createAgent({
29
29
  });
30
30
 
31
31
  const router = createRouter({
32
- configPath: resolve("home/.config/sessions.json"),
32
+ configPath: resolve("home/.config/routes.json"),
33
33
  agentHandler: agent.resolve,
34
34
  fileHandler: createFileHandlerResolver(resolve("home")),
35
35
  });
@@ -4,6 +4,6 @@
4
4
  "biome.json.tmpl": "biome.json",
5
5
  "home/.config/volute.json.tmpl": "home/.config/volute.json"
6
6
  },
7
- "substitute": ["package.json", ".init/SOUL.md"],
7
+ "substitute": ["package.json", ".init/SOUL.md", "home/.config/routes.json"],
8
8
  "skillsDir": "home/.claude/skills"
9
9
  }