volute 0.4.0 → 0.6.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 +22 -22
  2. package/dist/agent-X7GJLBLW.js +79 -0
  3. package/dist/{agent-manager-AUCKMGPR.js → agent-manager-JDVXU3ON.js} +4 -4
  4. package/dist/channel-SMCNOIVQ.js +262 -0
  5. package/dist/chunk-AOKAQGO4.js +107 -0
  6. package/dist/{chunk-VRVVQIYY.js → chunk-AZEL2IEK.js} +1 -1
  7. package/dist/chunk-B3R6L2GW.js +24 -0
  8. package/dist/{chunk-MXUCNIBG.js → chunk-BX7KI4S3.js} +68 -3
  9. package/dist/{chunk-I6OHXCMV.js → chunk-G6ZNGLUX.js} +47 -9
  10. package/dist/{chunk-DNOXHLE5.js → chunk-H7AMDUIA.js} +1 -1
  11. package/dist/{chunk-YGFIWIOF.js → chunk-JR4UXCTO.js} +1 -1
  12. package/dist/{chunk-3C2XR4IY.js → chunk-UWHWAPGO.js} +120 -107
  13. package/dist/{chunk-SOZA2TLP.js → chunk-W76KWE23.js} +1 -1
  14. package/dist/{chunk-GSPKUPKU.js → chunk-XUA3JUFK.js} +2 -1
  15. package/dist/chunk-ZYGKG6VC.js +22 -0
  16. package/dist/chunk-ZZOOTYXK.js +583 -0
  17. package/dist/cli.js +83 -74
  18. package/dist/{connector-DKDJTLYZ.js → connector-Y7JPNROO.js} +11 -6
  19. package/dist/connectors/discord.js +34 -5
  20. package/dist/connectors/slack.js +36 -8
  21. package/dist/connectors/telegram.js +55 -6
  22. package/dist/create-G525LWEA.js +91 -0
  23. package/dist/{daemon-client-XR24PUJF.js → daemon-client-442IV43D.js} +2 -2
  24. package/dist/daemon.js +1273 -384
  25. package/dist/{delete-55MXCEY5.js → delete-2PH2CGDY.js} +7 -8
  26. package/dist/{down-3OB6UVAJ.js → down-FXWAN66A.js} +1 -1
  27. package/dist/{env-JB27UAC3.js → env-7GLUJCWS.js} +8 -5
  28. package/dist/{history-BKG74I43.js → history-H72ZUIBN.js} +3 -3
  29. package/dist/{import-4CI2ZUTJ.js → import-AVKQJDYC.js} +8 -8
  30. package/dist/{logs-NXFFGUKY.js → logs-EDGK26AK.js} +2 -2
  31. package/dist/message-SCOQDR3P.js +32 -0
  32. package/dist/{package-Z2SFO2SV.js → package-4DP4Y4UO.js} +1 -1
  33. package/dist/restart-O4ETYLJF.js +29 -0
  34. package/dist/{schedule-A35SH4HT.js → schedule-S6QVC5ON.js} +10 -5
  35. package/dist/send-G7PE4DOJ.js +72 -0
  36. package/dist/{setup-2FDVN7OF.js → setup-F4TCWVSP.js} +5 -5
  37. package/dist/{start-LDPMCMYT.js → start-VHQ7LNWM.js} +3 -3
  38. package/dist/{status-MVSQG54T.js → status-QAJWXKMZ.js} +3 -3
  39. package/dist/{stop-5PZTZCLL.js → stop-CAGCT5NI.js} +6 -7
  40. package/dist/{up-F7TMTLRE.js → up-CSX3ZUIU.js} +16 -4
  41. package/dist/update-XSIX3GGP.js +140 -0
  42. package/dist/update-check-5ZADDHCK.js +17 -0
  43. package/dist/{upgrade-6ZW2RD64.js → upgrade-YXKPWDRU.js} +16 -15
  44. package/dist/{variant-T64BKARF.js → variant-4Z6W3PP6.js} +15 -10
  45. package/dist/web-assets/assets/index-D5PzIndO.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 +1 -1
  51. package/templates/_base/.init/.config/hooks/startup-context.sh +19 -1
  52. package/templates/_base/.init/.config/scripts/session-reader.ts +59 -0
  53. package/templates/_base/_skills/sessions/SKILL.md +49 -0
  54. package/templates/_base/_skills/volute-agent/SKILL.md +114 -14
  55. package/templates/_base/home/.config/routes.json +10 -0
  56. package/templates/_base/home/VOLUTE.md +14 -35
  57. package/templates/_base/src/lib/format-prefix.ts +7 -1
  58. package/templates/_base/src/lib/router.ts +193 -19
  59. package/templates/_base/src/lib/routing.ts +55 -18
  60. package/templates/_base/src/lib/session-monitor.ts +400 -0
  61. package/templates/_base/src/lib/types.ts +5 -1
  62. package/templates/agent-sdk/.init/.config/routes.json +5 -0
  63. package/templates/agent-sdk/.init/CLAUDE.md +2 -2
  64. package/templates/agent-sdk/src/agent.ts +18 -1
  65. package/templates/agent-sdk/src/lib/hooks/session-context.ts +32 -0
  66. package/templates/agent-sdk/src/server.ts +8 -2
  67. package/templates/agent-sdk/volute-template.json +1 -1
  68. package/templates/pi/.init/.config/routes.json +5 -0
  69. package/templates/pi/.init/AGENTS.md +1 -1
  70. package/templates/pi/src/agent.ts +12 -4
  71. package/templates/pi/src/lib/session-context-extension.ts +33 -0
  72. package/templates/pi/src/server.ts +1 -1
  73. package/templates/pi/volute-template.json +1 -1
  74. package/dist/channel-DQ6UY7QB.js +0 -67
  75. package/dist/chunk-5OCWMTVS.js +0 -152
  76. package/dist/chunk-ZHCE4DPY.js +0 -110
  77. package/dist/create-ILVOG75A.js +0 -79
  78. package/dist/send-3U6OTKG7.js +0 -57
  79. package/dist/web-assets/assets/index-NS621maO.js +0 -296
  80. package/templates/agent-sdk/.init/.config/sessions.json +0 -4
  81. package/templates/pi/.init/.config/sessions.json +0 -1
  82. package/dist/{service-SA4TTMDU.js → service-HZNIDNJF.js} +3 -3
