volute 0.5.0 → 0.7.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 (62) hide show
  1. package/dist/{agent-Z2B6EFEQ.js → agent-7JF7MT73.js} +13 -9
  2. package/dist/{agent-manager-PXBKA2GK.js → agent-manager-IMZ7ZMBF.js} +4 -4
  3. package/dist/channel-SMCNOIVQ.js +262 -0
  4. package/dist/{chunk-MW2KFO3B.js → chunk-62X577Y7.js} +10 -8
  5. package/dist/chunk-7ACDT3P2.js +265 -0
  6. package/dist/{chunk-MXUCNIBG.js → chunk-BX7KI4S3.js} +68 -3
  7. package/dist/{up-7ILD7GU7.js → chunk-EG45HBSJ.js} +16 -4
  8. package/dist/{chunk-HE67X4T6.js → chunk-H7AMDUIA.js} +1 -1
  9. package/dist/{chunk-7L4AN5D4.js → chunk-JR4UXCTO.js} +1 -1
  10. package/dist/{down-O7IFZLVJ.js → chunk-LLJNZPCU.js} +48 -13
  11. package/dist/{chunk-5X7HGB6L.js → chunk-NKXULRSW.js} +2 -1
  12. package/dist/{chunk-UX25Z2ND.js → chunk-UWHWAPGO.js} +7 -0
  13. package/dist/{chunk-UAVD2AHX.js → chunk-W76KWE23.js} +1 -1
  14. package/dist/chunk-ZZOOTYXK.js +583 -0
  15. package/dist/cli.js +22 -21
  16. package/dist/{connector-LYEMXQEV.js → connector-Y7JPNROO.js} +3 -3
  17. package/dist/connectors/discord.js +38 -7
  18. package/dist/connectors/slack.js +22 -3
  19. package/dist/connectors/telegram.js +34 -4
  20. package/dist/{create-RVCZN6HE.js → create-G525LWEA.js} +2 -2
  21. package/dist/{daemon-client-ZY6UUN2M.js → daemon-client-442IV43D.js} +2 -2
  22. package/dist/daemon-restart-4HVEKYFY.js +23 -0
  23. package/dist/daemon.js +1042 -809
  24. package/dist/{delete-3QH7VYIN.js → delete-UOU4AFQN.js} +7 -3
  25. package/dist/down-AZVH5TCD.js +11 -0
  26. package/dist/{env-4D4REPJF.js → env-7GLUJCWS.js} +2 -2
  27. package/dist/{history-OEONB53Z.js → history-H72ZUIBN.js} +2 -2
  28. package/dist/{import-MXJB2EII.js → import-AVKQJDYC.js} +2 -2
  29. package/dist/{logs-DF342W4M.js → logs-EDGK26AK.js} +1 -1
  30. package/dist/{message-ADHWFHSI.js → message-SCOQDR3P.js} +2 -2
  31. package/dist/{package-VQOE7JNH.js → package-T2WAVJOU.js} +1 -1
  32. package/dist/restart-O4ETYLJF.js +29 -0
  33. package/dist/{schedule-NAG6F463.js → schedule-S6QVC5ON.js} +2 -2
  34. package/dist/send-G7PE4DOJ.js +72 -0
  35. package/dist/{setup-RPRRGG2F.js → setup-F4TCWVSP.js} +2 -2
  36. package/dist/{start-TUOXDSFL.js → start-VHQ7LNWM.js} +2 -2
  37. package/dist/{status-A36EHRO4.js → status-QAJWXKMZ.js} +2 -2
  38. package/dist/{stop-AOJZLQ5X.js → stop-CAGCT5NI.js} +2 -2
  39. package/dist/up-RWZF6MLT.js +12 -0
  40. package/dist/{update-LPSIAWQ2.js → update-F7QWV2LB.js} +2 -2
  41. package/dist/{update-check-Y33QDCFL.js → update-check-B4J6IEQ4.js} +2 -2
  42. package/dist/{upgrade-FX2TKJ2S.js → upgrade-YXKPWDRU.js} +2 -2
  43. package/dist/{variant-LAB67OC2.js → variant-4Z6W3PP6.js} +2 -2
  44. package/dist/web-assets/assets/index-B1CqjUYD.js +308 -0
  45. package/dist/web-assets/index.html +1 -1
  46. package/package.json +1 -1
  47. package/templates/_base/.init/.config/scripts/session-reader.ts +59 -0
  48. package/templates/_base/_skills/sessions/SKILL.md +49 -0
  49. package/templates/_base/_skills/volute-agent/SKILL.md +13 -9
  50. package/templates/_base/src/lib/format-prefix.ts +6 -0
  51. package/templates/_base/src/lib/router.ts +30 -3
  52. package/templates/_base/src/lib/session-monitor.ts +400 -0
  53. package/templates/_base/src/lib/types.ts +2 -0
  54. package/templates/agent-sdk/src/agent.ts +16 -0
  55. package/templates/agent-sdk/src/lib/hooks/session-context.ts +32 -0
  56. package/templates/pi/src/agent.ts +7 -1
  57. package/templates/pi/src/lib/session-context-extension.ts +33 -0
  58. package/dist/channel-MK5OK2SI.js +0 -113
  59. package/dist/chunk-SMISE4SV.js +0 -226
  60. package/dist/conversation-ERXEQZTY.js +0 -163
  61. package/dist/send-66QMKRUH.js +0 -75
  62. package/dist/web-assets/assets/index-BbRmoxoA.js +0 -308
