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