@@ -1,31 +1,52 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { log } from "./logger.js";
3
3
 
4
+ export type BatchConfig = {
5
+ debounce?: number; // seconds of quiet before flush
6
+ maxWait?: number; // max seconds before forced flush
7
+ triggers?: string[]; // patterns that cause immediate flush
8
+ };
9
+
4
10
  export type RoutingRule = {
5
11
  session?: string;
6
12
  destination?: "agent" | "file";
7
13
  path?: string; // file path for file destination
8
14
  interrupt?: boolean; // interrupt in-progress agent turn (default: true for agent)
9
- batch?: number; // minutes buffer messages, flush on timer
15
+ batch?: number | BatchConfig; // number = minutes (legacy), object = fine-grained control
10
16
  channel?: string;
11
17
  sender?: string;
18
+ isDM?: boolean; // match on isDM metadata
19
+ participants?: number; // match on participant count (e.g. 2 = DM)
12
20
  };
13
21
 
14
22
  export type RoutingConfig = {
15
23
  rules?: RoutingRule[];
16
24
  default?: string;
25
+ gateUnmatched?: boolean;
17
26
  };
18
27
 
19
28
  export type ResolvedRoute =
20
- | { destination: "agent"; session: string; interrupt: boolean; batch?: number }
21
- | { destination: "file"; path: string };
29
+ | {
30
+ destination: "agent";
31
+ session: string;
32
+ interrupt: boolean;
33
+ batch?: BatchConfig;
34
+ matched: boolean;
35
+ }
36
+ | { destination: "file"; path: string; matched: boolean };
37
+
38
+ /** Normalize batch config: number (minutes) → { maxWait } in seconds. */
39
+ export function normalizeBatch(batch: number | BatchConfig): BatchConfig {
40
+ if (typeof batch === "number") return { maxWait: batch * 60 };
41
+ return batch;
42
+ }
22
43
 
