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.
- package/dist/{agent-Z2B6EFEQ.js → agent-7JF7MT73.js} +13 -9
- package/dist/{agent-manager-PXBKA2GK.js → agent-manager-IMZ7ZMBF.js} +4 -4
- package/dist/channel-SMCNOIVQ.js +262 -0
- package/dist/{chunk-MW2KFO3B.js → chunk-62X577Y7.js} +10 -8
- package/dist/chunk-7ACDT3P2.js +265 -0
- package/dist/{chunk-MXUCNIBG.js → chunk-BX7KI4S3.js} +68 -3
- package/dist/{up-7ILD7GU7.js → chunk-EG45HBSJ.js} +16 -4
- package/dist/{chunk-HE67X4T6.js → chunk-H7AMDUIA.js} +1 -1
- package/dist/{chunk-7L4AN5D4.js → chunk-JR4UXCTO.js} +1 -1
- package/dist/{down-O7IFZLVJ.js → chunk-LLJNZPCU.js} +48 -13
- package/dist/{chunk-5X7HGB6L.js → chunk-NKXULRSW.js} +2 -1
- package/dist/{chunk-UX25Z2ND.js → chunk-UWHWAPGO.js} +7 -0
- package/dist/{chunk-UAVD2AHX.js → chunk-W76KWE23.js} +1 -1
- package/dist/chunk-ZZOOTYXK.js +583 -0
- package/dist/cli.js +22 -21
- package/dist/{connector-LYEMXQEV.js → connector-Y7JPNROO.js} +3 -3
- package/dist/connectors/discord.js +38 -7
- package/dist/connectors/slack.js +22 -3
- package/dist/connectors/telegram.js +34 -4
- package/dist/{create-RVCZN6HE.js → create-G525LWEA.js} +2 -2
- package/dist/{daemon-client-ZY6UUN2M.js → daemon-client-442IV43D.js} +2 -2
- package/dist/daemon-restart-4HVEKYFY.js +23 -0
- package/dist/daemon.js +1042 -809
- package/dist/{delete-3QH7VYIN.js → delete-UOU4AFQN.js} +7 -3
- package/dist/down-AZVH5TCD.js +11 -0
- package/dist/{env-4D4REPJF.js → env-7GLUJCWS.js} +2 -2
- package/dist/{history-OEONB53Z.js → history-H72ZUIBN.js} +2 -2
- package/dist/{import-MXJB2EII.js → import-AVKQJDYC.js} +2 -2
- package/dist/{logs-DF342W4M.js → logs-EDGK26AK.js} +1 -1
- package/dist/{message-ADHWFHSI.js → message-SCOQDR3P.js} +2 -2
- package/dist/{package-VQOE7JNH.js → package-T2WAVJOU.js} +1 -1
- package/dist/restart-O4ETYLJF.js +29 -0
- package/dist/{schedule-NAG6F463.js → schedule-S6QVC5ON.js} +2 -2
- package/dist/send-G7PE4DOJ.js +72 -0
- package/dist/{setup-RPRRGG2F.js → setup-F4TCWVSP.js} +2 -2
- package/dist/{start-TUOXDSFL.js → start-VHQ7LNWM.js} +2 -2
- package/dist/{status-A36EHRO4.js → status-QAJWXKMZ.js} +2 -2
- package/dist/{stop-AOJZLQ5X.js → stop-CAGCT5NI.js} +2 -2
- package/dist/up-RWZF6MLT.js +12 -0
- package/dist/{update-LPSIAWQ2.js → update-F7QWV2LB.js} +2 -2
- package/dist/{update-check-Y33QDCFL.js → update-check-B4J6IEQ4.js} +2 -2
- package/dist/{upgrade-FX2TKJ2S.js → upgrade-YXKPWDRU.js} +2 -2
- package/dist/{variant-LAB67OC2.js → variant-4Z6W3PP6.js} +2 -2
- package/dist/web-assets/assets/index-B1CqjUYD.js +308 -0
- package/dist/web-assets/index.html +1 -1
- package/package.json +1 -1
- package/templates/_base/.init/.config/scripts/session-reader.ts +59 -0
- package/templates/_base/_skills/sessions/SKILL.md +49 -0
- package/templates/_base/_skills/volute-agent/SKILL.md +13 -9
- package/templates/_base/src/lib/format-prefix.ts +6 -0
- package/templates/_base/src/lib/router.ts +30 -3
- package/templates/_base/src/lib/session-monitor.ts +400 -0
- package/templates/_base/src/lib/types.ts +2 -0
- package/templates/agent-sdk/src/agent.ts +16 -0
- package/templates/agent-sdk/src/lib/hooks/session-context.ts +32 -0
- package/templates/pi/src/agent.ts +7 -1
- package/templates/pi/src/lib/session-context-extension.ts +33 -0
- package/dist/channel-MK5OK2SI.js +0 -113
- package/dist/chunk-SMISE4SV.js +0 -226
- package/dist/conversation-ERXEQZTY.js +0 -163
- package/dist/send-66QMKRUH.js +0 -75
- 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-
|
|
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
|
@@ -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
|
|
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
|
-
|
|
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]
|
|
180
|
-
volute channel send <uri> "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
|
|
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
|
-
|
|
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
|
|