volute 0.3.1 → 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.
Files changed (40) hide show
  1. package/README.md +7 -7
  2. package/dist/{channel-7FZ6D25H.js → channel-DQ6UY7QB.js} +16 -39
  3. package/dist/chunk-5OCWMTVS.js +152 -0
  4. package/dist/chunk-MXUCNIBG.js +168 -0
  5. package/dist/{chunk-N4YNKR3Q.js → chunk-ZHCE4DPY.js} +20 -0
  6. package/dist/cli.js +29 -18
  7. package/dist/connector-DKDJTLYZ.js +152 -0
  8. package/dist/connectors/discord.js +102 -161
  9. package/dist/connectors/slack.js +170 -0
  10. package/dist/connectors/telegram.js +156 -0
  11. package/dist/daemon.js +262 -142
  12. package/dist/{import-K4MP2GX7.js → import-4CI2ZUTJ.js} +15 -0
  13. package/dist/package-Z2SFO2SV.js +89 -0
  14. package/dist/{send-UK3JBZIB.js → send-3U6OTKG7.js} +6 -2
  15. package/dist/web-assets/assets/{index-BC5eSqbY.js → index-NS621maO.js} +23 -23
  16. package/dist/web-assets/index.html +1 -1
  17. package/package.json +3 -1
  18. package/templates/_base/_skills/volute-agent/SKILL.md +3 -3
  19. package/templates/_base/home/VOLUTE.md +18 -6
  20. package/templates/_base/src/lib/file-handler.ts +46 -0
  21. package/templates/_base/src/lib/router.ts +180 -0
  22. package/templates/_base/src/lib/routing.ts +100 -0
  23. package/templates/_base/src/lib/types.ts +13 -2
  24. package/templates/_base/src/lib/volute-server.ts +20 -48
  25. package/templates/agent-sdk/src/agent.ts +268 -82
  26. package/templates/agent-sdk/src/server.ts +12 -3
  27. package/templates/pi/src/agent.ts +277 -58
  28. package/templates/pi/src/server.ts +15 -4
  29. package/dist/connector-TVJULIRT.js +0 -96
  30. package/templates/_base/src/lib/sessions.ts +0 -71
  31. package/templates/agent-sdk/src/lib/agent-sessions.ts +0 -204
  32. package/templates/pi/src/lib/agent-sessions.ts +0 -210
  33. package/dist/{create-BRG2DBWI.js → create-ILVOG75A.js} +3 -3
  34. package/dist/{delete-GQ7JEK2S.js → delete-55MXCEY5.js} +3 -3
  35. package/dist/{history-3VRUBGGV.js → history-BKG74I43.js} +3 -3
  36. package/dist/{schedule-4I5TYHFH.js → schedule-A35SH4HT.js} +3 -3
  37. package/dist/{setup-SRS7AUAA.js → setup-2FDVN7OF.js} +3 -3
  38. package/dist/{up-UT3IMKCA.js → up-F7TMTLRE.js} +0 -0
  39. package/dist/{upgrade-CDKECCGN.js → upgrade-6ZW2RD64.js} +3 -3
  40. package/dist/{variant-CVYM3EQG.js → variant-T64BKARF.js} +6 -6
@@ -1,15 +1,58 @@
1
- import { createSessionManager } from "./lib/agent-sessions.js";
2
- import { formatPrefix } from "./lib/format-prefix.js";
1
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { resolve as resolvePath } from "node:path";
3
+ import type { HookCallback } from "@anthropic-ai/claude-agent-sdk";
4
+ import { query } from "@anthropic-ai/claude-agent-sdk";
3
5
  import { createAutoCommitHook } from "./lib/hooks/auto-commit.js";
4
6
  import { createIdentityReloadHook } from "./lib/hooks/identity-reload.js";