@@ -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&display=swap" rel="stylesheet" />
10
- <script type="module" crossorigin src="/assets/index-BbRmoxoA.js"></script>
10
+ <script type="module" crossorigin src="/assets/index-B1CqjUYD.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.5.0",
3
+ "version": "0.7.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",
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * Session reader — displays a human-readable log of another session's activity.
4
+ *
5
+ * Usage: npx tsx .config/scripts/session-reader.ts <session-name> [--lines N]
6
+ *
7
+ * Runs from the agent's home/ directory.
8
+ */
9
+ import { existsSync } from "node:fs";
10
+ import { resolve } from "node:path";
11
+ import {
12
+ readSessionLog,
13
+ resolveAgentSdkJsonl,
14
+ resolvePiJsonl,
15
+ } from "../../src/lib/session-monitor.js";
16
+
17
+ const args = process.argv.slice(2);
18
+ let sessionName: string | undefined;
19
+ let lines = 50;
20
+
21
+ for (let i = 0; i < args.length; i++) {
22
+ if (args[i] === "--lines" && args[i + 1]) {
23
+ lines = parseInt(args[++i], 10);
24
+ } else if (!sessionName && !args[i].startsWith("-")) {
25
+ sessionName = args[i];
26
+ }
27
+ }
28
+
29
+ if (!sessionName) {
30
+ console.error("Usage: npx tsx .config/scripts/session-reader.ts <session-name> [--lines N]");
31
+ process.exit(1);
32
+ }
33
+
34
+ // Detect template type and resolve JSONL path
35
+ const cwd = process.cwd();
36
+ const agentSdkSessions = resolve(cwd, "../.volute/sessions");
37
+ const piSessions = resolve(cwd, "../.volute/pi-sessions");
38
+
39
+ let jsonlPath: string | null = null;
40
+ let format: "agent-sdk" | "pi";
41
+
42
+ if (existsSync(agentSdkSessions)) {
43
+ format = "agent-sdk";
44
+ jsonlPath = resolveAgentSdkJsonl(agentSdkSessions, sessionName, cwd);
45
+ } else if (existsSync(piSessions)) {
46
+ format = "pi";
47
+ jsonlPath = resolvePiJsonl(piSessions, sessionName);
48
+ } else {
49
+ console.error("No session directory found. Expected .volute/sessions/ or .volute/pi-sessions/");
50
+ process.exit(1);
51
+ }
52
+
53
+ if (!jsonlPath || !existsSync(jsonlPath)) {
54
+ console.error(`No session log found for "${sessionName}".`);
55
+ process.exit(1);
56
+ }
57
+
58
+ const output = readSessionLog({ jsonlPath, format, lines });
59
+ console.log(output);
@@ -0,0 +1,49 @@
1
+ ---
2
+ name: Sessions
3
+ description: This skill should be used when checking activity in other sessions, reading session logs, understanding cross-session context, or investigating what happened in another session. Covers "session activity", "other sessions", "session reader", "session log", "cross-session", "what happened in discord", "check session".
4
+ ---
5
+
6
+ # Cross-Session Awareness
7
+
8
+ You can have multiple concurrent sessions (main, discord, email, etc.), each with its own conversation history stored as a JSONL file.
9
+
10
+ ## Automatic Updates
11
+
12
+ When a message arrives, you automatically receive a brief summary of new activity in other sessions (if any). This appears as a `[Session Activity]` block showing what happened since your last check.
13
+
14
+ ## Listing Sessions
15
+
16
+ To see which sessions are active:
17
+
18
+ ```sh
19
+ ls ../.volute/sessions/ # agent-sdk template
20
+ ls ../.volute/pi-sessions/ # pi template
21
+ ```
22
+
23
+ ## Reading a Session Log
24
+
25
+ For a detailed view of what happened in another session, run the session reader script:
26
+
27
+ ```sh
28
+ npx tsx .config/scripts/session-reader.ts <session-name> [--lines N]
29
+ ```
30
+
31
+ - `session-name`: The session to inspect (e.g., `discord`, `main`, `email`)
32
+ - `--lines N`: Number of recent entries to show (default: 50)
33
+
34
+ ### Output Format
35
+
36
+ The reader shows a chronological log with:
37
+ - **User messages**: Full text of what was sent
38
+ - **Assistant text**: Full text of your responses
39
+ - **Tool uses**: `[ToolName primary-arg]` format (e.g., `[Edit home/MEMORY.md]`, `[Bash npm test]`)
40
+ - **Timestamps** on each entry
41
+
42
+ Thinking blocks, tool result content, and metadata entries are omitted for readability.
43
+
44
+ ## When to Use This
45
+
46
+ - When you receive a `[Session Activity]` summary and want more detail
47
+ - When you want to understand what happened in another session before responding
48
+ - When coordinating work across multiple sessions
49
+ - When a user references something from a different channel
@@ -26,12 +26,12 @@ You manage yourself through the `volute` CLI. Your agent name is auto-detected v
26
26
  | `volute connector disconnect <type>` | Disable a connector |
