vole-agent 0.1.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.
@@ -0,0 +1,17 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Vole</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body { font-family: system-ui, -apple-system, sans-serif; background: #0f1117; color: #e2e8f0; height: 100vh; display: flex; flex-direction: column; }
10
+ #root { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
11
+ </style>
12
+ <script type="module" crossorigin src="/assets/index-CjJBdA5w.js"></script>
13
+ </head>
14
+ <body>
15
+ <div id="root"></div>
16
+ </body>
17
+ </html>
@@ -0,0 +1,417 @@
1
+ /**
2
+ * INPUT: HTTP requests, WebSocket frames, env vars (VOLE_API_KEY, VOLE_MODEL, etc.), runtime events from AgentRuntime.
3
+ * OUTPUT: JSON API (sessions CRUD, turns SSE stream, approval resolution, gateway sessions endpoint), WebSocket endpoint (/ws/:id) for bidirectional session communication, static client files in production.
4
+ * POS: Web adapter layer; exposes AgentRuntime over HTTP/SSE/WebSocket without owning agent logic.
5
+ *
6
+ * Session storage: one shared JsonlSessionStore at resolveSessionsDirectory(config).
7
+ * Transient runtime state (runtime, approvalResolver, traceStore) is held in the sessions Map.
8
+ * Persistent session data (messages, trace events) lives in the JsonlSessionStore.
9
+ *
10
+ * Update this header and the parent directory docs when responsibilities change.
11
+ */
12
+ import { serve } from "@hono/node-server";
13
+ import { serveStatic } from "@hono/node-server/serve-static";
14
+ import { WebSocketServer } from "ws";
15
+ import { Hono } from "hono";
16
+ import { streamSSE } from "hono/streaming";
17
+ import { WEB_CAPABILITIES, filterToolsByProfile } from "@vole/adapters";
18
+ import { loadConfig, resolveSessionsDirectory } from "@vole/config";
19
+ import { DefaultContextAssembler } from "@vole/context";
20
+ import { AgentRuntime, InMemoryRuntimeTraceStore } from "@vole/core";
21
+ import { SessionGateway } from "@vole/gateway";
22
+ import { AnthropicProvider, OpenAICompatibleProvider } from "@vole/models";
23
+ import { JsonlSessionStore } from "@vole/sessions";
24
+ import { SkillLoader, toSkillSummary } from "@vole/skills";
25
+ import { createListDirectoryTool, createLoadSkillTool, createMemoryGetTool, createMemorySearchTool, createReadFileTool, createReadWebPageTool, createShellTool, createWriteFileTool } from "@vole/tools";
26
+ /** Module-level SessionGateway singleton — tracks all active Web sessions in this process. */
27
+ const webGateway = new SessionGateway();
28
+ // ─── Web Approval Resolver ────────────────────────────────────────────────────
29
+ class WebApprovalResolver {
30
+ #pending = new Map();
31
+ resolve(request) {
32
+ return new Promise((resolve) => {
33
+ this.#pending.set(request.call.id, { request, resolve });
34
+ });
35
+ }
36
+ settle(callId, decision) {
37
+ const entry = this.#pending.get(callId);
38
+ if (entry === undefined)
39
+ return false;
40
+ this.#pending.delete(callId);
41
+ entry.resolve(decision);
42
+ return true;
43
+ }
44
+ pendingRequest(callId) {
45
+ return this.#pending.get(callId)?.request;
46
+ }
47
+ }
48
+ /** Shared durable store — created once at server start. */
49
+ let sharedStore;
50
+ /** Transient runtime state per active session in this process. */
51
+ const sessions = new Map();
52
+ /** AbortControllers for in-flight turns — keyed by sessionId. */
53
+ const runningTurns = new Map();
54
+ function getOrCreateSharedStore(config) {
55
+ if (sharedStore === undefined) {
56
+ const directory = resolveSessionsDirectory(config, process.env);
57
+ sharedStore = new JsonlSessionStore({ directory });
58
+ }
59
+ return sharedStore;
60
+ }
61
+ function createProvider(config) {
62
+ if (config.model.provider === "anthropic") {
63
+ return new AnthropicProvider({
64
+ ...(config.secrets.apiKey !== undefined ? { apiKey: config.secrets.apiKey } : {}),
65
+ model: config.model.model,
66
+ temperature: config.model.temperature,
67
+ maxTokens: config.model.maxTokens,
68
+ ...(config.model.thinkingBudget !== undefined ? { thinkingBudget: config.model.thinkingBudget } : {})
69
+ });
70
+ }
71
+ return new OpenAICompatibleProvider({
72
+ baseURL: config.model.baseURL,
73
+ ...(config.secrets.apiKey !== undefined ? { apiKey: config.secrets.apiKey } : {}),
74
+ model: config.model.model,
75
+ temperature: config.model.temperature,
76
+ maxTokens: config.model.maxTokens
77
+ });
78
+ }
79
+ async function createWebSession(config, existingSessionId) {
80
+ const store = getOrCreateSharedStore(config);
81
+ const approvalResolver = new WebApprovalResolver();
82
+ const traceStore = new InMemoryRuntimeTraceStore();
83
+ const currentDate = new Date().toISOString().slice(0, 10);
84
+ const skillDefinitions = await new SkillLoader().load({ workspaceRoot: config.workspace.root });
85
+ const skillIndex = skillDefinitions.map(toSkillSummary);
86
+ const skillFileMap = new Map(skillDefinitions.map((s) => [s.name, s.filePath]));
87
+ const allWebTools = [
88
+ createReadFileTool(),
89
+ createListDirectoryTool(),
90
+ createWriteFileTool(),
91
+ createShellTool(config.runtime.sandboxed !== undefined ? { sandboxed: config.runtime.sandboxed } : undefined),
92
+ createReadWebPageTool()
93
+ ];
94
+ if (config.memory.longTermFiles === "read-only" || config.memory.longTermFiles === "write") {
95
+ allWebTools.push(createMemorySearchTool(config.workspace.root));
96
+ allWebTools.push(createMemoryGetTool(config.workspace.root));
97
+ }
98
+ if (skillFileMap.size > 0) {
99
+ allWebTools.push(createLoadSkillTool(skillFileMap));
100
+ }
101
+ const tools = config.runtime.toolProfile !== undefined
102
+ ? filterToolsByProfile(allWebTools, config.runtime.toolProfile)
103
+ : allWebTools;
104
+ const runtime = new AgentRuntime({
105
+ contextAssembler: new DefaultContextAssembler({
106
+ workspacePromptFiles: ["AGENTS.md", "SOUL.md", "TOOLS.md", "IDENTITY.md", "HEARTBEAT.md", "BOOTSTRAP.md"]
107
+ }),
108
+ modelProvider: createProvider(config),
109
+ systemInstruction: "You are Vole, a personal general-purpose agent. You can use tools to read files, list directories, write files, run shell commands, and read web pages. You follow a permission policy that governs which actions require user approval.",
110
+ runtime: {
111
+ mode: config.runtime.defaultMode,
112
+ workspace: config.workspace.root,
113
+ currentDate
114
+ },
115
+ tools,
116
+ skillIndex,
117
+ preferStreaming: true,
118
+ approvalResolver,
119
+ ...(config.runtime.promptMode !== undefined ? { promptMode: config.runtime.promptMode } : {}),
120
+ ...(config.runtime.executionContract !== undefined ? { executionContract: config.runtime.executionContract } : {})
121
+ });
122
+ let id;
123
+ if (existingSessionId !== undefined) {
124
+ // Resume existing session — verify it exists in the store
125
+ const existing = await store.getSession(existingSessionId);
126
+ if (existing === undefined) {
127
+ throw new Error(`Session "${existingSessionId}" not found in store.`);
128
+ }
129
+ id = existingSessionId;
130
+ }
131
+ else {
132
+ // Create a new session — use the store's generated ID so the JSONL file
133
+ // name matches the ID used for all subsequent store operations.
134
+ const record = await store.createSession({ title: `session_${crypto.randomUUID()}` });
135
+ id = record.id;
136
+ }
137
+ const sessionRuntime = { id, runtime, traceStore, approvalResolver };
138
+ sessions.set(id, sessionRuntime);
139
+ const now = new Date().toISOString();
140
+ webGateway.register({
141
+ id,
142
+ adapterName: "web",
143
+ capabilities: WEB_CAPABILITIES,
144
+ registeredAt: now,
145
+ lastActivityAt: now
146
+ });
147
+ return sessionRuntime;
148
+ }
149
+ // ─── Hono app ─────────────────────────────────────────────────────────────────
150
+ const app = new Hono();
151
+ // CORS for development (Vite dev server on 5173, Hono on 3120)
152
+ app.use("/api/*", async (c, next) => {
153
+ await next();
154
+ c.res.headers.set("Access-Control-Allow-Origin", "*");
155
+ c.res.headers.set("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
156
+ c.res.headers.set("Access-Control-Allow-Headers", "Content-Type");
157
+ });
158
+ app.options("/api/*", (c) => c.text("", 200));
159
+ // POST /api/sessions — create new session or resume existing
160
+ app.post("/api/sessions", async (c) => {
161
+ let config;
162
+ try {
163
+ config = loadConfig({ env: process.env });
164
+ }
165
+ catch (err) {
166
+ return c.json({ error: err instanceof Error ? err.message : "Config error" }, 400);
167
+ }
168
+ if (config.secrets.apiKey === undefined) {
169
+ return c.json({ error: "Missing API key. Set VOLE_API_KEY or OPENROUTER_API_KEY." }, 400);
170
+ }
171
+ // Optional: resume an existing session by passing { sessionId }
172
+ const body = await c.req.json().catch(() => ({ sessionId: undefined }));
173
+ const existingSessionId = body.sessionId;
174
+ try {
175
+ const session = await createWebSession(config, existingSessionId);
176
+ return c.json({ sessionId: session.id });
177
+ }
178
+ catch (err) {
179
+ return c.json({ error: err instanceof Error ? err.message : "Failed to create session" }, 400);
180
+ }
181
+ });
182
+ // GET /api/sessions — list sessions from the durable store
183
+ app.get("/api/sessions", async (c) => {
184
+ let config;
185
+ try {
186
+ config = loadConfig({ env: process.env });
187
+ }
188
+ catch {
189
+ return c.json({ sessions: [] });
190
+ }
191
+ try {
192
+ const store = getOrCreateSharedStore(config);
193
+ const sessionRecords = await store.listSessions();
194
+ const list = sessionRecords.map((s) => ({
195
+ id: s.id,
196
+ title: s.title,
197
+ createdAt: s.createdAt,
198
+ updatedAt: s.updatedAt
199
+ }));
200
+ return c.json({ sessions: list });
201
+ }
202
+ catch {
203
+ return c.json({ sessions: [] });
204
+ }
205
+ });
206
+ // GET /api/sessions/:id — single session metadata
207
+ app.get("/api/sessions/:id", async (c) => {
208
+ let config;
209
+ try {
210
+ config = loadConfig({ env: process.env });
211
+ }
212
+ catch (err) {
213
+ return c.json({ error: err instanceof Error ? err.message : "Config error" }, 400);
214
+ }
215
+ const id = c.req.param("id");
216
+ try {
217
+ const store = getOrCreateSharedStore(config);
218
+ const session = await store.getSession(id);
219
+ if (session === undefined)
220
+ return c.json({ error: "Session not found" }, 404);
221
+ return c.json({ session });
222
+ }
223
+ catch (err) {
224
+ return c.json({ error: err instanceof Error ? err.message : "Store error" }, 500);
225
+ }
226
+ });
227
+ // GET /api/sessions/:id/messages — get messages
228
+ app.get("/api/sessions/:id/messages", async (c) => {
229
+ let config;
230
+ try {
231
+ config = loadConfig({ env: process.env });
232
+ }
233
+ catch (err) {
234
+ return c.json({ error: err instanceof Error ? err.message : "Config error" }, 400);
235
+ }
236
+ const id = c.req.param("id");
237
+ try {
238
+ const store = getOrCreateSharedStore(config);
239
+ const messages = await store.listMessages(id);
240
+ return c.json({ messages });
241
+ }
242
+ catch (err) {
243
+ return c.json({ error: err instanceof Error ? err.message : "Store error" }, 500);
244
+ }
245
+ });
246
+ // POST /api/sessions/:id/turns — run a turn, stream events via SSE
247
+ app.post("/api/sessions/:id/turns", async (c) => {
248
+ const id = c.req.param("id");
249
+ // Ensure runtime is initialized for this session
250
+ let sessionRuntime = sessions.get(id);
251
+ if (sessionRuntime === undefined) {
252
+ // Session exists in store but has no active runtime — need to resume
253
+ let config;
254
+ try {
255
+ config = loadConfig({ env: process.env });
256
+ }
257
+ catch (err) {
258
+ return c.json({ error: err instanceof Error ? err.message : "Config error" }, 400);
259
+ }
260
+ try {
261
+ sessionRuntime = await createWebSession(config, id);
262
+ }
263
+ catch {
264
+ return c.json({ error: "Session not found" }, 404);
265
+ }
266
+ }
267
+ const session = sessionRuntime;
268
+ const body = await c.req.json();
269
+ const message = body.message?.trim() ?? "";
270
+ if (message === "")
271
+ return c.json({ error: "Message is required" }, 400);
272
+ let config;
273
+ try {
274
+ config = loadConfig({ env: process.env });
275
+ }
276
+ catch (err) {
277
+ return c.json({ error: err instanceof Error ? err.message : "Config error" }, 400);
278
+ }
279
+ const store = getOrCreateSharedStore(config);
280
+ const recentRaw = await store.listMessages(id, { limit: 12 });
281
+ const recentMessages = recentRaw.map((m) => ({ role: m.role, content: m.content }));
282
+ const controller = new AbortController();
283
+ runningTurns.set(id, controller);
284
+ return streamSSE(c, async (stream) => {
285
+ let assistantText = "";
286
+ try {
287
+ for await (const event of session.runtime.runTurn({ sessionId: id, recentMessages, message, signal: controller.signal })) {
288
+ await session.traceStore.append(event);
289
+ await store.appendTraceEvent({ sessionId: id, event });
290
+ await stream.writeSSE({ event: event.type, data: JSON.stringify(event) });
291
+ if (event.type === "assistant_message_created") {
292
+ assistantText = event.message.content;
293
+ }
294
+ if (event.type === "run_completed" || event.type === "run_failed")
295
+ break;
296
+ }
297
+ }
298
+ finally {
299
+ runningTurns.delete(id);
300
+ }
301
+ // Persist messages after the turn completes (skip if aborted mid-turn)
302
+ if (!controller.signal.aborted) {
303
+ await store.appendMessage({ sessionId: id, role: "user", content: message });
304
+ if (assistantText !== "") {
305
+ await store.appendMessage({ sessionId: id, role: "assistant", content: assistantText });
306
+ }
307
+ }
308
+ // Update gateway activity timestamp
309
+ webGateway.touch(id);
310
+ });
311
+ });
312
+ // DELETE /api/sessions/:id/turns — abort the running turn for a session
313
+ app.delete("/api/sessions/:id/turns", (c) => {
314
+ const id = c.req.param("id");
315
+ const controller = runningTurns.get(id);
316
+ if (controller === undefined)
317
+ return c.json({ ok: false, reason: "no running turn" }, 404);
318
+ controller.abort();
319
+ return c.json({ ok: true });
320
+ });
321
+ // GET /api/gateway/sessions — list all active web sessions registered in the gateway
322
+ app.get("/api/gateway/sessions", (c) => {
323
+ return c.json({ sessions: webGateway.list() });
324
+ });
325
+ // POST /api/sessions/:id/approvals — resolve a pending approval
326
+ app.post("/api/sessions/:id/approvals", async (c) => {
327
+ const id = c.req.param("id");
328
+ const session = sessions.get(id);
329
+ if (session === undefined)
330
+ return c.json({ error: "Session not found" }, 404);
331
+ const body = await c.req.json();
332
+ const decision = {
333
+ approved: body.approved,
334
+ reason: body.reason ?? (body.approved ? "Approved." : "Denied.")
335
+ };
336
+ const ok = session.approvalResolver.settle(body.callId, decision);
337
+ if (!ok)
338
+ return c.json({ error: "No pending approval with that callId" }, 404);
339
+ return c.json({ success: true });
340
+ });
341
+ // Serve built client assets in production
342
+ app.use("/*", serveStatic({ root: "./dist/client" }));
343
+ // ─── Start server ─────────────────────────────────────────────────────────────
344
+ const port = Number(process.env["PORT"] ?? 3120);
345
+ console.log(`Vole web server starting on http://localhost:${port}`);
346
+ const server = serve({ fetch: app.fetch, port });
347
+ // ─── WebSocket endpoint: GET /ws/:id ─────────────────────────────────────────
348
+ // Bidirectional session communication over WebSocket.
349
+ // Client sends { type: "turn", message } or { type: "approval", callId, approved, reason }.
350
+ // Server streams runtime events as JSON frames.
351
+ const wss = new WebSocketServer({ noServer: true });
352
+ server.on("upgrade", (request, socket, head) => {
353
+ const url = request.url ?? "";
354
+ const match = /^\/ws\/([^/?#]+)/.exec(url);
355
+ if (match === null) {
356
+ socket.destroy();
357
+ return;
358
+ }
359
+ wss.handleUpgrade(request, socket, head, (ws) => {
360
+ const sessionId = match[1];
361
+ const session = sessions.get(sessionId);
362
+ if (session === undefined) {
363
+ ws.send(JSON.stringify({ type: "error", message: "Session not found." }));
364
+ ws.close();
365
+ return;
366
+ }
367
+ ws.send(JSON.stringify({ type: "connected", sessionId }));
368
+ ws.on("message", (data) => {
369
+ void (async () => {
370
+ const currentSession = sessions.get(sessionId);
371
+ if (currentSession === undefined) {
372
+ ws.close();
373
+ return;
374
+ }
375
+ let msg;
376
+ try {
377
+ msg = JSON.parse(String(data));
378
+ }
379
+ catch {
380
+ ws.send(JSON.stringify({ type: "error", message: "Invalid JSON." }));
381
+ return;
382
+ }
383
+ if (msg.type === "turn" && msg.message !== undefined) {
384
+ const userMessage = msg.message;
385
+ let config;
386
+ try {
387
+ config = loadConfig({ env: process.env });
388
+ }
389
+ catch {
390
+ ws.send(JSON.stringify({ type: "error", message: "Config error." }));
391
+ return;
392
+ }
393
+ const store = getOrCreateSharedStore(config);
394
+ const recentRaw = await store.listMessages(sessionId, { limit: 12 });
395
+ const recentMessages = recentRaw.map((m) => ({ role: m.role, content: m.content }));
396
+ for await (const runtimeEvent of currentSession.runtime.runTurn({ sessionId, recentMessages, message: userMessage })) {
397
+ await currentSession.traceStore.append(runtimeEvent);
398
+ await store.appendTraceEvent({ sessionId, event: runtimeEvent });
399
+ ws.send(JSON.stringify(runtimeEvent));
400
+ if (runtimeEvent.type === "run_completed" || runtimeEvent.type === "run_failed")
401
+ break;
402
+ }
403
+ await store.appendMessage({ sessionId, role: "user", content: userMessage });
404
+ }
405
+ else if (msg.type === "approval" && msg.callId !== undefined) {
406
+ currentSession.approvalResolver.settle(msg.callId, {
407
+ approved: msg.approved ?? false,
408
+ reason: msg.reason ?? (msg.approved ? "Approved." : "Denied.")
409
+ });
410
+ }
411
+ })();
412
+ });
413
+ // Session stays alive; only the WS connection closed
414
+ ws.on("close", () => { });
415
+ });
416
+ });
417
+ //# sourceMappingURL=server.js.map
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "vole-agent",
3
+ "version": "0.1.0",
4
+ "description": "A capable coding and general-purpose agent",
5
+ "type": "module",
6
+ "bin": {
7
+ "vole": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist/**/*.js",
11
+ "dist/web"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsup",
15
+ "typecheck": "tsc -b --noEmit",
16
+ "prepublishOnly": "bash ../../scripts/build-release.sh"
17
+ },
18
+ "dependencies": {
19
+ "@anthropic-ai/sdk": "^0.92.0",
20
+ "chalk": "^5.6.2",
21
+ "commander": "^14.0.3",
22
+ "dotenv": "^17.4.2",
23
+ "ink": "^7.0.1",
24
+ "ink-text-input": "^6.0.0",
25
+ "marked": "^18.0.3",
26
+ "react": "^19.2.5"
27
+ },
28
+ "devDependencies": {
29
+ "@types/react": "^19.2.14",
30
+ "@vole/adapters": "workspace:*",
31
+ "@vole/config": "workspace:*",
32
+ "@vole/context": "workspace:*",
33
+ "@vole/core": "workspace:*",
34
+ "@vole/gateway": "workspace:*",
35
+ "@vole/models": "workspace:*",
36
+ "@vole/scheduler": "workspace:*",
37
+ "@vole/sessions": "workspace:*",
38
+ "@vole/skills": "workspace:*",
39
+ "@vole/taskflow": "workspace:*",
40
+ "@vole/tools": "workspace:*",
41
+ "tsup": "^8.5.1"
42
+ },
43
+ "engines": {
44
+ "node": ">=22"
45
+ }
46
+ }