5
- import { logMessage } from "./lib/logger.js";
6
- import {
7
- type ChannelMeta,
8
- INTERACTIVE_CHANNELS,
9
- type Listener,
10
- type VoluteContentPart,
7
+ import { createPreCompactHook } from "./lib/hooks/pre-compact.js";
8
+ import { log, logText, logThinking, logToolUse } from "./lib/logger.js";
9
+ import { createMessageChannel } from "./lib/message-channel.js";
10
+ import type {
11
+ HandlerMeta,
12
+ HandlerResolver,
13
+ Listener,
14
+ MessageHandler,
15
+ VoluteContentPart,
16
+ VoluteEvent,
11
17
  } from "./lib/types.js";
12
18
 
19
+ type SDKContent = (
20
+ | { type: "text"; text: string }
21
+ | {
22
+ type: "image";
23
+ source: {
24
+ type: "base64";
25
+ media_type: "image/jpeg" | "image/png" | "image/gif" | "image/webp";
26
+ data: string;
27
+ };
28
+ }
29
+ )[];
30
+
31
+ type Session = {
32
+ name: string;
33
+ channel: ReturnType<typeof createMessageChannel>;
34
+ listeners: Set<Listener>;
35
+ messageIds: (string | undefined)[];
36
+ currentMessageId?: string;
37
+ currentQuery?: ReturnType<typeof query>;
38
+ };
39
+
40
+ function toSDKContent(content: VoluteContentPart[]): SDKContent {
41
+ return content.map((part) => {
42
+ if (part.type === "text") {
43
+ return { type: "text" as const, text: part.text };
44
+ }
45
+ return {
46
+ type: "image" as const,
47
+ source: {
48
+ type: "base64" as const,
49
+ media_type: part.media_type as "image/jpeg" | "image/png" | "image/gif" | "image/webp",
50
+ data: part.data,
51
+ },
52
+ };
53
+ });
54
+ }
55
+
13
56
  export function createAgent(options: {
14
57
  systemPrompt: string;
15
58
  cwd: string;
@@ -18,95 +61,238 @@ export function createAgent(options: {
18
61
  sessionsDir: string;
19
62
  compactionMessage?: string;
20
63
  onIdentityReload?: () => Promise<void>;
21
- }) {
64
+ }): { resolve: HandlerResolver; waitForCommits: () => Promise<void> } {
22
65
  const autoCommit = createAutoCommitHook(options.cwd);
23
66
  const identityReload = createIdentityReloadHook(options.cwd);
67
+ const postToolUseHooks: { matcher: string; hooks: HookCallback[] }[] = [
68
+ { matcher: "Edit|Write", hooks: [autoCommit.hook, identityReload.hook] },
69
+ ];
24
70
 
25
- const sessionManager = createSessionManager({
26
- systemPrompt: options.systemPrompt,
27
- cwd: options.cwd,
28
- abortController: options.abortController,
29
- model: options.model,
30
- sessionsDir: options.sessionsDir,
31
- compactionMessage: options.compactionMessage,
32
- postToolUseHooks: [{ matcher: "Edit|Write", hooks: [autoCommit.hook, identityReload.hook] }],
33
- onTurnDone: () => {
34
- if (identityReload.needsReload()) {
35
- options.onIdentityReload?.();
71
+ const sessions = new Map<string, Session>();
72
+ const compactionMessage =
73
+ 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
+
76
+ // --- Session persistence ---
77
+
78
+ function sessionFilePath(sessionName: string): string {
79
+ return resolvePath(options.sessionsDir, `${sessionName}.json`);
80
+ }
81
+
82
+ function loadSessionId(sessionName: string): string | undefined {
83
+ try {
84
+ const data = JSON.parse(readFileSync(sessionFilePath(sessionName), "utf-8"));
85
+ return data.sessionId;
86
+ } catch (err: any) {
87
+ if (err?.code !== "ENOENT") {
88
+ log("agent", `failed to load session file for "${sessionName}":`, err);
36
89
  }
37
- },
38
- });
90
+ return undefined;
91
+ }
92
+ }
39
93
 
40
- function sendMessage(content: string | VoluteContentPart[], meta?: ChannelMeta) {
41
- const sessionName = meta?.sessionName ?? "main";
42
- const session = sessionManager.getOrCreateSession(sessionName);
43
-
44
- const text =
45
- typeof content === "string"
46
- ? content
47
- : content.map((p) => (p.type === "text" ? p.text : `[${p.type}]`)).join(" ");
48
- logMessage("in", text, meta?.channel);
49
-
50
- const time = new Date().toLocaleString();
51
- const prefix = formatPrefix(meta, time);
52
-
53
- let sdkContent: (
54
- | { type: "text"; text: string }
55
- | {
56
- type: "image";
57
- source: {
58
- type: "base64";
59
- media_type: "image/jpeg" | "image/png" | "image/gif" | "image/webp";
60
- data: string;
61
- };
62
- }
63
- )[];
94
+ function saveSessionId(sessionName: string, sessionId: string) {
95
+ mkdirSync(options.sessionsDir, { recursive: true });
96
+ writeFileSync(sessionFilePath(sessionName), JSON.stringify({ sessionId }));
97
+ }
64
98
 
65
- if (typeof content === "string") {
66
- sdkContent = [{ type: "text" as const, text: prefix + content }];
67
- } else {
68
- const hasText = content.some((p) => p.type === "text");
69
- sdkContent = content.map((part, i) => {
70
- if (part.type === "text") {
71
- return { type: "text" as const, text: (i === 0 ? prefix : "") + part.text };
72
- }
73
- return {
74
- type: "image" as const,
75
- source: {
76
- type: "base64" as const,
77
- media_type: part.media_type as "image/jpeg" | "image/png" | "image/gif" | "image/webp",
78
- data: part.data,
79
- },
80
- };
99
+ function deleteSessionId(sessionName: string) {
100
+ try {
101
+ const path = sessionFilePath(sessionName);
102
+ if (existsSync(path)) unlinkSync(path);
103
+ } catch (err) {
104
+ log("agent", `failed to delete session file for "${sessionName}":`, err);
105
+ }
106
+ }
107
+
108
+ // --- Event broadcasting ---
109
+
110
+ function broadcastToSession(session: Session, event: VoluteEvent) {
111
+ const tagged =
112
+ session.currentMessageId != null ? { ...event, messageId: session.currentMessageId } : event;
113
+ for (const listener of session.listeners) {
114
+ try {
115
+ listener(tagged);
116
+ } catch (err) {
117
+ log("agent", "listener threw during broadcast:", err);
118
+ }
119
+ }
120
+ }
121
+
122
+ // --- SDK stream management ---
123
+
124
+ function createStream(session: Session, resume?: string) {
125
+ const preCompact = createPreCompactHook(() => {
126
+ session.messageIds.push(undefined);
127
+ session.channel.push({
128
+ type: "user",
129
+ session_id: "",
130
+ message: {
131
+ role: "user",
132
+ content: [{ type: "text", text: compactionMessage }],
133
+ },
134
+ parent_tool_use_id: null,
81
135
  });
82
- if (prefix && !hasText) {
83
- sdkContent.unshift({ type: "text" as const, text: prefix.trimEnd() });
136
+ });
137
+
138
+ return query({
139
+ prompt: session.channel.iterable,
140
+ options: {
141
+ systemPrompt: options.systemPrompt,
142
+ permissionMode: "bypassPermissions",
143
+ allowDangerouslySkipPermissions: true,
144
+ settingSources: ["project"],
145
+ cwd: options.cwd,
146
+ abortController: options.abortController,
147
+ model: options.model,
148
+ resume,
149
+ hooks: {
150
+ PostToolUse: postToolUseHooks,
151
+ PreCompact: [{ hooks: [preCompact.hook] }],
152
+ },
153
+ },
154
+ });
155
+ }
156
+
157
+ async function consumeStream(stream: ReturnType<typeof query>, session: Session) {
158
+ for await (const msg of stream) {
159
+ if (session.currentMessageId === undefined) {
160
+ session.currentMessageId = session.messageIds.shift();
161
+ }
162
+ if ("session_id" in msg && msg.session_id) {
163
+ if (!session.name.startsWith("new-")) {
164
+ saveSessionId(session.name, msg.session_id as string);
165
+ }
166
+ }
167
+ if (msg.type === "assistant") {
168
+ for (const b of msg.message.content) {
169
+ if (b.type === "thinking" && "thinking" in b && b.thinking) {
170
+ logThinking(b.thinking as string);
171
+ } else if (b.type === "text") {
172
+ const text = (b as { text: string }).text;
173
+ logText(text);
174
+ broadcastToSession(session, { type: "text", content: text });
175
+ } else if (b.type === "tool_use") {
176
+ const tb = b as { name: string; input: unknown };
177
+ logToolUse(tb.name, tb.input);
178
+ broadcastToSession(session, { type: "tool_use", name: tb.name, input: tb.input });
179
+ }
180
+ }
181
+ }
182
+ if (msg.type === "result") {
183
+ log("agent", `session "${session.name}": turn done`);
184
+ broadcastToSession(session, { type: "done" });
185
+ session.currentMessageId = undefined;
186
+ if (identityReload.needsReload()) {
187
+ options.onIdentityReload?.();
188
+ }
84
189
  }
85
190
  }
191
+ }
192
+
193
+ function startSession(session: Session, savedSessionId?: string) {
194
+ (async () => {
195
+ log("agent", `session "${session.name}": stream consumer started`);
196
+ try {
197
+ const q = createStream(session, savedSessionId);
198
+ session.currentQuery = q;
199
+ await consumeStream(q, session);
200
+ } catch (err) {
201
+ if (savedSessionId) {
202
+ log("agent", `session "${session.name}": resume failed, starting fresh:`, err);
203
+ deleteSessionId(session.name);
204
+ try {
205
+ const q = createStream(session);
206
+ session.currentQuery = q;
207
+ await consumeStream(q, session);
208
+ } catch (retryErr) {
209
+ log("agent", `session "${session.name}": stream consumer error:`, retryErr);
210
+ broadcastToSession(session, { type: "done" });
211
+ sessions.delete(session.name);
212
+ }
213
+ } else {
214
+ log("agent", `session "${session.name}": stream consumer error:`, err);
215
+ broadcastToSession(session, { type: "done" });
216
+ sessions.delete(session.name);
217
+ }
218
+ }
219
+ log("agent", `session "${session.name}": stream consumer ended`);
220
+ })();
221
+ }
222
+
223
+ function getOrCreateSession(name: string): Session {
224
+ const existing = sessions.get(name);
225
+ if (existing) return existing;
226
+
227
+ const session: Session = {
228
+ name,
229
+ channel: createMessageChannel(),
230
+ listeners: new Set(),
231
+ messageIds: [],
232
+ };
233
+ sessions.set(name, session);
86
234
 
87
- // Interrupt current turn for interactive channels so the new message is processed immediately
88
- if (INTERACTIVE_CHANNELS.has(meta?.channel ?? "") && session.currentMessageId !== undefined) {
89
- sessionManager.interruptSession(sessionName);
235
+ const isEphemeral = name.startsWith("new-");
236
+ const savedSessionId = isEphemeral ? undefined : loadSessionId(name);
237
+ if (savedSessionId) {
238
+ log("agent", `session "${name}": resuming ${savedSessionId}`);
239
+ } else {
240
+ log("agent", `session "${name}": starting fresh`);
90
241
  }
91
242
 
92
- session.messageIds.push(meta?.messageId);
93
- session.channel.push({
94
- type: "user",
95
- session_id: "",
96
- message: {
97
- role: "user",
98
- content: sdkContent,
243
+ startSession(session, savedSessionId);
244
+ return session;
245
+ }
246
+
247
+ // --- MessageHandler implementation ---
248
+
249
+ function createSessionHandler(sessionName: string): MessageHandler {
250
+ return {
251
+ handle(content: VoluteContentPart[], meta: HandlerMeta, listener: Listener): () => void {
252
+ const session = getOrCreateSession(sessionName);
253
+
254
+ // Filter listener to only receive events for this messageId
255
+ const filteredListener: Listener = (event) => {
256
+ if (event.messageId === meta.messageId) listener(event);
257
+ };
258
+ session.listeners.add(filteredListener);
259
+
260
+ // Interrupt if requested and session is mid-turn
261
+ if (meta.interrupt && session.currentMessageId !== undefined && session.currentQuery) {
262
+ log("agent", `session "${sessionName}": interrupting current turn`);
263
+ session.currentQuery.interrupt();
264
+ }
265
+
266
+ // Push message into SDK
267
+ session.messageIds.push(meta.messageId);
268
+ session.channel.push({
269
+ type: "user",
270
+ session_id: "",
271
+ message: { role: "user", content: toSDKContent(content) },
272
+ parent_tool_use_id: null,
273
+ });
274
+
275
+ return () => session.listeners.delete(filteredListener);
99
276
  },
100
- parent_tool_use_id: null,
101
- });
277
+ };
102
278
  }
103
279
 
104
- function onMessage(listener: Listener, sessionName?: string): () => void {
105
- const name = sessionName ?? "main";
106
- const session = sessionManager.getOrCreateSession(name);
107
- session.listeners.add(listener);
108
- return () => session.listeners.delete(listener);
280
+ // --- HandlerResolver ---
281
+
282
+ const handlers = new Map<string, MessageHandler>();
283
+
284
+ function resolve(sessionName: string): MessageHandler {
285
+ // Ephemeral sessions get unique names — don't cache their handlers
286
+ if (sessionName.startsWith("new-")) {
287
+ return createSessionHandler(sessionName);
288
+ }
289
+ let handler = handlers.get(sessionName);
290
+ if (!handler) {
291
+ handler = createSessionHandler(sessionName);
292
+ handlers.set(sessionName, handler);
293
+ }
294
+ return handler;
109
295
  }
110
296
 
111
- return { sendMessage, onMessage, waitForCommits: autoCommit.waitForCommits };
297
+ return { resolve, waitForCommits: autoCommit.waitForCommits };
112
298
  }
@@ -1,7 +1,9 @@
1
1
  import { existsSync, mkdirSync, renameSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { createAgent } from "./agent.js";
4
+ import { createFileHandlerResolver } from "./lib/file-handler.js";
4
5
  import { log } from "./lib/logger.js";
6
+ import { createRouter } from "./lib/router.js";
5
7
  import {
6
8
  handleMergeContext,
7
9
  loadConfig,
@@ -44,19 +46,26 @@ const agent = createAgent({
44
46
  },
45
47
  });
46
48
 
49
+ const router = createRouter({
50
+ configPath: resolve("home/.config/sessions.json"),
51
+ agentHandler: agent.resolve,
52
+ fileHandler: createFileHandlerResolver(resolve("home")),
53
+ });
54
+
47
55
  const server = createVoluteServer({
48
- agent,
56
+ router,
49
57
  port,
50
58
  name: pkg.name,
51
59
  version: pkg.version,
52
- sessionsConfigPath: resolve("home/.config/sessions.json"),
53
60
  });
54
61
 
55
62
  server.listen(port, () => {
56
63
  const addr = server.address();
57
64
  const actualPort = typeof addr === "object" && addr ? addr.port : port;
58
65
  log("server", `listening on :${actualPort}`);
59
- handleMergeContext((content) => agent.sendMessage(content));
66
+ handleMergeContext((content) =>
67
+ router.route([{ type: "text", text: content }], { channel: "system" }),
68
+ );
60
69
  });
61
70
 
62
71
  setupShutdown();