27
27
  | `volute channel read <platform>:<id> [--limit N]` | Read channel history |
28
28
  | `volute channel send <platform>:<id> "msg"` | Send a message proactively (or pipe via stdin) |
29
+ | `volute channel list [<platform>]` | List conversations on a platform (or all platforms) |
30
+ | `volute channel users <platform>` | List users/contacts on a platform |
31
+ | `volute channel create <platform> --participants u1,u2 [--name "..."]` | Create a conversation on a platform |
29
32
  | `volute schedule add --cron "..." --message "..."` | Schedule a recurring message to yourself |
30
33
  | `volute schedule list` | List your schedules |
31
34
  | `volute schedule remove --id <id>` | Remove a schedule |
32
- | `volute conversation create --participants u1,a1` | Create a group conversation |
33
- | `volute conversation list` | List your conversations |
34
- | `volute conversation send <id> "msg"` | Send a message to a conversation (or pipe via stdin) |
35
35
 
36
36
  ## Schedules
37
37
 
@@ -49,19 +49,20 @@ All send commands accept the message from stdin instead of as an argument. This
49
49
  ```sh
50
50
  echo "Hello, how's it going?" | volute message send other-agent
51
51
  echo "Check out this $variable" | volute channel send discord:123456
52
- echo "Update on the task" | volute conversation send conv-abc
53
52
  ```
54
53
 
55
54
  If both a positional argument and stdin are provided, the argument takes precedence. Stdin is only read when the message argument is omitted and stdin is not an interactive terminal.
56
55
 
57
56
  ## Agent-to-Agent Messaging
58
57
 
59
- When you use `volute message send`, your agent name is automatically used as the sender and the channel is set to `agent`. The receiving agent can route agent messages to a specific session via their session routing config:
58
+ When you use `volute message send`, your agent name is automatically used as the sender. Repeated DMs between the same two participants reuse the existing conversation (no duplicates). The receiving agent can route agent messages to a specific session via their session routing config:
60
59
 
61
60
  ```json
62
61
  { "channel": "agent", "sender": "your-name", "session": "your-name" }
63
62
  ```
64
63
 
64
+ For group conversations, use `volute channel create volute --participants agent-b,agent-c --name "Planning"` and then send messages with `volute channel send volute:<id> "msg"`.
65
+
65
66
  ## Configuration
66
67
 
67
68
  Your `.config/volute.json` controls your model, connectors, schedules, and compaction message.
@@ -173,14 +174,17 @@ When `gateUnmatched` is `true` (the default), messages from channels without a m
173
174
 
174
175
  ## Channel Commands
175
176
 
176
- Read and send messages to any connected channel:
177
+ Channels are the universal interface for reading, sending, listing, and creating conversations across all platforms:
177
178
 
178
179
  ```sh
179
- volute channel read <uri> [--limit N] # Read recent messages
180
- volute channel send <uri> "message" # Send a message
180
+ volute channel read <uri> [--limit N] # Read recent messages
181
+ volute channel send <uri> "message" # Send a message
182
+ volute channel list [<platform>] # List conversations
183
+ volute channel users <platform> # List users/contacts
184
+ volute channel create <platform> --participants u1,u2 [--name ""] # Create a conversation
181
185
  ```
182
186
 
183
- Channel URIs use `platform:id` format (e.g. `discord:123456`, `volute:conv-abc`).
187
+ Channel URIs use `platform:id` format (e.g. `discord:123456`, `volute:conv-abc`, `slack:C01234`). Supported platforms: `volute`, `discord`, `slack`, `telegram`.
184
188
 
185
189
  ## Git Introspection
186
190
 
