volute 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +29 -29
  2. package/dist/agent-Z2B6EFEQ.js +75 -0
  3. package/dist/{agent-manager-AUCKMGPR.js → agent-manager-PXBKA2GK.js} +4 -4
  4. package/dist/channel-MK5OK2SI.js +113 -0
  5. package/dist/chunk-5X7HGB6L.js +107 -0
  6. package/dist/{chunk-YGFIWIOF.js → chunk-7L4AN5D4.js} +1 -1
  7. package/dist/{chunk-VRVVQIYY.js → chunk-AZEL2IEK.js} +1 -1
  8. package/dist/chunk-B3R6L2GW.js +24 -0
  9. package/dist/{chunk-DNOXHLE5.js → chunk-HE67X4T6.js} +1 -1
  10. package/dist/{chunk-I6OHXCMV.js → chunk-MW2KFO3B.js} +47 -9
  11. package/dist/chunk-MXUCNIBG.js +168 -0
  12. package/dist/chunk-SMISE4SV.js +226 -0
  13. package/dist/{chunk-SOZA2TLP.js → chunk-UAVD2AHX.js} +1 -1
  14. package/dist/{chunk-3C2XR4IY.js → chunk-UX25Z2ND.js} +113 -107
  15. package/dist/{chunk-GSPKUPKU.js → chunk-XUA3JUFK.js} +2 -1
  16. package/dist/chunk-ZYGKG6VC.js +22 -0
  17. package/dist/cli.js +98 -75
  18. package/dist/connector-LYEMXQEV.js +157 -0
  19. package/dist/connectors/discord.js +104 -161
  20. package/dist/connectors/slack.js +179 -0
  21. package/dist/connectors/telegram.js +175 -0
  22. package/dist/conversation-ERXEQZTY.js +163 -0
  23. package/dist/create-RVCZN6HE.js +91 -0
  24. package/dist/{daemon-client-XR24PUJF.js → daemon-client-ZY6UUN2M.js} +2 -2
  25. package/dist/daemon.js +824 -252
  26. package/dist/{delete-GQ7JEK2S.js → delete-3QH7VYIN.js} +8 -9
  27. package/dist/{down-3OB6UVAJ.js → down-O7IFZLVJ.js} +1 -1
  28. package/dist/{env-JB27UAC3.js → env-4D4REPJF.js} +8 -5
  29. package/dist/{history-3VRUBGGV.js → history-OEONB53Z.js} +5 -5
  30. package/dist/{import-K4MP2GX7.js → import-MXJB2EII.js} +23 -8
  31. package/dist/{logs-NXFFGUKY.js → logs-DF342W4M.js} +2 -2
  32. package/dist/message-ADHWFHSI.js +32 -0
  33. package/dist/package-VQOE7JNH.js +89 -0
  34. package/dist/{schedule-4I5TYHFH.js → schedule-NAG6F463.js} +12 -7
  35. package/dist/send-66QMKRUH.js +75 -0
  36. package/dist/{setup-SRS7AUAA.js → setup-RPRRGG2F.js} +6 -6
  37. package/dist/{start-LDPMCMYT.js → start-TUOXDSFL.js} +3 -3
  38. package/dist/{status-MVSQG54T.js → status-A36EHRO4.js} +3 -3
  39. package/dist/{stop-5PZTZCLL.js → stop-AOJZLQ5X.js} +6 -7
  40. package/dist/{up-UT3IMKCA.js → up-7ILD7GU7.js} +2 -2
  41. package/dist/update-LPSIAWQ2.js +140 -0
  42. package/dist/update-check-Y33QDCFL.js +17 -0
  43. package/dist/{upgrade-CDKECCGN.js → upgrade-FX2TKJ2S.js} +16 -15
  44. package/dist/{variant-CVYM3EQG.js → variant-LAB67OC2.js} +17 -12
  45. package/dist/web-assets/assets/index-BbRmoxoA.js +308 -0
  46. package/dist/web-assets/index.html +2 -2
  47. package/drizzle/0003_clean_ego.sql +12 -0
  48. package/drizzle/meta/0003_snapshot.json +417 -0
  49. package/drizzle/meta/_journal.json +7 -0
  50. package/package.json +3 -1
  51. package/templates/_base/.init/.config/hooks/startup-context.sh +19 -1
  52. package/templates/_base/_skills/volute-agent/SKILL.md +112 -16
  53. package/templates/_base/home/.config/routes.json +10 -0
  54. package/templates/_base/home/VOLUTE.md +19 -28
  55. package/templates/_base/src/lib/file-handler.ts +46 -0
  56. package/templates/_base/src/lib/format-prefix.ts +1 -1
  57. package/templates/_base/src/lib/router.ts +327 -0
  58. package/templates/_base/src/lib/routing.ts +137 -0
  59. package/templates/_base/src/lib/types.ts +16 -3
  60. package/templates/_base/src/lib/volute-server.ts +20 -48
  61. package/templates/agent-sdk/.init/.config/routes.json +5 -0
  62. package/templates/agent-sdk/.init/CLAUDE.md +2 -2
  63. package/templates/agent-sdk/src/agent.ts +269 -82
  64. package/templates/agent-sdk/src/server.ts +19 -4
  65. package/templates/agent-sdk/volute-template.json +1 -1
  66. package/templates/pi/.init/.config/routes.json +5 -0
  67. package/templates/pi/.init/AGENTS.md +1 -1
  68. package/templates/pi/src/agent.ts +279 -58
  69. package/templates/pi/src/server.ts +15 -4
  70. package/templates/pi/volute-template.json +1 -1
  71. package/dist/channel-7FZ6D25H.js +0 -90
  72. package/dist/chunk-N4YNKR3Q.js +0 -90
  73. package/dist/connector-TVJULIRT.js +0 -96
  74. package/dist/create-BRG2DBWI.js +0 -79
  75. package/dist/send-UK3JBZIB.js +0 -53
  76. package/dist/web-assets/assets/index-BC5eSqbY.js +0 -296
  77. package/templates/_base/src/lib/sessions.ts +0 -71
  78. package/templates/agent-sdk/.init/.config/sessions.json +0 -4
  79. package/templates/agent-sdk/src/lib/agent-sessions.ts +0 -204
  80. package/templates/pi/.init/.config/sessions.json +0 -1
  81. package/templates/pi/src/lib/agent-sessions.ts +0 -210
  82. package/dist/{service-SA4TTMDU.js → service-HZNIDNJF.js} +3 -3
