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.
- package/README.md +22 -22
- package/dist/agent-X7GJLBLW.js +79 -0
- package/dist/{agent-manager-AUCKMGPR.js → agent-manager-JDVXU3ON.js} +4 -4
- package/dist/channel-SMCNOIVQ.js +262 -0
- package/dist/chunk-AOKAQGO4.js +107 -0
- package/dist/{chunk-VRVVQIYY.js → chunk-AZEL2IEK.js} +1 -1
- package/dist/chunk-B3R6L2GW.js +24 -0
- package/dist/{chunk-MXUCNIBG.js → chunk-BX7KI4S3.js} +68 -3
- package/dist/{chunk-I6OHXCMV.js → chunk-G6ZNGLUX.js} +47 -9
- package/dist/{chunk-DNOXHLE5.js → chunk-H7AMDUIA.js} +1 -1
- package/dist/{chunk-YGFIWIOF.js → chunk-JR4UXCTO.js} +1 -1
- package/dist/{chunk-3C2XR4IY.js → chunk-UWHWAPGO.js} +120 -107
- package/dist/{chunk-SOZA2TLP.js → chunk-W76KWE23.js} +1 -1
- package/dist/{chunk-GSPKUPKU.js → chunk-XUA3JUFK.js} +2 -1
- package/dist/chunk-ZYGKG6VC.js +22 -0
- package/dist/chunk-ZZOOTYXK.js +583 -0
- package/dist/cli.js +83 -74
- package/dist/{connector-DKDJTLYZ.js → connector-Y7JPNROO.js} +11 -6
- package/dist/connectors/discord.js +34 -5
- package/dist/connectors/slack.js +36 -8
- package/dist/connectors/telegram.js +55 -6
- package/dist/create-G525LWEA.js +91 -0
- package/dist/{daemon-client-XR24PUJF.js → daemon-client-442IV43D.js} +2 -2
- package/dist/daemon.js +1273 -384
- package/dist/{delete-55MXCEY5.js → delete-2PH2CGDY.js} +7 -8
- package/dist/{down-3OB6UVAJ.js → down-FXWAN66A.js} +1 -1
- package/dist/{env-JB27UAC3.js → env-7GLUJCWS.js} +8 -5
- package/dist/{history-BKG74I43.js → history-H72ZUIBN.js} +3 -3
- package/dist/{import-4CI2ZUTJ.js → import-AVKQJDYC.js} +8 -8
- package/dist/{logs-NXFFGUKY.js → logs-EDGK26AK.js} +2 -2
- package/dist/message-SCOQDR3P.js +32 -0
- package/dist/{package-Z2SFO2SV.js → package-4DP4Y4UO.js} +1 -1
- package/dist/restart-O4ETYLJF.js +29 -0
- package/dist/{schedule-A35SH4HT.js → schedule-S6QVC5ON.js} +10 -5
- package/dist/send-G7PE4DOJ.js +72 -0
- package/dist/{setup-2FDVN7OF.js → setup-F4TCWVSP.js} +5 -5
- package/dist/{start-LDPMCMYT.js → start-VHQ7LNWM.js} +3 -3
- package/dist/{status-MVSQG54T.js → status-QAJWXKMZ.js} +3 -3
- package/dist/{stop-5PZTZCLL.js → stop-CAGCT5NI.js} +6 -7
- package/dist/{up-F7TMTLRE.js → up-CSX3ZUIU.js} +16 -4
- package/dist/update-XSIX3GGP.js +140 -0
- package/dist/update-check-5ZADDHCK.js +17 -0
- package/dist/{upgrade-6ZW2RD64.js → upgrade-YXKPWDRU.js} +16 -15
- package/dist/{variant-T64BKARF.js → variant-4Z6W3PP6.js} +15 -10
- package/dist/web-assets/assets/index-D5PzIndO.js +308 -0
- package/dist/web-assets/index.html +2 -2
- package/drizzle/0003_clean_ego.sql +12 -0
- package/drizzle/meta/0003_snapshot.json +417 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/templates/_base/.init/.config/hooks/startup-context.sh +19 -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 +114 -14
- package/templates/_base/home/.config/routes.json +10 -0
- package/templates/_base/home/VOLUTE.md +14 -35
- package/templates/_base/src/lib/format-prefix.ts +7 -1
- package/templates/_base/src/lib/router.ts +193 -19
- package/templates/_base/src/lib/routing.ts +55 -18
- package/templates/_base/src/lib/session-monitor.ts +400 -0
- package/templates/_base/src/lib/types.ts +5 -1
- package/templates/agent-sdk/.init/.config/routes.json +5 -0
- package/templates/agent-sdk/.init/CLAUDE.md +2 -2
- package/templates/agent-sdk/src/agent.ts +18 -1
- package/templates/agent-sdk/src/lib/hooks/session-context.ts +32 -0
- package/templates/agent-sdk/src/server.ts +8 -2
- package/templates/agent-sdk/volute-template.json +1 -1
- package/templates/pi/.init/.config/routes.json +5 -0
- package/templates/pi/.init/AGENTS.md +1 -1
- package/templates/pi/src/agent.ts +12 -4
- package/templates/pi/src/lib/session-context-extension.ts +33 -0
- package/templates/pi/src/server.ts +1 -1
- package/templates/pi/volute-template.json +1 -1
- package/dist/channel-DQ6UY7QB.js +0 -67
- package/dist/chunk-5OCWMTVS.js +0 -152
- package/dist/chunk-ZHCE4DPY.js +0 -110
- package/dist/create-ILVOG75A.js +0 -79
- package/dist/send-3U6OTKG7.js +0 -57
- package/dist/web-assets/assets/index-NS621maO.js +0 -296
- package/templates/agent-sdk/.init/.config/sessions.json +0 -4
- package/templates/pi/.init/.config/sessions.json +0 -1
- 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; //
|
|
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
|
-
| {
|
|
21
|
-
|
|
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("
|
|
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
|
|
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
|
-
|
|
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 (!
|
|
52
|
-
const value = meta[key as
|
|
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:
|
|
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("
|
|
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
|
-
|
|
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
|
|
|
@@ -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/`
|
|
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/
|
|
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
|
-
|
|
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
|
+
}
|