@@ -22,3 +22,9 @@ export function formatPrefix(meta: ChannelMeta | undefined, time: string): strin
22
22
  meta.sessionName && meta.sessionName !== "main" ? ` — session: ${meta.sessionName}` : "";
23
23
  return parts.length > 0 ? `[${parts.join(": ")}${sessionPart} — ${time}]\n` : "";
24
24
  }
25
+
26
+ export function formatTypingSuffix(typing: string[] | undefined): string {
27
+ if (!typing || typing.length === 0) return "";
28
+ if (typing.length === 1) return `\n[${typing[0]} is typing]`;
29
+ return `\n[${typing.join(", ")} are typing]`;
30
+ }
@@ -1,4 +1,4 @@
1
- import { formatPrefix } from "./format-prefix.js";
1
+ import { formatPrefix, formatTypingSuffix } from "./format-prefix.js";
2
2
  import { log, logMessage } from "./logger.js";
3
3
  import { type BatchConfig, loadRoutingConfig, resolveRoute } from "./routing.js";
4
4
  import type { ChannelMeta, HandlerResolver, Listener, VoluteContentPart } from "./types.js";
@@ -19,6 +19,7 @@ type BufferedMessage = {
19
19
  channelName?: string;
20
20
  serverName?: string;
21
21
  timestamp: string;
22
+ typing?: string[];
22
23
  };
23
24
 
24
25
  type BatchBuffer = {
@@ -51,6 +52,26 @@ function applyPrefix(content: VoluteContentPart[], meta: ChannelMeta): VoluteCon
51
52
  });
52
53
  }
53
54
 
55
+ function appendTypingSuffix(
56
+ content: VoluteContentPart[],
57
+ typing: string[] | undefined,
58
+ ): VoluteContentPart[] {
59
+ const suffix = formatTypingSuffix(typing);
60
+ if (!suffix) return content;
61
+ let lastTextIdx = -1;
62
+ for (let i = content.length - 1; i >= 0; i--) {
63
+ if (content[i].type === "text") {
64
+ lastTextIdx = i;
65
+ break;
66
+ }
67
+ }
68
+ if (lastTextIdx === -1) return [...content, { type: "text", text: suffix.trimStart() }];
69
+ return content.map((part, i) => {
70
+ if (i === lastTextIdx) return { type: "text", text: (part as { text: string }).text + suffix };
71
+ return part;
72
+ });
73
+ }
74
+
54
75
  function sanitizeChannelPath(channel: string): string {
55
76
  return channel
56
77
  .replace(/[/\\:]/g, "-")
@@ -152,7 +173,11 @@ export function createRouter(options: {
152
173
  })
153
174
  .join("\n\n");
154
175
 
155
- const content: VoluteContentPart[] = [{ type: "text", text: `${header}\n\n${body}` }];
176
+ const lastTyping = messages[messages.length - 1]?.typing;
177
+ const typingSuffix = formatTypingSuffix(lastTyping);
178
+ const content: VoluteContentPart[] = [
179
+ { type: "text", text: `${header}\n\n${body}${typingSuffix}` },
180
+ ];
156
181
  const messageId = generateMessageId();
157
182
  const handler = options.agentHandler(buffer.sessionName);
158
183
 
@@ -290,6 +315,7 @@ export function createRouter(options: {
290
315
  hour: "numeric",
291
316
  minute: "2-digit",
292
317
  }),
318
+ typing: meta.typing,
293
319
  });
294
320
 
295
321
  // Check triggers — flush immediately if matched
@@ -305,9 +331,10 @@ export function createRouter(options: {
305
331
 
306
332
  // Direct dispatch to agent
307
333
  const formatted = applyPrefix(content, { ...meta, sessionName });
334
+ const withTyping = appendTypingSuffix(formatted, meta.typing);
308
335
  const handler = options.agentHandler(sessionName);
309
336
  const unsubscribe = handler.handle(
310
- formatted,
337
+ withTyping,
311
338
  { ...meta, sessionName, messageId, interrupt: resolved.interrupt },
312
339
  safeListener,
313
340
  );
@@ -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
+ }
@@ -12,6 +12,7 @@ export type ChannelMeta = {
12
12
  sessionName?: string;
13
13
  participants?: string[];
14
14
  participantCount?: number;
15
+ typing?: string[];
15
16
  };
16
17
 
17
18
  /** ChannelMeta enriched by the router with dispatch info. */
@@ -30,6 +31,7 @@ export type VoluteEvent = { messageId?: string } & (
30
31
  | { type: "image"; media_type: string; data: string }
31
32
  | { type: "tool_use"; name: string; input: unknown }
32
33
  | { type: "tool_result"; output: string; is_error?: boolean }
34
+ | { type: "usage"; input_tokens: number; output_tokens: number }
33
35
  | { type: "done" }
34
36
  );
35
37