23
44
  export function loadRoutingConfig(configPath: string): RoutingConfig {
24
45
  try {
25
46
  return JSON.parse(readFileSync(configPath, "utf-8"));
26
47
  } catch (err: any) {
27
48
  if (err?.code !== "ENOENT") {
28
- log("sessions", `failed to load ${configPath}:`, err);
49
+ log("routing", `failed to load ${configPath}:`, err);
29
50
  }
30
51
  return {};
31
52
  }
@@ -41,21 +62,39 @@ function globMatch(pattern: string, value: string): boolean {
41
62
  return new RegExp(`^${regex}$`).test(value);
42
63
  }
43
64
 
44
- const MATCH_KEYS = new Set(["channel", "sender"]);
65
+ const GLOB_MATCH_KEYS = new Set(["channel", "sender"]);
45
66
  const NON_MATCH_KEYS = new Set(["session", "batch", "destination", "path", "interrupt"]);
46
67
 
47
- function ruleMatches(rule: RoutingRule, meta: { channel?: string; sender?: string }): boolean {
68
+ type MatchMeta = { channel?: string; sender?: string; isDM?: boolean; participantCount?: number };
69
+
70
+ function ruleMatches(rule: RoutingRule, meta: MatchMeta): boolean {
48
71
  for (const [key, pattern] of Object.entries(rule)) {
49
72
  if (NON_MATCH_KEYS.has(key)) continue;
73
+
74
+ // Boolean match: isDM
75
+ if (key === "isDM") {
76
+ if (typeof pattern !== "boolean") return false;
77
+ if ((meta.isDM ?? false) !== pattern) return false;
78
+ continue;
79
+ }
80
+
81
+ // Numeric match: participants
82
+ if (key === "participants") {
83
+ if (typeof pattern !== "number") return false;
84
+ if ((meta.participantCount ?? 0) !== pattern) return false;
85
+ continue;
86
+ }
87
+
88
+ // Glob string match: channel, sender
50
89
  if (typeof pattern !== "string") return false;
51
- if (!MATCH_KEYS.has(key)) return false;
52
- const value = meta[key as keyof typeof meta] ?? "";
90
+ if (!GLOB_MATCH_KEYS.has(key)) return false;
91
+ const value = meta[key as "channel" | "sender"] ?? "";
53
92
  if (!globMatch(pattern, value)) return false;
54
93
  }
55
94
  return true;
56
95
  }
57
96
 
58
- function expandTemplate(template: string, meta: { channel?: string; sender?: string }): string {
97
+ function expandTemplate(template: string, meta: MatchMeta): string {
59
98
  return template
60
99
  .replace(/\$\{sender\}/g, meta.sender ?? "unknown")
61
100
  .replace(/\$\{channel\}/g, meta.channel ?? "unknown");
@@ -64,35 +103,33 @@ function expandTemplate(template: string, meta: { channel?: string; sender?: str
64
103
  /**
65
104
  * Resolve the full route for a message: destination type, session/path, interrupt, batch.
66
105
  */
67
- export function resolveRoute(
68
- config: RoutingConfig,
69
- meta: { channel?: string; sender?: string },
70
- ): ResolvedRoute {
106
+ export function resolveRoute(config: RoutingConfig, meta: MatchMeta): ResolvedRoute {
71
107
  const fallback = config.default ?? "main";
72
108
 
73
109
  if (!config.rules) {
74
- return { destination: "agent", session: fallback, interrupt: true };
110
+ return { destination: "agent", session: fallback, interrupt: true, matched: false };
75
111
  }
76
112
 
77
113
  for (const rule of config.rules) {
78
114
  if (ruleMatches(rule, meta)) {
79
115
  if (rule.destination === "file") {
80
116
  if (!rule.path) {
81
- log("sessions", `file destination rule missing path — falling through`);
117
+ log("routing", `file destination rule missing path — falling through`);
82
118
  continue;
83
119
  }
84
- return { destination: "file", path: rule.path };
120
+ return { destination: "file", path: rule.path, matched: true };
85
121
  }
86
122
  return {
87
123
  destination: "agent",
88
124
  session: sanitizeSessionName(expandTemplate(rule.session ?? fallback, meta)),
89
125
  interrupt: rule.interrupt ?? true,
90
- batch: rule.batch,
126
+ batch: rule.batch != null ? normalizeBatch(rule.batch) : undefined,
127
+ matched: true,
91
128
  };
92
129
  }
93
130
  }
94
131
 
95
- return { destination: "agent", session: fallback, interrupt: true };
132
+ return { destination: "agent", session: fallback, interrupt: true, matched: false };
96
133
  }
97
134
 
98
135
  function sanitizeSessionName(name: string): string {
@@ -0,0 +1,400 @@
1
+ import {
2
+ closeSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ openSync,
6
+ readdirSync,
7
+ readFileSync,
8
+ readSync,
9
+ statSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { dirname, resolve } from "node:path";
13
+
14
+ // --- Types ---
15
+
16
+ type CursorState = Record<string, Record<string, { offset: number }>>;
17
+
18
+ type ParsedEntry = {
19
+ role: "user" | "assistant";
20
+ timestamp?: string;
21
+ text?: string;
22
+ toolUses?: { name: string; primaryArg?: string }[];
23
+ };
24
+
25
+ type SessionSummary = {
26
+ firstUserText: string;
27
+ toolCounts: { edits: number; reads: number; commands: number; other: number };
28
+ messageCount: number;
29
+ timeSpan: { first?: string; last?: string };
30
+ lastAssistantText?: string;
31
+ };
32
+
33
+ type Format = "agent-sdk" | "pi";
34
+
35
+ // --- Public API ---
36
+
37
+ export function getSessionUpdates(options: {
38
+ currentSession: string;
39
+ sessionsDir: string;
40
+ cursorFile: string;
41
+ jsonlResolver: (sessionName: string) => string | null;
42
+ format: Format;
43
+ }): string | null {
44
+ const sessionNames = listSessionNames(options.sessionsDir, options.format);
45
+ const others = sessionNames.filter((n) => n !== options.currentSession && !n.startsWith("new-"));
46
+ if (others.length === 0) return null;
47
+
48
+ const cursors = loadCursors(options.cursorFile);
49
+ const currentCursors = cursors[options.currentSession] ?? {};
50
+ const summaries: string[] = [];
51
+
52
+ for (const name of others) {
53
+ try {
54
+ const jsonlPath = options.jsonlResolver(name);
55
+ if (!jsonlPath || !existsSync(jsonlPath)) continue;
56
+
57
+ const stat = statSync(jsonlPath);
58
+ const prevOffset = currentCursors[name]?.offset ?? 0;
59
+ const fileSize = stat.size;
60
+
61
+ // Reset if offset past EOF (file was truncated/recreated)
62
+ const offset = prevOffset > fileSize ? 0 : prevOffset;
63
+ if (offset >= fileSize) {
64
+ currentCursors[name] = { offset: fileSize };
65
+ continue;
66
+ }
67
+
68
+ const newBytes = readBytesFrom(jsonlPath, offset, fileSize - offset);
69
+ const lines = newBytes.split("\n").filter((l) => l.trim());
70
+ const entries = parseJsonlEntries(lines, options.format);
71
+ const summary = summarizeEntries(entries);
72
+
73
+ currentCursors[name] = { offset: fileSize };
74
+
75
+ if (!summary) continue;
76
+
77
+ const ago = summary.timeSpan.last ? formatTimeAgo(summary.timeSpan.last) : "recently";
78
+ const parts = [`- ${name} (${ago}, ${summary.messageCount} messages)`];
79
+
80
+ if (summary.firstUserText) {
81
+ parts[0] += `: "${truncate(summary.firstUserText, 100)}"`;
82
+ }
83
+
84
+ const actions: string[] = [];
85
+ if (summary.toolCounts.edits > 0) actions.push(`edited ${summary.toolCounts.edits} files`);
86
+ if (summary.toolCounts.commands > 0)
87
+ actions.push(`ran ${summary.toolCounts.commands} commands`);
88
+ if (summary.toolCounts.reads > 0) actions.push(`read ${summary.toolCounts.reads} files`);
89
+ if (summary.toolCounts.other > 0) actions.push(`${summary.toolCounts.other} other tool uses`);
90
+ if (actions.length > 0) {
91
+ parts[0] += ` -> ${actions.join(", ")}`;
92
+ }
93
+
94
+ summaries.push(parts[0]);
95
+ } catch {}
96
+ }
97
+
98
+ cursors[options.currentSession] = currentCursors;
99
+ try {
100
+ saveCursors(options.cursorFile, cursors);
101
+ } catch {
102
+ // Non-fatal: worst case is duplicate summaries on next check
103
+ }
104
+
105
+ if (summaries.length === 0) return null;
106
+
107
+ // Cap total output at ~500 chars
108
+ let output = "[Session Activity]\n" + summaries.join("\n");
109
+ if (output.length > 500) {
110
+ output = output.slice(0, 497) + "...";
111
+ }
112
+ return output;
113
+ }
114
+
115
+ export function readSessionLog(options: {
116
+ jsonlPath: string;
117
+ format: Format;
118
+ lines?: number;
119
+ }): string {
120
+ const maxLines = options.lines ?? 50;
121
+ if (!existsSync(options.jsonlPath)) return "No session log found.";
122
+
123
+ const content = readFileSync(options.jsonlPath, "utf-8");
124
+ const allLines = content.split("\n").filter((l) => l.trim());
125
+ const lines = allLines.slice(-maxLines);
126
+ const entries = parseJsonlEntries(lines, options.format);
127
+
128
+ const output: string[] = [];
129
+ for (const entry of entries) {
130
+ const ts = entry.timestamp ? `[${formatTimestamp(entry.timestamp)}]` : "";
131
+ if (entry.role === "user" && entry.text) {
132
+ output.push(`${ts} User: ${entry.text}`);
133
+ } else if (entry.role === "assistant") {
134
+ if (entry.text) {
135
+ output.push(`${ts} Assistant: ${entry.text}`);
136
+ }
137
+ if (entry.toolUses) {
138
+ for (const tool of entry.toolUses) {
139
+ const arg = tool.primaryArg ? ` ${tool.primaryArg}` : "";
140
+ output.push(`${ts} [${tool.name}${arg}]`);
141
+ }
142
+ }
143
+ }
144
+ }
145
+
146
+ return output.length > 0 ? output.join("\n") : "No activity found.";
147
+ }
148
+
149
+ // --- JSONL Path Resolvers ---
150
+
151
+ export function resolveAgentSdkJsonl(
152
+ sessionsDir: string,
153
+ sessionName: string,
154
+ cwd: string,
155
+ ): string | null {
156
+ const sessionFile = resolve(sessionsDir, `${sessionName}.json`);
157
+ if (!existsSync(sessionFile)) return null;
158
+
159
+ try {
160
+ const data = JSON.parse(readFileSync(sessionFile, "utf-8"));
161
+ const sessionId = data.sessionId;
162
+ if (!sessionId) return null;
163
+
164
+ const encoded = encodeCwd(cwd);
165
+ const home = process.env.HOME || process.env.USERPROFILE || "";
166
+ return resolve(home, ".claude", "projects", encoded, `${sessionId}.jsonl`);
167
+ } catch {
168
+ return null;
169
+ }
170
+ }
171
+
172
+ export function encodeCwd(cwd: string): string {
173
+ return cwd.replace(/\//g, "-").replace(/\./g, "-");
174
+ }
175
+
176
+ export function resolvePiJsonl(sessionsDir: string, sessionName: string): string | null {
177
+ const sessionDir = resolve(sessionsDir, sessionName);
178
+ if (!existsSync(sessionDir)) return null;
179
+
180
+ try {
181
+ const files = readdirSync(sessionDir)
182
+ .filter((f) => f.endsWith(".jsonl"))
183
+ .map((f) => ({
184
+ name: f,
185
+ mtime: statSync(resolve(sessionDir, f)).mtimeMs,
186
+ }))
187
+ .sort((a, b) => b.mtime - a.mtime);
188
+
189
+ if (files.length === 0) return null;
190
+ return resolve(sessionDir, files[0].name);
191
+ } catch {
192
+ return null;
193
+ }
194
+ }
195
+
196
+ // --- Parsing ---
197
+
198
+ export function parseJsonlEntries(lines: string[], format: Format): ParsedEntry[] {
199
+ const entries: ParsedEntry[] = [];
200
+
201
+ for (const line of lines) {
202
+ let parsed: any;
203
+ try {
204
+ parsed = JSON.parse(line);
205
+ } catch {
206
+ continue;
207
+ }
208
+
209
+ if (format === "agent-sdk") {
210
+ if (parsed.type === "user" && parsed.message?.role === "user") {
211
+ const text = extractTextFromContent(parsed.message.content);
212
+ if (text) entries.push({ role: "user", timestamp: parsed.timestamp, text });
213
+ } else if (parsed.type === "assistant" && parsed.message?.role === "assistant") {
214
+ const text = extractTextFromContent(parsed.message.content);
215
+ const toolUses = extractToolUses(parsed.message.content, format);
216
+ if (text || toolUses.length > 0) {
217
+ entries.push({
218
+ role: "assistant",
219
+ timestamp: parsed.timestamp,
220
+ text: text || undefined,
221
+ toolUses,
222
+ });
223
+ }
224
+ }
225
+ } else {
226
+ // pi format
227
+ if (parsed.type === "message" && parsed.message?.role === "user") {
228
+ const text = extractTextFromContent(parsed.message.content);
229
+ if (text) entries.push({ role: "user", timestamp: parsed.timestamp, text });
230
+ } else if (parsed.type === "message" && parsed.message?.role === "assistant") {
231
+ const text = extractTextFromContent(parsed.message.content);
232
+ const toolUses = extractToolUses(parsed.message.content, format);
233
+ if (text || toolUses.length > 0) {
234
+ entries.push({
235
+ role: "assistant",
236
+ timestamp: parsed.timestamp,
237
+ text: text || undefined,
238
+ toolUses,
239
+ });
240
+ }
241
+ }
242
+ }
243
+ }
244
+
245
+ return entries;
246
+ }
247
+
248
+ export function summarizeEntries(entries: ParsedEntry[]): SessionSummary | null {
249
+ if (entries.length === 0) return null;
250
+
251
+ let firstUserText = "";
252
+ let lastAssistantText: string | undefined;
253
+ const toolCounts = { edits: 0, reads: 0, commands: 0, other: 0 };
254
+ let messageCount = 0;
255
+ const timestamps: string[] = [];
256
+
257
+ for (const entry of entries) {
258
+ messageCount++;
259
+ if (entry.timestamp) timestamps.push(entry.timestamp);
260
+
261
+ if (entry.role === "user" && entry.text && !firstUserText) {
262
+ firstUserText = entry.text;
263
+ }
264
+
265
+ if (entry.role === "assistant") {
266
+ if (entry.text) lastAssistantText = entry.text;
267
+ if (entry.toolUses) {
268
+ for (const tool of entry.toolUses) {
269
+ const cat = categorizeTool(tool.name);
270
+ toolCounts[cat]++;
271
+ }
272
+ }
273
+ }
274
+ }
275
+
276
+ return {
277
+ firstUserText,
278
+ toolCounts,
279
+ messageCount,
280
+ timeSpan: {
281
+ first: timestamps[0],
282
+ last: timestamps[timestamps.length - 1],
283
+ },
284
+ lastAssistantText,
285
+ };
286
+ }
287
+
288
+ // --- Helpers ---
289
+
290
+ function extractTextFromContent(content: any[]): string | null {
291
+ if (!Array.isArray(content)) return null;
292
+ const texts: string[] = [];
293
+ for (const part of content) {
294
+ if (part.type === "text" && part.text) {
295
+ texts.push(part.text);
296
+ }
297
+ }
298
+ return texts.length > 0 ? texts.join("\n") : null;
299
+ }
300
+
301
+ function extractToolUses(content: any[], format: Format): { name: string; primaryArg?: string }[] {
302
+ if (!Array.isArray(content)) return [];
303
+ const tools: { name: string; primaryArg?: string }[] = [];
304
+
305
+ for (const part of content) {
306
+ const isToolUse = format === "agent-sdk" ? part.type === "tool_use" : part.type === "toolCall";
307
+
308
+ if (isToolUse) {
309
+ const name = part.name || "unknown";
310
+ const input = format === "agent-sdk" ? part.input : part.arguments;
311
+ const primaryArg = extractPrimaryArg(name, input);
312
+ tools.push({ name, primaryArg });
313
+ }
314
+ }
315
+
316
+ return tools;
317
+ }
318
+
319
+ function extractPrimaryArg(_name: string, input: any): string | undefined {
320
+ if (!input || typeof input !== "object") return undefined;
321
+ // Common patterns for primary argument
322
+ return (
323
+ input.file_path || input.path || input.command || input.pattern || input.query || input.url
324
+ );
325
+ }
326
+
327
+ function categorizeTool(name: string): "edits" | "reads" | "commands" | "other" {
328
+ const lowerName = name.toLowerCase();
329
+ if (["edit", "write", "notebookedit"].includes(lowerName)) return "edits";
330
+ if (["read", "glob", "grep", "ls"].includes(lowerName)) return "reads";
331
+ if (["bash", "exec", "execute_shell_command"].includes(lowerName)) return "commands";
332
+ return "other";
333
+ }
334
+
335
+ function listSessionNames(sessionsDir: string, format: Format): string[] {
336
+ if (!existsSync(sessionsDir)) return [];
337
+ try {
338
+ const entries = readdirSync(sessionsDir);
339
+ if (format === "agent-sdk") {
340
+ return entries.filter((e) => e.endsWith(".json")).map((e) => e.replace(/\.json$/, ""));
341
+ }
342
+ // pi: subdirectories
343
+ return entries.filter((e) => {
344
+ try {
345
+ return statSync(resolve(sessionsDir, e)).isDirectory();
346
+ } catch {
347
+ return false;
348
+ }
349
+ });
350
+ } catch {
351
+ return [];
352
+ }
353
+ }
354
+
355
+ function loadCursors(cursorFile: string): CursorState {
356
+ try {
357
+ return JSON.parse(readFileSync(cursorFile, "utf-8"));
358
+ } catch {
359
+ return {};
360
+ }
361
+ }
362
+
363
+ function saveCursors(cursorFile: string, cursors: CursorState): void {
364
+ mkdirSync(dirname(cursorFile), { recursive: true });
365
+ writeFileSync(cursorFile, JSON.stringify(cursors, null, 2));
366
+ }
367
+
368
+ function readBytesFrom(filePath: string, offset: number, length: number): string {
369
+ const buf = Buffer.alloc(length);
370
+ const fd = openSync(filePath, "r");
371
+ try {
372
+ readSync(fd, buf, 0, length, offset);
373
+ } finally {
374
+ closeSync(fd);
375
+ }
376
+ return buf.toString("utf-8");
377
+ }
378
+
379
+ function truncate(s: string, max: number): string {
380
+ if (s.length <= max) return s;
381
+ return s.slice(0, max - 3) + "...";
382
+ }
383
+
384
+ function formatTimeAgo(timestamp: string): string {
385
+ const diff = Date.now() - new Date(timestamp).getTime();
386
+ if (isNaN(diff) || diff < 0) return "just now";
387
+ const minutes = Math.floor(diff / 60000);
388
+ if (minutes < 1) return "just now";
389
+ if (minutes < 60) return `${minutes}m ago`;
390
+ const hours = Math.floor(minutes / 60);
391
+ if (hours < 24) return `${hours}h ago`;
392
+ const days = Math.floor(hours / 24);
393
+ return `${days}d ago`;
394
+ }
395
+
396
+ function formatTimestamp(timestamp: string): string {
397
+ const d = new Date(timestamp);
398
+ if (isNaN(d.getTime())) return timestamp;
399
+ return d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
400
+ }
@@ -8,8 +8,11 @@ export type ChannelMeta = {
8
8
  platform?: string;
9
9
  isDM?: boolean;
10
10
  channelName?: string;
11
- guildName?: string;
11
+ serverName?: string;
12
12
  sessionName?: string;
13
+ participants?: string[];
14
+ participantCount?: number;
15
+ typing?: string[];
13
16
  };
14
17
 
15
18
  /** ChannelMeta enriched by the router with dispatch info. */
@@ -28,6 +31,7 @@ export type VoluteEvent = { messageId?: string } & (
28
31
  | { type: "image"; media_type: string; data: string }
29
32
  | { type: "tool_use"; name: string; input: unknown }
30
33
  | { type: "tool_result"; output: string; is_error?: boolean }
34
+ | { type: "usage"; input_tokens: number; output_tokens: number }
31
35
  | { type: "done" }
32
36
  );
33
37
 
@@ -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.
@@ -5,6 +5,7 @@ import { query } from "@anthropic-ai/claude-agent-sdk";
5
5
  import { createAutoCommitHook } from "./lib/hooks/auto-commit.js";
6
6
  import { createIdentityReloadHook } from "./lib/hooks/identity-reload.js";
7
7
  import { createPreCompactHook } from "./lib/hooks/pre-compact.js";
8
+ import { createSessionContextHook } from "./lib/hooks/session-context.js";
8
9
  import { log, logText, logThinking, logToolUse } from "./lib/logger.js";
9
10
  import { createMessageChannel } from "./lib/message-channel.js";
10
11
  import type {
@@ -69,9 +70,10 @@ export function createAgent(options: {
69
70
  ];
70
71
 
71
72
  const sessions = new Map<string, Session>();
73
+ const today = new Date().toISOString().slice(0, 10);
72
74
  const compactionMessage =
73
75
  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.";
76
+ `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
77
 
76
78
  // --- Session persistence ---
77
79
 
@@ -135,6 +137,12 @@ export function createAgent(options: {
135
137
  });
136
138
  });
137
139
 
140
+ const sessionContext = createSessionContextHook({
141
+ currentSession: session.name,
142
+ sessionsDir: options.sessionsDir,
143
+ cwd: options.cwd,
144
+ });
145
+
138
146
  return query({
139
147
  prompt: session.channel.iterable,
140
148
  options: {
@@ -149,6 +157,7 @@ export function createAgent(options: {
149
157
  hooks: {
150
158
  PostToolUse: postToolUseHooks,
151
159
  PreCompact: [{ hooks: [preCompact.hook] }],
160
+ UserPromptSubmit: [{ hooks: [sessionContext.hook] }],
152
161
  },
153
162
  },
154
163
  });
@@ -181,6 +190,14 @@ export function createAgent(options: {
181
190
  }
182
191
  if (msg.type === "result") {
183
192
  log("agent", `session "${session.name}": turn done`);
193
+ const result = msg as { usage?: { input_tokens?: number; output_tokens?: number } };
194
+ if (result.usage) {
195
+ broadcastToSession(session, {
196
+ type: "usage",
197
+ input_tokens: result.usage.input_tokens ?? 0,
198
+ output_tokens: result.usage.output_tokens ?? 0,
199
+ });
200
+ }
184
201
  broadcastToSession(session, { type: "done" });
185
202
  session.currentMessageId = undefined;
186
203
  if (identityReload.needsReload()) {
@@ -0,0 +1,32 @@
1
+ import { resolve } from "node:path";
2
+ import type { HookCallback } from "@anthropic-ai/claude-agent-sdk";
3
+ import { getSessionUpdates, resolveAgentSdkJsonl } from "../session-monitor.js";
4
+
5
+ export function createSessionContextHook(options: {
6
+ currentSession: string;
7
+ sessionsDir: string;
8
+ cwd: string;
9
+ }) {
10
+ const hook: HookCallback = async () => {
11
+ try {
12
+ const summary = getSessionUpdates({
13
+ currentSession: options.currentSession,
14
+ sessionsDir: options.sessionsDir,
15
+ cursorFile: resolve(options.sessionsDir, "../session-cursors.json"),
16
+ jsonlResolver: (name) => resolveAgentSdkJsonl(options.sessionsDir, name, options.cwd),
17
+ format: "agent-sdk",
18
+ });
19
+ if (!summary) return {};
20
+ return {
21
+ hookSpecificOutput: {
22
+ hookEventName: "UserPromptSubmit" as const,
23
+ additionalContext: summary,
24
+ },
25
+ };
26
+ } catch {
27
+ return {};
28
+ }
29
+ };
30
+
31
+ return { hook };
32
+ }