volute 0.1.0 → 0.2.1
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 +1 -2
- package/dist/agent-manager-SSJUZWOV.js +13 -0
- package/dist/{channel-Q642YUZE.js → channel-2WJRM7PE.js} +2 -2
- package/dist/{chunk-H5XQARAP.js → chunk-4YXYAMFT.js} +3 -3
- package/dist/{chunk-5YW4B7CG.js → chunk-6UCG6MIX.js} +72 -23
- package/dist/{chunk-A5ZJEMHT.js → chunk-KFNNHQK7.js} +4 -4
- package/dist/chunk-L3BQEZ4Z.js +271 -0
- package/dist/{chunk-N4QN44LC.js → chunk-MY74SUOL.js} +29 -22
- package/dist/{chunk-KSMIWOCN.js → chunk-N4YNKR3Q.js} +6 -0
- package/dist/cli.js +23 -19
- package/dist/{connect-LW6G23AV.js → connect-X5V5IMRW.js} +3 -3
- package/dist/connectors/discord.js +9 -2
- package/dist/{create-3K6O2SDC.js → create-23AM7H5B.js} +1 -1
- package/dist/{daemon-client-ZTHW7ROS.js → daemon-client-VN24HM5T.js} +2 -2
- package/dist/daemon.js +394 -436
- package/dist/{delete-JNGY7ZFH.js → delete-GDMSOW3U.js} +2 -2
- package/dist/{disconnect-ACVTKTRE.js → disconnect-5JWFZ6RV.js} +2 -2
- package/dist/{down-FYCUYC5H.js → down-WTF73FE7.js} +5 -4
- package/dist/{env-7SLRN3MG.js → env-YKUJOFHE.js} +12 -5
- package/dist/{fork-BB3DZ426.js → fork-GRSVMBKI.js} +39 -32
- package/dist/history-7WVVKMUY.js +46 -0
- package/dist/{import-W2AMTEV5.js → import-42DOLBDT.js} +1 -1
- package/dist/{logs-BUHRIQ2L.js → logs-SYRQOL6B.js} +1 -1
- package/dist/{merge-446QTE7Q.js → merge-CSAVLSLY.js} +33 -36
- package/dist/{schedule-KKSOVUDF.js → schedule-J37XQM6E.js} +2 -2
- package/dist/{send-WQSVSRDD.js → send-PLOYEYER.js} +7 -5
- package/dist/{start-LKMWS6ZE.js → start-AG7QLULK.js} +2 -2
- package/dist/{status-CIEKUI3V.js → status-GCNU4M3K.js} +9 -2
- package/dist/{stop-YTOAGYE4.js → stop-IL5Q6NER.js} +2 -2
- package/dist/{up-AJJ4GCXY.js → up-ZC6G6K4K.js} +21 -37
- package/dist/{upgrade-JACA6YMO.js → upgrade-DD5TNJWU.js} +3 -5
- package/dist/{variants-HPY4DEWU.js → variants-QQIEKT6M.js} +2 -2
- package/drizzle/0000_flaky_mariko_yashida.sql +34 -0
- package/drizzle/0001_careless_warpath.sql +12 -0
- package/drizzle/meta/0000_snapshot.json +227 -0
- package/drizzle/meta/0001_snapshot.json +298 -0
- package/drizzle/meta/_journal.json +20 -0
- package/package.json +2 -1
- package/templates/_base/.init/.config/hooks/startup-context.sh +28 -0
- package/templates/_base/_skills/memory/SKILL.md +56 -13
- package/templates/_base/_skills/volute-agent/SKILL.md +27 -3
- package/templates/_base/home/VOLUTE.md +25 -0
- package/templates/_base/src/lib/format-prefix.ts +24 -0
- package/templates/_base/src/lib/sessions.ts +71 -0
- package/templates/_base/src/lib/startup.ts +132 -0
- package/templates/_base/src/lib/types.ts +3 -0
- package/templates/_base/src/lib/volute-server.ts +18 -2
- package/templates/agent-sdk/.init/.claude/settings.json +14 -0
- package/templates/agent-sdk/.init/.config/sessions.json +4 -0
- package/templates/agent-sdk/.init/CLAUDE.md +3 -2
- package/templates/agent-sdk/package.json.tmpl +1 -1
- package/templates/agent-sdk/src/agent.ts +101 -0
- package/templates/agent-sdk/src/lib/agent-sessions.ts +180 -0
- package/templates/agent-sdk/src/server.ts +33 -129
- package/templates/agent-sdk/volute-template.json +1 -1
- package/templates/pi/.init/.config/sessions.json +1 -0
- package/templates/pi/.init/AGENTS.md +2 -1
- package/templates/pi/src/agent.ts +61 -0
- package/templates/pi/src/lib/agent-sessions.ts +188 -0
- package/templates/pi/src/server.ts +28 -102
- package/templates/pi/volute-template.json +1 -1
- package/templates/agent-sdk/src/lib/agent.ts +0 -199
- package/templates/pi/src/lib/agent.ts +0 -205
- /package/templates/_base/.init/memory/{.gitkeep → journal/.gitkeep} +0 -0
- /package/templates/_base/{volute.json.tmpl → home/.config/volute.json.tmpl} +0 -0
- /package/templates/pi/{volute.json.tmpl → home/.config/volute.json.tmpl} +0 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import type { HookCallback } from "@anthropic-ai/claude-agent-sdk";
|
|
4
|
+
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
5
|
+
import { createPreCompactHook } from "./hooks/pre-compact.js";
|
|
6
|
+
import { log, logText, logThinking, logToolUse } from "./logger.js";
|
|
7
|
+
import { createMessageChannel } from "./message-channel.js";
|
|
8
|
+
import type { Listener, VoluteEvent } from "./types.js";
|
|
9
|
+
|
|
10
|
+
type Session = {
|
|
11
|
+
name: string;
|
|
12
|
+
channel: ReturnType<typeof createMessageChannel>;
|
|
13
|
+
listeners: Set<Listener>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function createSessionManager(options: {
|
|
17
|
+
systemPrompt: string;
|
|
18
|
+
cwd: string;
|
|
19
|
+
abortController: AbortController;
|
|
20
|
+
model?: string;
|
|
21
|
+
sessionsDir: string;
|
|
22
|
+
postToolUseHooks: { matcher: string; hooks: HookCallback[] }[];
|
|
23
|
+
onTurnDone?: () => void;
|
|
24
|
+
compactionMessage?: string;
|
|
25
|
+
}) {
|
|
26
|
+
const sessions = new Map<string, Session>();
|
|
27
|
+
const compactionMessage =
|
|
28
|
+
options.compactionMessage ??
|
|
29
|
+
"Your conversation is approaching its context limit. Please update today's journal entry to preserve important context before the conversation is compacted.";
|
|
30
|
+
|
|
31
|
+
function sessionFilePath(sessionName: string): string {
|
|
32
|
+
return resolve(options.sessionsDir, `${sessionName}.json`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function loadSessionId(sessionName: string): string | undefined {
|
|
36
|
+
try {
|
|
37
|
+
const data = JSON.parse(readFileSync(sessionFilePath(sessionName), "utf-8"));
|
|
38
|
+
return data.sessionId;
|
|
39
|
+
} catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function saveSessionId(sessionName: string, sessionId: string) {
|
|
45
|
+
mkdirSync(options.sessionsDir, { recursive: true });
|
|
46
|
+
writeFileSync(sessionFilePath(sessionName), JSON.stringify({ sessionId }));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function deleteSessionId(sessionName: string) {
|
|
50
|
+
try {
|
|
51
|
+
const path = sessionFilePath(sessionName);
|
|
52
|
+
if (existsSync(path)) unlinkSync(path);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
log("agent", `failed to delete session file for "${sessionName}":`, err);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function broadcastToSession(session: Session, event: VoluteEvent) {
|
|
59
|
+
for (const listener of session.listeners) {
|
|
60
|
+
try {
|
|
61
|
+
listener(event);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
log("agent", "listener threw during broadcast:", err);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function createStream(session: Session, resume?: string) {
|
|
69
|
+
const preCompact = createPreCompactHook(() => {
|
|
70
|
+
session.channel.push({
|
|
71
|
+
type: "user",
|
|
72
|
+
session_id: "",
|
|
73
|
+
message: {
|
|
74
|
+
role: "user",
|
|
75
|
+
content: [{ type: "text", text: compactionMessage }],
|
|
76
|
+
},
|
|
77
|
+
parent_tool_use_id: null,
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return query({
|
|
82
|
+
prompt: session.channel.iterable,
|
|
83
|
+
options: {
|
|
84
|
+
systemPrompt: options.systemPrompt,
|
|
85
|
+
permissionMode: "bypassPermissions",
|
|
86
|
+
allowDangerouslySkipPermissions: true,
|
|
87
|
+
settingSources: ["project"],
|
|
88
|
+
cwd: options.cwd,
|
|
89
|
+
abortController: options.abortController,
|
|
90
|
+
model: options.model,
|
|
91
|
+
resume,
|
|
92
|
+
hooks: {
|
|
93
|
+
PostToolUse: options.postToolUseHooks,
|
|
94
|
+
PreCompact: [{ hooks: [preCompact.hook] }],
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function consumeStream(stream: ReturnType<typeof query>, session: Session) {
|
|
101
|
+
for await (const msg of stream) {
|
|
102
|
+
if ("session_id" in msg && msg.session_id) {
|
|
103
|
+
if (!session.name.startsWith("new-")) {
|
|
104
|
+
saveSessionId(session.name, msg.session_id as string);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (msg.type === "assistant") {
|
|
108
|
+
for (const b of msg.message.content) {
|
|
109
|
+
if (b.type === "thinking" && "thinking" in b && b.thinking) {
|
|
110
|
+
logThinking(b.thinking as string);
|
|
111
|
+
} else if (b.type === "text") {
|
|
112
|
+
const text = (b as { text: string }).text;
|
|
113
|
+
logText(text);
|
|
114
|
+
broadcastToSession(session, { type: "text", content: text });
|
|
115
|
+
} else if (b.type === "tool_use") {
|
|
116
|
+
const tb = b as { name: string; input: unknown };
|
|
117
|
+
logToolUse(tb.name, tb.input);
|
|
118
|
+
broadcastToSession(session, { type: "tool_use", name: tb.name, input: tb.input });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (msg.type === "result") {
|
|
123
|
+
log("agent", `session "${session.name}": turn done`);
|
|
124
|
+
broadcastToSession(session, { type: "done" });
|
|
125
|
+
options.onTurnDone?.();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function startSession(session: Session, savedSessionId?: string) {
|
|
131
|
+
(async () => {
|
|
132
|
+
log("agent", `session "${session.name}": stream consumer started`);
|
|
133
|
+
try {
|
|
134
|
+
await consumeStream(createStream(session, savedSessionId), session);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
if (savedSessionId) {
|
|
137
|
+
log("agent", `session "${session.name}": resume failed, starting fresh:`, err);
|
|
138
|
+
deleteSessionId(session.name);
|
|
139
|
+
try {
|
|
140
|
+
await consumeStream(createStream(session), session);
|
|
141
|
+
} catch (retryErr) {
|
|
142
|
+
log("agent", `session "${session.name}": stream consumer error:`, retryErr);
|
|
143
|
+
broadcastToSession(session, { type: "done" });
|
|
144
|
+
sessions.delete(session.name);
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
log("agent", `session "${session.name}": stream consumer error:`, err);
|
|
148
|
+
broadcastToSession(session, { type: "done" });
|
|
149
|
+
sessions.delete(session.name);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
log("agent", `session "${session.name}": stream consumer ended`);
|
|
153
|
+
})();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function getOrCreateSession(name: string): Session {
|
|
157
|
+
const existing = sessions.get(name);
|
|
158
|
+
if (existing) return existing;
|
|
159
|
+
|
|
160
|
+
const session: Session = {
|
|
161
|
+
name,
|
|
162
|
+
channel: createMessageChannel(),
|
|
163
|
+
listeners: new Set(),
|
|
164
|
+
};
|
|
165
|
+
sessions.set(name, session);
|
|
166
|
+
|
|
167
|
+
const isEphemeral = name.startsWith("new-");
|
|
168
|
+
const savedSessionId = isEphemeral ? undefined : loadSessionId(name);
|
|
169
|
+
if (savedSessionId) {
|
|
170
|
+
log("agent", `session "${name}": resuming ${savedSessionId}`);
|
|
171
|
+
} else {
|
|
172
|
+
log("agent", `session "${name}": starting fresh`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
startSession(session, savedSessionId);
|
|
176
|
+
return session;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { getOrCreateSession };
|
|
180
|
+
}
|
|
@@ -1,117 +1,41 @@
|
|
|
1
|
-
import { existsSync, mkdirSync,
|
|
2
|
-
import {
|
|
3
|
-
import { createAgent } from "./
|
|
1
|
+
import { existsSync, mkdirSync, renameSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { createAgent } from "./agent.js";
|
|
4
4
|
import { log } from "./lib/logger.js";
|
|
5
|
+
import {
|
|
6
|
+
handleMergeContext,
|
|
7
|
+
loadConfig,
|
|
8
|
+
loadPackageInfo,
|
|
9
|
+
loadSystemPrompt,
|
|
10
|
+
parseArgs,
|
|
11
|
+
setupShutdown,
|
|
12
|
+
} from "./lib/startup.js";
|
|
5
13
|
import { createVoluteServer } from "./lib/volute-server.js";
|
|
6
14
|
|
|
7
|
-
function parseArgs() {
|
|
8
|
-
const args = process.argv.slice(2);
|
|
9
|
-
let port = 4100;
|
|
10
|
-
|
|
11
|
-
for (let i = 0; i < args.length; i++) {
|
|
12
|
-
if (args[i] === "--port" && args[i + 1]) {
|
|
13
|
-
port = parseInt(args[++i], 10);
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
return { port };
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function loadConfig(): { model?: string } {
|
|
21
|
-
try {
|
|
22
|
-
return JSON.parse(readFileSync(resolve("volute.json"), "utf-8"));
|
|
23
|
-
} catch {
|
|
24
|
-
return {};
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function loadFile(path: string): string {
|
|
29
|
-
try {
|
|
30
|
-
return readFileSync(path, "utf-8");
|
|
31
|
-
} catch {
|
|
32
|
-
return "";
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
15
|
const { port } = parseArgs();
|
|
37
16
|
const config = loadConfig();
|
|
38
|
-
|
|
39
|
-
if (model) {
|
|
40
|
-
log("server", `using model: ${model}`);
|
|
41
|
-
}
|
|
42
|
-
const soulPath = resolve("home/SOUL.md");
|
|
43
|
-
const memoryPath = resolve("home/MEMORY.md");
|
|
44
|
-
const volutePath = resolve("home/VOLUTE.md");
|
|
45
|
-
|
|
46
|
-
const soul = loadFile(soulPath);
|
|
47
|
-
if (!soul) {
|
|
48
|
-
console.error(`Could not read soul file: ${soulPath}`);
|
|
49
|
-
process.exit(1);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const memory = loadFile(memoryPath);
|
|
53
|
-
const volute = loadFile(volutePath);
|
|
54
|
-
|
|
55
|
-
const promptParts = [soul];
|
|
56
|
-
if (volute) promptParts.push(volute);
|
|
57
|
-
if (memory) promptParts.push(`## Memory\n\n${memory}`);
|
|
58
|
-
const systemPrompt = promptParts.join("\n\n---\n\n");
|
|
17
|
+
if (config.model) log("server", `using model: ${config.model}`);
|
|
59
18
|
|
|
60
|
-
const
|
|
19
|
+
const systemPrompt = loadSystemPrompt();
|
|
20
|
+
const sessionsDir = resolve(".volute/sessions");
|
|
61
21
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
22
|
+
// Migrate old single session.json → sessions/main.json
|
|
23
|
+
const oldSessionPath = resolve(".volute/session.json");
|
|
24
|
+
if (existsSync(oldSessionPath) && !existsSync(resolve(sessionsDir, "main.json"))) {
|
|
25
|
+
mkdirSync(sessionsDir, { recursive: true });
|
|
26
|
+
renameSync(oldSessionPath, resolve(sessionsDir, "main.json"));
|
|
27
|
+
log("server", "migrated session.json → sessions/main.json");
|
|
69
28
|
}
|
|
70
29
|
|
|
71
|
-
|
|
72
|
-
mkdirSync(dirname(sessionPath), { recursive: true });
|
|
73
|
-
writeFileSync(sessionPath, JSON.stringify({ sessionId }));
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
let shuttingDown = false;
|
|
77
|
-
|
|
78
|
-
function deleteSessionFile() {
|
|
79
|
-
if (shuttingDown) return;
|
|
80
|
-
try {
|
|
81
|
-
unlinkSync(sessionPath);
|
|
82
|
-
log("server", "deleted session file");
|
|
83
|
-
} catch {}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Read name/version from package.json for health endpoint
|
|
87
|
-
let pkgName = "unknown";
|
|
88
|
-
let pkgVersion = "0.0.0";
|
|
89
|
-
try {
|
|
90
|
-
const pkg = JSON.parse(readFileSync(resolve("package.json"), "utf-8"));
|
|
91
|
-
pkgName = pkg.name || pkgName;
|
|
92
|
-
pkgVersion = pkg.version || pkgVersion;
|
|
93
|
-
} catch {}
|
|
94
|
-
|
|
30
|
+
const pkg = loadPackageInfo();
|
|
95
31
|
const abortController = new AbortController();
|
|
96
|
-
const savedSessionId = loadSessionId();
|
|
97
|
-
if (savedSessionId) {
|
|
98
|
-
log("server", `resuming session: ${savedSessionId}`);
|
|
99
|
-
}
|
|
100
32
|
const agent = createAgent({
|
|
101
33
|
systemPrompt,
|
|
102
34
|
cwd: resolve("home"),
|
|
103
35
|
abortController,
|
|
104
|
-
model,
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
onStreamError: deleteSessionFile,
|
|
108
|
-
onCompact: () => {
|
|
109
|
-
log("server", "pre-compact — asking agent to update daily log");
|
|
110
|
-
agent.sendMessage(
|
|
111
|
-
"Conversation is about to be compacted. Please update today's daily log with a summary of what we've discussed and accomplished so far, so context is preserved before compaction.",
|
|
112
|
-
"system",
|
|
113
|
-
);
|
|
114
|
-
},
|
|
36
|
+
model: config.model,
|
|
37
|
+
sessionsDir,
|
|
38
|
+
compactionMessage: config.compactionMessage,
|
|
115
39
|
onIdentityReload: async () => {
|
|
116
40
|
log("server", "identity file changed — restarting to reload");
|
|
117
41
|
await agent.waitForCommits();
|
|
@@ -120,39 +44,19 @@ const agent = createAgent({
|
|
|
120
44
|
},
|
|
121
45
|
});
|
|
122
46
|
|
|
123
|
-
const server = createVoluteServer({
|
|
47
|
+
const server = createVoluteServer({
|
|
48
|
+
agent,
|
|
49
|
+
port,
|
|
50
|
+
name: pkg.name,
|
|
51
|
+
version: pkg.version,
|
|
52
|
+
sessionsConfigPath: resolve("home/.config/sessions.json"),
|
|
53
|
+
});
|
|
124
54
|
|
|
125
55
|
server.listen(port, () => {
|
|
126
56
|
const addr = server.address();
|
|
127
57
|
const actualPort = typeof addr === "object" && addr ? addr.port : port;
|
|
128
58
|
log("server", `listening on :${actualPort}`);
|
|
129
|
-
|
|
130
|
-
// Check for post-merge context
|
|
131
|
-
const mergedPath = resolve(".volute/merged.json");
|
|
132
|
-
if (existsSync(mergedPath)) {
|
|
133
|
-
try {
|
|
134
|
-
const merged = JSON.parse(readFileSync(mergedPath, "utf-8"));
|
|
135
|
-
unlinkSync(mergedPath);
|
|
136
|
-
|
|
137
|
-
const parts = [
|
|
138
|
-
`[system] Variant "${merged.name}" has been merged and you have been restarted.`,
|
|
139
|
-
];
|
|
140
|
-
if (merged.summary) parts.push(`Changes: ${merged.summary}`);
|
|
141
|
-
if (merged.justification) parts.push(`Why: ${merged.justification}`);
|
|
142
|
-
if (merged.memory) parts.push(`Context: ${merged.memory}`);
|
|
143
|
-
|
|
144
|
-
agent.sendMessage(parts.join("\n"));
|
|
145
|
-
log("server", `sent post-merge orientation for variant: ${merged.name}`);
|
|
146
|
-
} catch (e) {
|
|
147
|
-
log("server", "failed to process merged.json:", e);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
59
|
+
handleMergeContext((content) => agent.sendMessage(content));
|
|
150
60
|
});
|
|
151
61
|
|
|
152
|
-
|
|
153
|
-
shuttingDown = true;
|
|
154
|
-
log("server", "shutdown signal received");
|
|
155
|
-
process.exit(0);
|
|
156
|
-
}
|
|
157
|
-
process.on("SIGINT", shutdown);
|
|
158
|
-
process.on("SIGTERM", shutdown);
|
|
62
|
+
setupShutdown();
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"rename": {
|
|
3
3
|
"package.json.tmpl": "package.json",
|
|
4
4
|
"biome.json.tmpl": "biome.json",
|
|
5
|
-
"volute.json.tmpl": "volute.json"
|
|
5
|
+
"home/.config/volute.json.tmpl": "home/.config/volute.json"
|
|
6
6
|
},
|
|
7
7
|
"substitute": ["package.json", ".init/SOUL.md"],
|
|
8
8
|
"skillsDir": "home/.claude/skills"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "rules": [], "default": "main" }
|
|
@@ -6,7 +6,7 @@ You are a volute agent running as a persistent server. Your state is managed acr
|
|
|
6
6
|
|
|
7
7
|
Messages arrive with a context prefix built by your server code:
|
|
8
8
|
```
|
|
9
|
-
[Discord: username in #general in My Server]
|
|
9
|
+
[Discord: username in #general in My Server — 1/15/2025, 10:30:00 AM]
|
|
10
10
|
```
|
|
11
11
|
|
|
12
12
|
## Memory System
|
|
@@ -21,6 +21,7 @@ See the **memory** skill for detailed guidance on consolidation and when to upda
|
|
|
21
21
|
|
|
22
22
|
## Sessions
|
|
23
23
|
|
|
24
|
+
- You may have **multiple named sessions** — each maintains its own conversation history. See `VOLUTE.md` for how to configure session routing via `.config/sessions.json`.
|
|
24
25
|
- Your conversation may be **resumed** from a previous session. If so, context from before is preserved — orient yourself by reading your recent daily logs if needed.
|
|
25
26
|
- If this is a **fresh session**, check your memory files (`MEMORY.md` and recent daily logs in `memory/`) to recall what you've been working on.
|
|
26
27
|
- On **conversation compaction**, update today's daily log with a summary of what happened so far, so context is preserved.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ImageContent } from "@mariozechner/pi-ai";
|
|
2
|
+
import { createPiSessionManager } from "./lib/agent-sessions.js";
|
|
3
|
+
import { formatPrefix } from "./lib/format-prefix.js";
|
|
4
|
+
import { logMessage } from "./lib/logger.js";
|
|
5
|
+
import type { ChannelMeta, Listener, VoluteContentPart } from "./lib/types.js";
|
|
6
|
+
|
|
7
|
+
export function createAgent(options: {
|
|
8
|
+
systemPrompt: string;
|
|
9
|
+
cwd: string;
|
|
10
|
+
model?: string;
|
|
11
|
+
compactionMessage?: string;
|
|
12
|
+
}) {
|
|
13
|
+
const { getOrCreateSession } = createPiSessionManager(options);
|
|
14
|
+
|
|
15
|
+
function sendMessage(content: string | VoluteContentPart[], meta?: ChannelMeta) {
|
|
16
|
+
const raw =
|
|
17
|
+
typeof content === "string"
|
|
18
|
+
? content
|
|
19
|
+
: content
|
|
20
|
+
.filter((p) => p.type === "text")
|
|
21
|
+
.map((p) => (p as { text: string }).text)
|
|
22
|
+
.join("\n");
|
|
23
|
+
logMessage("in", raw, meta?.channel);
|
|
24
|
+
|
|
25
|
+
const sessionName = meta?.sessionName ?? "main";
|
|
26
|
+
const session = getOrCreateSession(sessionName);
|
|
27
|
+
|
|
28
|
+
// Build context prefix from channel metadata
|
|
29
|
+
const prefix = formatPrefix(meta, new Date().toLocaleString());
|
|
30
|
+
const text = prefix + raw;
|
|
31
|
+
|
|
32
|
+
// Convert image parts to pi-ai ImageContent format
|
|
33
|
+
const images: ImageContent[] | undefined =
|
|
34
|
+
typeof content === "string"
|
|
35
|
+
? undefined
|
|
36
|
+
: content
|
|
37
|
+
.filter((p) => p.type === "image")
|
|
38
|
+
.map((p) => ({ type: "image" as const, mimeType: p.media_type, data: p.data }));
|
|
39
|
+
|
|
40
|
+
const opts = images?.length ? { images } : {};
|
|
41
|
+
|
|
42
|
+
// Fire-and-forget: await session ready then prompt
|
|
43
|
+
(async () => {
|
|
44
|
+
await session.ready;
|
|
45
|
+
if (session.agentSession!.isStreaming) {
|
|
46
|
+
session.agentSession!.prompt(text, { streamingBehavior: "followUp", ...opts });
|
|
47
|
+
} else {
|
|
48
|
+
session.agentSession!.prompt(text, opts);
|
|
49
|
+
}
|
|
50
|
+
})();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function onMessage(listener: Listener, sessionName?: string): () => void {
|
|
54
|
+
const name = sessionName ?? "main";
|
|
55
|
+
const session = getOrCreateSession(name);
|
|
56
|
+
session.listeners.add(listener);
|
|
57
|
+
return () => session.listeners.delete(listener);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { sendMessage, onMessage };
|
|
61
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { getModel, getModels } from "@mariozechner/pi-ai";
|
|
2
|
+
import {
|
|
3
|
+
AuthStorage,
|
|
4
|
+
createAgentSession,
|
|
5
|
+
DefaultResourceLoader,
|
|
6
|
+
type ExtensionFactory,
|
|
7
|
+
ModelRegistry,
|
|
8
|
+
SessionManager,
|
|
9
|
+
SettingsManager,
|
|
10
|
+
} from "@mariozechner/pi-coding-agent";
|
|
11
|
+
import { commitFileChange } from "./auto-commit.js";
|
|
12
|
+
import { log, logText, logThinking, logToolResult, logToolUse } from "./logger.js";
|
|
13
|
+
import type { Listener, VoluteEvent } from "./types.js";
|
|
14
|
+
|
|
15
|
+
type AgentSession = Awaited<ReturnType<typeof createAgentSession>>["session"];
|
|
16
|
+
|
|
17
|
+
type PiSession = {
|
|
18
|
+
name: string;
|
|
19
|
+
agentSession: AgentSession | null;
|
|
20
|
+
ready: Promise<void>;
|
|
21
|
+
listeners: Set<Listener>;
|
|
22
|
+
unsubscribe?: () => void;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const DEFAULT_COMPACTION_MESSAGE =
|
|
26
|
+
"Your conversation is approaching its context limit. Please update today's journal entry to preserve important context before the conversation is compacted.";
|
|
27
|
+
|
|
28
|
+
function resolveModel(modelStr: string) {
|
|
29
|
+
const [provider, ...rest] = modelStr.split(":");
|
|
30
|
+
const modelId = rest.join(":");
|
|
31
|
+
|
|
32
|
+
// Try exact match first, then prefix match against available models
|
|
33
|
+
let model = getModel(provider as any, modelId as any);
|
|
34
|
+
if (!model) {
|
|
35
|
+
const available = getModels(provider as any);
|
|
36
|
+
const found = available.find((m) => m.id.startsWith(modelId));
|
|
37
|
+
if (found) model = found;
|
|
38
|
+
}
|
|
39
|
+
if (!model) {
|
|
40
|
+
const available = getModels(provider as any);
|
|
41
|
+
throw new Error(
|
|
42
|
+
`Model not found: ${modelStr}\nAvailable ${provider} models: ${available.map((m) => m.id).join(", ")}`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
return model;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createPiSessionManager(options: {
|
|
49
|
+
systemPrompt: string;
|
|
50
|
+
cwd: string;
|
|
51
|
+
model?: string;
|
|
52
|
+
compactionMessage?: string;
|
|
53
|
+
}) {
|
|
54
|
+
const sessions = new Map<string, PiSession>();
|
|
55
|
+
const compactionMessage = options.compactionMessage ?? DEFAULT_COMPACTION_MESSAGE;
|
|
56
|
+
|
|
57
|
+
// Shared setup (created once)
|
|
58
|
+
const modelStr = options.model || process.env.PI_MODEL || "anthropic:claude-sonnet-4-20250514";
|
|
59
|
+
const model = resolveModel(modelStr);
|
|
60
|
+
const authStorage = new AuthStorage();
|
|
61
|
+
const modelRegistry = new ModelRegistry(authStorage);
|
|
62
|
+
|
|
63
|
+
function getOrCreateSession(name: string): PiSession {
|
|
64
|
+
const existing = sessions.get(name);
|
|
65
|
+
if (existing) return existing;
|
|
66
|
+
|
|
67
|
+
const session: PiSession = {
|
|
68
|
+
name,
|
|
69
|
+
agentSession: null,
|
|
70
|
+
ready: Promise.resolve(),
|
|
71
|
+
listeners: new Set(),
|
|
72
|
+
};
|
|
73
|
+
sessions.set(name, session);
|
|
74
|
+
|
|
75
|
+
session.ready = initSession(session);
|
|
76
|
+
return session;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function initSession(session: PiSession) {
|
|
80
|
+
const isEphemeral = session.name.startsWith("new-");
|
|
81
|
+
|
|
82
|
+
// Per-session session manager
|
|
83
|
+
const sessionManager = isEphemeral
|
|
84
|
+
? SessionManager.inMemory()
|
|
85
|
+
: SessionManager.continueRecent(options.cwd, `.volute/pi-sessions/${session.name}`);
|
|
86
|
+
|
|
87
|
+
log("agent", `session "${session.name}": ${isEphemeral ? "ephemeral" : "persistent"}`);
|
|
88
|
+
|
|
89
|
+
// Per-session pre-compact extension: block once, send compaction message, allow on second call
|
|
90
|
+
let compactBlocked = false;
|
|
91
|
+
const preCompactExtension: ExtensionFactory = (pi) => {
|
|
92
|
+
pi.on("session_before_compact", () => {
|
|
93
|
+
if (!compactBlocked) {
|
|
94
|
+
compactBlocked = true;
|
|
95
|
+
log(
|
|
96
|
+
"agent",
|
|
97
|
+
`session "${session.name}": blocking compaction — asking agent to update daily log`,
|
|
98
|
+
);
|
|
99
|
+
session.agentSession?.prompt(compactionMessage, { streamingBehavior: "followUp" });
|
|
100
|
+
return { cancel: true };
|
|
101
|
+
}
|
|
102
|
+
compactBlocked = false;
|
|
103
|
+
log("agent", `session "${session.name}": allowing compaction`);
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const settingsManager = SettingsManager.inMemory({
|
|
108
|
+
retry: { enabled: true, maxRetries: 3 },
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const resourceLoader = new DefaultResourceLoader({
|
|
112
|
+
cwd: options.cwd,
|
|
113
|
+
settingsManager,
|
|
114
|
+
systemPrompt: options.systemPrompt,
|
|
115
|
+
extensionFactories: [preCompactExtension],
|
|
116
|
+
});
|
|
117
|
+
await resourceLoader.reload();
|
|
118
|
+
|
|
119
|
+
const { session: agentSession } = await createAgentSession({
|
|
120
|
+
cwd: options.cwd,
|
|
121
|
+
model,
|
|
122
|
+
authStorage,
|
|
123
|
+
modelRegistry,
|
|
124
|
+
sessionManager,
|
|
125
|
+
settingsManager,
|
|
126
|
+
resourceLoader,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
session.agentSession = agentSession;
|
|
130
|
+
|
|
131
|
+
// Per-session event subscription
|
|
132
|
+
const toolArgs = new Map<string, any>();
|
|
133
|
+
|
|
134
|
+
session.unsubscribe = agentSession.subscribe((event) => {
|
|
135
|
+
if (event.type === "message_update") {
|
|
136
|
+
const ae = event.assistantMessageEvent;
|
|
137
|
+
if (ae.type === "text_delta") {
|
|
138
|
+
logText(ae.delta);
|
|
139
|
+
broadcast(session, { type: "text", content: ae.delta });
|
|
140
|
+
} else if (ae.type === "thinking_delta") {
|
|
141
|
+
logThinking(ae.delta);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (event.type === "tool_execution_start") {
|
|
146
|
+
toolArgs.set(event.toolCallId, event.args);
|
|
147
|
+
logToolUse(event.toolName, event.args);
|
|
148
|
+
broadcast(session, { type: "tool_use", name: event.toolName, input: event.args });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (event.type === "tool_execution_end") {
|
|
152
|
+
const output =
|
|
153
|
+
typeof event.result === "string" ? event.result : JSON.stringify(event.result);
|
|
154
|
+
logToolResult(event.toolName, output, event.isError);
|
|
155
|
+
broadcast(session, { type: "tool_result", output, is_error: event.isError });
|
|
156
|
+
|
|
157
|
+
// Auto-commit file changes in home/
|
|
158
|
+
if ((event.toolName === "Edit" || event.toolName === "Write") && !event.isError) {
|
|
159
|
+
const args = toolArgs.get(event.toolCallId);
|
|
160
|
+
const filePath = (args as { file_path?: string })?.file_path;
|
|
161
|
+
if (filePath) {
|
|
162
|
+
commitFileChange(filePath, options.cwd);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
toolArgs.delete(event.toolCallId);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (event.type === "agent_end") {
|
|
169
|
+
log("agent", `session "${session.name}": turn done`);
|
|
170
|
+
broadcast(session, { type: "done" });
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
log("agent", `session "${session.name}": ready`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function broadcast(session: PiSession, event: VoluteEvent) {
|
|
178
|
+
for (const listener of session.listeners) {
|
|
179
|
+
try {
|
|
180
|
+
listener(event);
|
|
181
|
+
} catch (err) {
|
|
182
|
+
log("agent", "listener threw during broadcast:", err);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return { getOrCreateSession };
|
|
188
|
+
}
|