@@ -0,0 +1,137 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { log } from "./logger.js";
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
+
10
+ export type RoutingRule = {
11
+ session?: string;
12
+ destination?: "agent" | "file";
13
+ path?: string; // file path for file destination
14
+ interrupt?: boolean; // interrupt in-progress agent turn (default: true for agent)
15
+ batch?: number | BatchConfig; // number = minutes (legacy), object = fine-grained control
16
+ channel?: string;
17
+ sender?: string;
18
+ isDM?: boolean; // match on isDM metadata
19
+ participants?: number; // match on participant count (e.g. 2 = DM)
20
+ };
21
+
22
+ export type RoutingConfig = {
23
+ rules?: RoutingRule[];
24
+ default?: string;
25
+ gateUnmatched?: boolean;
26
+ };
27
+
28
+ export type ResolvedRoute =
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
+ }
43
+
44
+ export function loadRoutingConfig(configPath: string): RoutingConfig {
45
+ try {
46
+ return JSON.parse(readFileSync(configPath, "utf-8"));
47
+ } catch (err: any) {
48
+ if (err?.code !== "ENOENT") {
49
+ log("routing", `failed to load ${configPath}:`, err);
50
+ }
51
+ return {};
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Match a glob-like pattern against a string.
57
+ * Supports only `*` as wildcard (matches any sequence of characters).
58
+ */
59
+ function globMatch(pattern: string, value: string): boolean {
60
+ // Escape regex special chars except *, then replace * with .*
61
+ const regex = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
62
+ return new RegExp(`^${regex}$`).test(value);
63
+ }
64
+
65
+ const GLOB_MATCH_KEYS = new Set(["channel", "sender"]);
66
+ const NON_MATCH_KEYS = new Set(["session", "batch", "destination", "path", "interrupt"]);
67
+
68
+ type MatchMeta = { channel?: string; sender?: string; isDM?: boolean; participantCount?: number };
69
+
70
+ function ruleMatches(rule: RoutingRule, meta: MatchMeta): boolean {
71
+ for (const [key, pattern] of Object.entries(rule)) {
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
89
+ if (typeof pattern !== "string") return false;
90
+ if (!GLOB_MATCH_KEYS.has(key)) return false;
91
+ const value = meta[key as "channel" | "sender"] ?? "";
92
+ if (!globMatch(pattern, value)) return false;
93
+ }
94
+ return true;
95
+ }
96
+
97
+ function expandTemplate(template: string, meta: MatchMeta): string {
98
+ return template
99
+ .replace(/\$\{sender\}/g, meta.sender ?? "unknown")
100
+ .replace(/\$\{channel\}/g, meta.channel ?? "unknown");
101
+ }
102
+
103
+ /**
104
+ * Resolve the full route for a message: destination type, session/path, interrupt, batch.
105
+ */
106
+ export function resolveRoute(config: RoutingConfig, meta: MatchMeta): ResolvedRoute {
107
+ const fallback = config.default ?? "main";
108
+
109
+ if (!config.rules) {
110
+ return { destination: "agent", session: fallback, interrupt: true, matched: false };
111
+ }
112
+
113
+ for (const rule of config.rules) {
114
+ if (ruleMatches(rule, meta)) {
115
+ if (rule.destination === "file") {
116
+ if (!rule.path) {
117
+ log("routing", `file destination rule missing path — falling through`);
118
+ continue;
119
+ }
120
+ return { destination: "file", path: rule.path, matched: true };
121
+ }
122
+ return {
123
+ destination: "agent",
124
+ session: sanitizeSessionName(expandTemplate(rule.session ?? fallback, meta)),
125
+ interrupt: rule.interrupt ?? true,
126
+ batch: rule.batch != null ? normalizeBatch(rule.batch) : undefined,
127
+ matched: true,
128
+ };
129
+ }
130
+ }
131
+
132
+ return { destination: "agent", session: fallback, interrupt: true, matched: false };
133
+ }
134
+
135
+ function sanitizeSessionName(name: string): string {
136
+ return name.replace(/\0/g, "").replace(/[/\\]/g, "-").replace(/\.\./g, "-").slice(0, 100);
137
+ }
@@ -8,9 +8,16 @@ 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
- messageId?: string;
13
+ participants?: string[];
14
+ participantCount?: number;
15
+ };
16
+
17
+ /** ChannelMeta enriched by the router with dispatch info. */
18
+ export type HandlerMeta = ChannelMeta & {
19
+ messageId: string;
20
+ interrupt?: boolean;
14
21
  };
15
22
 
16
23
  export type VoluteRequest = {
@@ -28,4 +35,10 @@ export type VoluteEvent = { messageId?: string } & (
28
35
 
29
36
  export type Listener = (event: VoluteEvent) => void;
30
37
 
31
- export const INTERACTIVE_CHANNELS = new Set(["web", "cli", "discord"]);
38
+ /** A handler that processes a single routed message and streams events to a listener. */
39
+ export type MessageHandler = {
40
+ handle(content: VoluteContentPart[], meta: HandlerMeta, listener: Listener): () => void; // returns unsubscribe
41
+ };
42
+
43
+ /** Resolves a key (session name, file path, etc.) to a MessageHandler. */
44
+ 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 { loadSessionConfig, resolveSession } from "./sessions.js";
4
- import type { ChannelMeta, VoluteContentPart, VoluteEvent, VoluteRequest } from "./types.js";
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
- agent: VoluteAgent;
16
+ router: Router;
22
17
  port: number;
23
18
  name: string;
24
19
  version: string;
25
- sessionsConfigPath?: string;
26
20
  }): Server {
27
- const { agent, port, name, version } = options;
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 removeListener = agent.onMessage((event) => {
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
- removeListener();
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
- agent.sendMessage(body.content, {
82
- channel: body.channel,
83
- sender: body.sender,
84
- platform: body.platform,
85
- isDM: body.isDM,
86
- channelName: body.channelName,
87
- guildName: body.guildName,
88
- sessionName,
89
- messageId,
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) => {
@@ -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.