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.
- package/README.md +22 -22
- package/dist/agent-Z2B6EFEQ.js +75 -0
- package/dist/{agent-manager-AUCKMGPR.js → agent-manager-PXBKA2GK.js} +4 -4
- package/dist/channel-MK5OK2SI.js +113 -0
- package/dist/chunk-5X7HGB6L.js +107 -0
- package/dist/{chunk-YGFIWIOF.js → chunk-7L4AN5D4.js} +1 -1
- package/dist/{chunk-VRVVQIYY.js → chunk-AZEL2IEK.js} +1 -1
- package/dist/chunk-B3R6L2GW.js +24 -0
- package/dist/{chunk-DNOXHLE5.js → chunk-HE67X4T6.js} +1 -1
- package/dist/{chunk-I6OHXCMV.js → chunk-MW2KFO3B.js} +47 -9
- package/dist/{chunk-5OCWMTVS.js → chunk-SMISE4SV.js} +77 -3
- package/dist/{chunk-SOZA2TLP.js → chunk-UAVD2AHX.js} +1 -1
- package/dist/{chunk-3C2XR4IY.js → chunk-UX25Z2ND.js} +113 -107
- package/dist/{chunk-GSPKUPKU.js → chunk-XUA3JUFK.js} +2 -1
- package/dist/chunk-ZYGKG6VC.js +22 -0
- package/dist/cli.js +86 -74
- package/dist/{connector-DKDJTLYZ.js → connector-LYEMXQEV.js} +11 -6
- package/dist/connectors/discord.js +3 -1
- package/dist/connectors/slack.js +14 -5
- package/dist/connectors/telegram.js +21 -2
- package/dist/conversation-ERXEQZTY.js +163 -0
- package/dist/create-RVCZN6HE.js +91 -0
- package/dist/{daemon-client-XR24PUJF.js → daemon-client-ZY6UUN2M.js} +2 -2
- package/dist/daemon.js +629 -177
- package/dist/{delete-55MXCEY5.js → delete-3QH7VYIN.js} +7 -8
- package/dist/{down-3OB6UVAJ.js → down-O7IFZLVJ.js} +1 -1
- package/dist/{env-JB27UAC3.js → env-4D4REPJF.js} +8 -5
- package/dist/{history-BKG74I43.js → history-OEONB53Z.js} +3 -3
- package/dist/{import-4CI2ZUTJ.js → import-MXJB2EII.js} +8 -8
- package/dist/{logs-NXFFGUKY.js → logs-DF342W4M.js} +2 -2
- package/dist/message-ADHWFHSI.js +32 -0
- package/dist/{package-Z2SFO2SV.js → package-VQOE7JNH.js} +1 -1
- package/dist/{schedule-A35SH4HT.js → schedule-NAG6F463.js} +10 -5
- package/dist/send-66QMKRUH.js +75 -0
- package/dist/{setup-2FDVN7OF.js → setup-RPRRGG2F.js} +5 -5
- package/dist/{start-LDPMCMYT.js → start-TUOXDSFL.js} +3 -3
- package/dist/{status-MVSQG54T.js → status-A36EHRO4.js} +3 -3
- package/dist/{stop-5PZTZCLL.js → stop-AOJZLQ5X.js} +6 -7
- package/dist/{up-F7TMTLRE.js → up-7ILD7GU7.js} +2 -2
- package/dist/update-LPSIAWQ2.js +140 -0
- package/dist/update-check-Y33QDCFL.js +17 -0
- package/dist/{upgrade-6ZW2RD64.js → upgrade-FX2TKJ2S.js} +16 -15
- package/dist/{variant-T64BKARF.js → variant-LAB67OC2.js} +15 -10
- package/dist/web-assets/assets/index-BbRmoxoA.js +308 -0
- package/dist/web-assets/index.html +2 -2
- package/drizzle/0003_clean_ego.sql +12 -0
- package/drizzle/meta/0003_snapshot.json +417 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/templates/_base/.init/.config/hooks/startup-context.sh +19 -1
- package/templates/_base/_skills/volute-agent/SKILL.md +110 -14
- package/templates/_base/home/.config/routes.json +10 -0
- package/templates/_base/home/VOLUTE.md +14 -35
- package/templates/_base/src/lib/format-prefix.ts +1 -1
- package/templates/_base/src/lib/router.ts +163 -16
- package/templates/_base/src/lib/routing.ts +55 -18
- package/templates/_base/src/lib/types.ts +3 -1
- package/templates/agent-sdk/.init/.config/routes.json +5 -0
- package/templates/agent-sdk/.init/CLAUDE.md +2 -2
- package/templates/agent-sdk/src/agent.ts +2 -1
- package/templates/agent-sdk/src/server.ts +8 -2
- package/templates/agent-sdk/volute-template.json +1 -1
- package/templates/pi/.init/.config/routes.json +5 -0
- package/templates/pi/.init/AGENTS.md +1 -1
- package/templates/pi/src/agent.ts +5 -3
- package/templates/pi/src/server.ts +1 -1
- package/templates/pi/volute-template.json +1 -1
- package/dist/channel-DQ6UY7QB.js +0 -67
- package/dist/chunk-ZHCE4DPY.js +0 -110
- package/dist/create-ILVOG75A.js +0 -79
- package/dist/send-3U6OTKG7.js +0 -57
- package/dist/web-assets/assets/index-NS621maO.js +0 -296
- package/templates/agent-sdk/.init/.config/sessions.json +0 -4
- package/templates/pi/.init/.config/sessions.json +0 -1
- package/dist/{service-SA4TTMDU.js → service-HZNIDNJF.js} +3 -3
|
@@ -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
|
-
|
|
|
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
|
-
|
|
14
|
+
## Responding to Messages
|
|
17
15
|
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
+
To reach out on your own initiative, use `volute channel send <uri> "message"`.
|
|
23
21
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
- **file** — appends the message to a file (requires `path`); useful for logging channels to disk
|
|
31
|
+
## Channel Gating
|
|
47
32
|
|
|
48
|
-
|
|
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
|
-
|
|
51
|
-
- `batch` — buffer messages for N minutes, then deliver as a single batch. Useful for high-volume channels.
|
|
35
|
+
## Reference
|
|
52
36
|
|
|
53
|
-
|
|
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.
|
|
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
|
-
|
|
20
|
+
serverName?: string;
|
|
21
21
|
timestamp: string;
|
|
22
22
|
};
|
|
23
23
|
|
|
24
24
|
type BatchBuffer = {
|
|
25
25
|
messages: BufferedMessage[];
|
|
26
|
-
|
|
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
|
|
69
|
-
|
|
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
|
|
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) =>
|
|
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, {
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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; //
|
|
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
|
-
| {
|
|
21
|
-
|
|
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("
|
|
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
|
|
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
|
-
|
|
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 (!
|
|
52
|
-
const value = meta[key as
|
|
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:
|
|
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("
|
|
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
|
-
|
|
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. */
|
|
@@ -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/`
|
|
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/
|
|
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
|
-
|
|
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/
|
|
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
|
}
|
|
@@ -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/
|
|
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
|
-
|
|
36
|
-
|
|
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 ??
|
|
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/
|
|
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
|
}
|