@versdotsh/reef 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/.github/workflows/test.yml +47 -0
  2. package/README.md +257 -0
  3. package/bun.lock +587 -0
  4. package/examples/services/board/board.test.ts +215 -0
  5. package/examples/services/board/index.ts +155 -0
  6. package/examples/services/board/routes.ts +335 -0
  7. package/examples/services/board/store.ts +329 -0
  8. package/examples/services/board/tools.ts +214 -0
  9. package/examples/services/commits/commits.test.ts +74 -0
  10. package/examples/services/commits/index.ts +14 -0
  11. package/examples/services/commits/routes.ts +43 -0
  12. package/examples/services/commits/store.ts +114 -0
  13. package/examples/services/feed/behaviors.ts +23 -0
  14. package/examples/services/feed/feed.test.ts +101 -0
  15. package/examples/services/feed/index.ts +117 -0
  16. package/examples/services/feed/routes.ts +224 -0
  17. package/examples/services/feed/store.ts +194 -0
  18. package/examples/services/feed/tools.ts +83 -0
  19. package/examples/services/journal/index.ts +15 -0
  20. package/examples/services/journal/journal.test.ts +57 -0
  21. package/examples/services/journal/routes.ts +45 -0
  22. package/examples/services/journal/store.ts +119 -0
  23. package/examples/services/journal/tools.ts +32 -0
  24. package/examples/services/log/index.ts +15 -0
  25. package/examples/services/log/log.test.ts +70 -0
  26. package/examples/services/log/routes.ts +44 -0
  27. package/examples/services/log/store.ts +105 -0
  28. package/examples/services/log/tools.ts +57 -0
  29. package/examples/services/registry/behaviors.ts +128 -0
  30. package/examples/services/registry/index.ts +37 -0
  31. package/examples/services/registry/registry.test.ts +135 -0
  32. package/examples/services/registry/routes.ts +76 -0
  33. package/examples/services/registry/store.ts +224 -0
  34. package/examples/services/registry/tools.ts +116 -0
  35. package/examples/services/reports/index.ts +14 -0
  36. package/examples/services/reports/reports.test.ts +75 -0
  37. package/examples/services/reports/routes.ts +42 -0
  38. package/examples/services/reports/store.ts +110 -0
  39. package/examples/services/ui/auth.ts +61 -0
  40. package/examples/services/ui/index.ts +16 -0
  41. package/examples/services/ui/routes.ts +160 -0
  42. package/examples/services/ui/static/app.js +369 -0
  43. package/examples/services/ui/static/index.html +42 -0
  44. package/examples/services/ui/static/style.css +157 -0
  45. package/examples/services/usage/behaviors.ts +166 -0
  46. package/examples/services/usage/index.ts +19 -0
  47. package/examples/services/usage/routes.ts +53 -0
  48. package/examples/services/usage/store.ts +341 -0
  49. package/examples/services/usage/tools.ts +75 -0
  50. package/examples/services/usage/usage.test.ts +91 -0
  51. package/package.json +29 -0
  52. package/services/agent/index.ts +465 -0
  53. package/services/board/index.ts +155 -0
  54. package/services/board/routes.ts +335 -0
  55. package/services/board/store.ts +329 -0
  56. package/services/board/tools.ts +214 -0
  57. package/services/docs/index.ts +391 -0
  58. package/services/feed/behaviors.ts +23 -0
  59. package/services/feed/index.ts +117 -0
  60. package/services/feed/routes.ts +224 -0
  61. package/services/feed/store.ts +194 -0
  62. package/services/feed/tools.ts +83 -0
  63. package/services/installer/index.ts +574 -0
  64. package/services/services/index.ts +165 -0
  65. package/services/ui/auth.ts +61 -0
  66. package/services/ui/index.ts +16 -0
  67. package/services/ui/routes.ts +160 -0
  68. package/services/ui/static/app.js +369 -0
  69. package/services/ui/static/index.html +42 -0
  70. package/services/ui/static/style.css +157 -0
  71. package/skills/create-service/SKILL.md +698 -0
  72. package/src/core/auth.ts +28 -0
  73. package/src/core/client.ts +99 -0
  74. package/src/core/discover.ts +152 -0
  75. package/src/core/events.ts +44 -0
  76. package/src/core/extension.ts +66 -0
  77. package/src/core/server.ts +262 -0
  78. package/src/core/testing.ts +155 -0
  79. package/src/core/types.ts +194 -0
  80. package/src/extension.ts +16 -0
  81. package/src/main.ts +11 -0
  82. package/tests/server.test.ts +1338 -0
  83. package/tsconfig.json +29 -0
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Usage behaviors — accumulate tokens/cost across turns, post session summary,
3
+ * track VM lifecycle, publish agent_stopped to feed with cost data.
4
+ */
5
+
6
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
7
+ import type { FleetClient } from "../src/core/types.js";
8
+
9
+ export function registerBehaviors(pi: ExtensionAPI, client: FleetClient) {
10
+ let sessionId = "";
11
+ let model = "";
12
+ let startedAt = "";
13
+ let turns = 0;
14
+ let tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
15
+ let cost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
16
+ let toolCalls: Record<string, number> = {};
17
+
18
+ function reset() {
19
+ startedAt = new Date().toISOString();
20
+ turns = 0;
21
+ tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
22
+ cost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
23
+ toolCalls = {};
24
+ }
25
+
26
+ pi.on("agent_start", async (_event, ctx) => {
27
+ reset();
28
+ sessionId = ctx.sessionManager.getSessionId();
29
+ model = ctx.model?.id || "unknown";
30
+ });
31
+
32
+ pi.on("turn_end", async (event, ctx) => {
33
+ turns++;
34
+ model = ctx.model?.id || model;
35
+
36
+ const msg = event.message as any;
37
+ if (msg?.role === "assistant" && msg?.usage) {
38
+ const u = msg.usage;
39
+ tokens.input += u.input || 0;
40
+ tokens.output += u.output || 0;
41
+ tokens.cacheRead += u.cacheRead || 0;
42
+ tokens.cacheWrite += u.cacheWrite || 0;
43
+ tokens.total += u.totalTokens || 0;
44
+
45
+ if (u.cost) {
46
+ cost.input += u.cost.input || 0;
47
+ cost.output += u.cost.output || 0;
48
+ cost.cacheRead += u.cost.cacheRead || 0;
49
+ cost.cacheWrite += u.cost.cacheWrite || 0;
50
+ cost.total += u.cost.total || 0;
51
+ }
52
+
53
+ // Live token update to feed
54
+ if (client.getBaseUrl()) {
55
+ const tokensThisTurn = (u.input || 0) + (u.output || 0);
56
+ client.api("POST", "/feed/events", {
57
+ type: "token_update",
58
+ agent: client.agentName,
59
+ summary: `${tokensThisTurn} tokens`,
60
+ detail: JSON.stringify({
61
+ agent: client.agentName,
62
+ tokensThisTurn,
63
+ totalTokens: tokens.total,
64
+ inputTokens: u.input || 0,
65
+ outputTokens: u.output || 0,
66
+ timestamp: Date.now(),
67
+ }),
68
+ }).catch(() => {});
69
+ }
70
+ }
71
+ });
72
+
73
+ // Count tool calls
74
+ pi.on("tool_result", async (event) => {
75
+ const name = event.toolName;
76
+ toolCalls[name] = (toolCalls[name] || 0) + 1;
77
+
78
+ // Track VM lifecycle from vers tool results
79
+ if (!client.getBaseUrl() || event.isError) return;
80
+
81
+ try {
82
+ const text = event.content
83
+ ?.filter((c: any) => c.type === "text")
84
+ .map((c: any) => c.text)
85
+ .join("");
86
+
87
+ if (name === "vers_vm_create" || name === "vers_vm_restore") {
88
+ const vmIdMatch = text?.match(/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})/);
89
+ if (vmIdMatch) {
90
+ await client.api("POST", "/usage/vms", {
91
+ vmId: vmIdMatch[1],
92
+ role: (event.input as any)?.role || "worker",
93
+ agent: client.agentName,
94
+ commitId: (event.input as any)?.commitId,
95
+ createdAt: new Date().toISOString(),
96
+ });
97
+ }
98
+ } else if (name === "vers_vm_delete") {
99
+ const vmId = (event.input as any)?.vmId || text?.match(/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})/)?.[1];
100
+ if (vmId) {
101
+ await client.api("POST", "/usage/vms", {
102
+ vmId, role: "worker", agent: client.agentName,
103
+ createdAt: new Date().toISOString(),
104
+ destroyedAt: new Date().toISOString(),
105
+ });
106
+ }
107
+ } else if (name === "vers_vm_commit") {
108
+ const inputVmId = (event.input as any)?.vmId;
109
+ if (inputVmId) {
110
+ const commitMatch = text?.match(/"commitId"\s*:\s*"([^"]+)"/);
111
+ await client.api("POST", "/usage/vms", {
112
+ vmId: inputVmId, role: "golden", agent: client.agentName,
113
+ commitId: commitMatch?.[1],
114
+ createdAt: new Date().toISOString(),
115
+ });
116
+ }
117
+ }
118
+ } catch {
119
+ // best-effort
120
+ }
121
+ });
122
+
123
+ // Post session summary + agent_stopped event on agent end
124
+ pi.on("agent_end", async () => {
125
+ if (!client.getBaseUrl()) return;
126
+
127
+ const endedAt = new Date().toISOString();
128
+
129
+ // Post session usage
130
+ try {
131
+ const roundedCost = {
132
+ input: Math.round(cost.input * 1e6) / 1e6,
133
+ output: Math.round(cost.output * 1e6) / 1e6,
134
+ cacheRead: Math.round(cost.cacheRead * 1e6) / 1e6,
135
+ cacheWrite: Math.round(cost.cacheWrite * 1e6) / 1e6,
136
+ total: Math.round(cost.total * 1e6) / 1e6,
137
+ };
138
+ await client.api("POST", "/usage/sessions", {
139
+ sessionId: sessionId || `session-${Date.now()}`,
140
+ agent: client.agentName,
141
+ parentAgent: process.env.VERS_PARENT_AGENT || null,
142
+ model,
143
+ tokens: { ...tokens },
144
+ cost: roundedCost,
145
+ turns,
146
+ toolCalls: { ...toolCalls },
147
+ startedAt: startedAt || endedAt,
148
+ endedAt,
149
+ });
150
+ } catch {
151
+ // best-effort
152
+ }
153
+
154
+ // Publish agent_stopped with cost data to feed
155
+ try {
156
+ const costStr = (Math.round(cost.total * 100) / 100).toFixed(2);
157
+ await client.api("POST", "/feed/events", {
158
+ agent: client.agentName,
159
+ type: "agent_stopped",
160
+ summary: `Agent ${client.agentName} finished (${turns} turns, ${tokens.total} tokens, $${costStr})`,
161
+ });
162
+ } catch {
163
+ // best-effort
164
+ }
165
+ });
166
+ }
@@ -0,0 +1,19 @@
1
+ import type { ServiceModule } from "../src/core/types.js";
2
+ import { UsageStore } from "./store.js";
3
+ import { createRoutes } from "./routes.js";
4
+ import { registerTools } from "./tools.js";
5
+ import { registerBehaviors } from "./behaviors.js";
6
+
7
+ const store = new UsageStore();
8
+
9
+ const usage: ServiceModule = {
10
+ name: "usage",
11
+ description: "Cost & token tracking",
12
+ routes: createRoutes(store),
13
+ store: { close: () => { store.close(); return Promise.resolve(); } },
14
+ registerTools,
15
+ registerBehaviors,
16
+ dependencies: ["feed"], // publishes agent_stopped to feed
17
+ };
18
+
19
+ export default usage;
@@ -0,0 +1,53 @@
1
+ import { Hono } from "hono";
2
+ import type { UsageStore } from "./store.js";
3
+ import { ValidationError } from "./store.js";
4
+
5
+ export function createRoutes(store: UsageStore): Hono {
6
+ const routes = new Hono();
7
+
8
+ routes.get("/", (c) => {
9
+ const range = c.req.query("range") || "7d";
10
+ return c.json(store.summary(range));
11
+ });
12
+
13
+ routes.post("/sessions", async (c) => {
14
+ try {
15
+ const body = await c.req.json();
16
+ const record = store.recordSession(body);
17
+ return c.json(record, 201);
18
+ } catch (e) {
19
+ if (e instanceof ValidationError) return c.json({ error: e.message }, 400);
20
+ throw e;
21
+ }
22
+ });
23
+
24
+ routes.get("/sessions", (c) => {
25
+ const sessions = store.listSessions({
26
+ agent: c.req.query("agent") || undefined,
27
+ range: c.req.query("range") || undefined,
28
+ });
29
+ return c.json({ sessions, count: sessions.length });
30
+ });
31
+
32
+ routes.post("/vms", async (c) => {
33
+ try {
34
+ const body = await c.req.json();
35
+ const record = store.recordVM(body);
36
+ return c.json(record, 201);
37
+ } catch (e) {
38
+ if (e instanceof ValidationError) return c.json({ error: e.message }, 400);
39
+ throw e;
40
+ }
41
+ });
42
+
43
+ routes.get("/vms", (c) => {
44
+ const vms = store.listVMs({
45
+ role: c.req.query("role") || undefined,
46
+ agent: c.req.query("agent") || undefined,
47
+ range: c.req.query("range") || undefined,
48
+ });
49
+ return c.json({ vms, count: vms.length });
50
+ });
51
+
52
+ return routes;
53
+ }
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Usage store — token/cost tracking. Uses Bun's built-in SQLite.
3
+ */
4
+
5
+ import { ulid } from "ulid";
6
+ import { Database } from "bun:sqlite";
7
+ import { existsSync, mkdirSync } from "node:fs";
8
+ import { dirname } from "node:path";
9
+
10
+ // --- Types ---
11
+
12
+ export interface TokenCounts {
13
+ input: number;
14
+ output: number;
15
+ cacheRead: number;
16
+ cacheWrite: number;
17
+ total: number;
18
+ }
19
+
20
+ export interface CostBreakdown {
21
+ input: number;
22
+ output: number;
23
+ cacheRead: number;
24
+ cacheWrite: number;
25
+ total: number;
26
+ }
27
+
28
+ export interface SessionRecord {
29
+ id: string;
30
+ sessionId: string;
31
+ agent: string;
32
+ parentAgent: string | null;
33
+ model: string;
34
+ tokens: TokenCounts;
35
+ cost: CostBreakdown;
36
+ turns: number;
37
+ toolCalls: Record<string, number>;
38
+ startedAt: string;
39
+ endedAt: string;
40
+ recordedAt: string;
41
+ }
42
+
43
+ export interface SessionInput {
44
+ sessionId: string;
45
+ agent: string;
46
+ parentAgent?: string | null;
47
+ model: string;
48
+ tokens: TokenCounts;
49
+ cost: CostBreakdown;
50
+ turns: number;
51
+ toolCalls?: Record<string, number>;
52
+ startedAt: string;
53
+ endedAt: string;
54
+ }
55
+
56
+ export interface VMRecord {
57
+ id: string;
58
+ vmId: string;
59
+ role: string;
60
+ agent: string;
61
+ commitId?: string;
62
+ createdAt: string;
63
+ destroyedAt?: string;
64
+ recordedAt: string;
65
+ }
66
+
67
+ export interface VMInput {
68
+ vmId: string;
69
+ role: string;
70
+ agent: string;
71
+ commitId?: string;
72
+ createdAt: string;
73
+ destroyedAt?: string;
74
+ }
75
+
76
+ export interface AgentUsage {
77
+ tokens: number;
78
+ cost: number;
79
+ sessions: number;
80
+ }
81
+
82
+ export interface UsageSummary {
83
+ range: string;
84
+ totals: { tokens: number; cost: number; sessions: number; vms: number };
85
+ byAgent: Record<string, AgentUsage>;
86
+ }
87
+
88
+ export class ValidationError extends Error {
89
+ constructor(message: string) { super(message); this.name = "ValidationError"; }
90
+ }
91
+
92
+ function parseDurationMs(duration: string): number | null {
93
+ const match = duration.match(/^(\d+)(h|d)$/);
94
+ if (!match) return null;
95
+ const value = parseInt(match[1], 10);
96
+ const unit = match[2];
97
+ if (unit === "h") return value * 3600_000;
98
+ if (unit === "d") return value * 86400_000;
99
+ return null;
100
+ }
101
+
102
+ // --- Store ---
103
+
104
+ export class UsageStore {
105
+ private db: Database;
106
+
107
+ constructor(dbPath = "data/usage.sqlite") {
108
+ const dir = dirname(dbPath);
109
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
110
+
111
+ this.db = new Database(dbPath);
112
+ this.db.exec("PRAGMA journal_mode=WAL");
113
+ this.initTables();
114
+ }
115
+
116
+ private initTables(): void {
117
+ this.db.exec(`
118
+ CREATE TABLE IF NOT EXISTS sessions (
119
+ id TEXT PRIMARY KEY,
120
+ session_id TEXT NOT NULL,
121
+ agent TEXT NOT NULL,
122
+ parent_agent TEXT,
123
+ model TEXT NOT NULL,
124
+ tokens_input INTEGER NOT NULL,
125
+ tokens_output INTEGER NOT NULL,
126
+ tokens_cache_read INTEGER NOT NULL,
127
+ tokens_cache_write INTEGER NOT NULL,
128
+ tokens_total INTEGER NOT NULL,
129
+ cost_input REAL NOT NULL,
130
+ cost_output REAL NOT NULL,
131
+ cost_cache_read REAL NOT NULL,
132
+ cost_cache_write REAL NOT NULL,
133
+ cost_total REAL NOT NULL,
134
+ turns INTEGER NOT NULL,
135
+ tool_calls TEXT NOT NULL,
136
+ started_at TEXT NOT NULL,
137
+ ended_at TEXT NOT NULL,
138
+ recorded_at TEXT NOT NULL
139
+ )
140
+ `);
141
+
142
+ this.db.exec(`
143
+ CREATE TABLE IF NOT EXISTS vm_records (
144
+ id TEXT PRIMARY KEY,
145
+ vm_id TEXT NOT NULL,
146
+ role TEXT NOT NULL,
147
+ agent TEXT NOT NULL,
148
+ commit_id TEXT,
149
+ created_at TEXT NOT NULL,
150
+ destroyed_at TEXT,
151
+ recorded_at TEXT NOT NULL
152
+ )
153
+ `);
154
+ }
155
+
156
+ recordSession(input: SessionInput): SessionRecord {
157
+ if (!input.sessionId?.trim()) throw new ValidationError("sessionId is required");
158
+ if (!input.agent?.trim()) throw new ValidationError("agent is required");
159
+ if (!input.model?.trim()) throw new ValidationError("model is required");
160
+ if (typeof input.turns !== "number" || input.turns < 0) throw new ValidationError("turns must be non-negative");
161
+ if (!input.startedAt) throw new ValidationError("startedAt is required");
162
+ if (!input.endedAt) throw new ValidationError("endedAt is required");
163
+
164
+ const id = ulid();
165
+ const now = new Date().toISOString();
166
+ const toolCalls = input.toolCalls || {};
167
+
168
+ this.db.run(
169
+ `INSERT INTO sessions VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
170
+ [
171
+ id, input.sessionId.trim(), input.agent.trim(),
172
+ input.parentAgent?.trim() || null, input.model.trim(),
173
+ input.tokens.input, input.tokens.output, input.tokens.cacheRead,
174
+ input.tokens.cacheWrite, input.tokens.total,
175
+ input.cost.input, input.cost.output, input.cost.cacheRead,
176
+ input.cost.cacheWrite, input.cost.total,
177
+ input.turns, JSON.stringify(toolCalls),
178
+ input.startedAt, input.endedAt, now,
179
+ ],
180
+ );
181
+
182
+ return {
183
+ id, sessionId: input.sessionId.trim(), agent: input.agent.trim(),
184
+ parentAgent: input.parentAgent?.trim() || null, model: input.model.trim(),
185
+ tokens: input.tokens, cost: input.cost, turns: input.turns,
186
+ toolCalls, startedAt: input.startedAt, endedAt: input.endedAt, recordedAt: now,
187
+ };
188
+ }
189
+
190
+ listSessions(filters?: { agent?: string; range?: string }): SessionRecord[] {
191
+ let sql = "SELECT * FROM sessions";
192
+ const conditions: string[] = [];
193
+ const params: any[] = [];
194
+
195
+ if (filters?.agent) {
196
+ conditions.push("agent = ?");
197
+ params.push(filters.agent);
198
+ }
199
+ if (filters?.range) {
200
+ const ms = parseDurationMs(filters.range);
201
+ if (ms !== null) {
202
+ conditions.push("started_at >= ?");
203
+ params.push(new Date(Date.now() - ms).toISOString());
204
+ }
205
+ }
206
+
207
+ if (conditions.length) sql += ` WHERE ${conditions.join(" AND ")}`;
208
+ sql += " ORDER BY started_at DESC";
209
+
210
+ return this.db.query(sql).all(...params).map(rowToSession);
211
+ }
212
+
213
+ recordVM(input: VMInput): VMRecord {
214
+ if (!input.vmId?.trim()) throw new ValidationError("vmId is required");
215
+ if (!input.agent?.trim()) throw new ValidationError("agent is required");
216
+ if (!input.createdAt) throw new ValidationError("createdAt is required");
217
+
218
+ // Check for destroy update
219
+ if (input.destroyedAt) {
220
+ const existing = this.db.query(
221
+ "SELECT * FROM vm_records WHERE vm_id = ? ORDER BY recorded_at DESC LIMIT 1"
222
+ ).get(input.vmId.trim()) as any;
223
+
224
+ if (existing) {
225
+ this.db.run("UPDATE vm_records SET destroyed_at = ? WHERE id = ?", [input.destroyedAt, existing.id]);
226
+ const updated = rowToVM(existing);
227
+ updated.destroyedAt = input.destroyedAt;
228
+ return updated;
229
+ }
230
+ }
231
+
232
+ const id = ulid();
233
+ const now = new Date().toISOString();
234
+
235
+ this.db.run(
236
+ "INSERT INTO vm_records VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
237
+ [id, input.vmId.trim(), input.role || "worker", input.agent.trim(),
238
+ input.commitId?.trim() || null, input.createdAt, input.destroyedAt || null, now],
239
+ );
240
+
241
+ return {
242
+ id, vmId: input.vmId.trim(), role: input.role || "worker",
243
+ agent: input.agent.trim(), commitId: input.commitId?.trim(),
244
+ createdAt: input.createdAt, destroyedAt: input.destroyedAt, recordedAt: now,
245
+ };
246
+ }
247
+
248
+ listVMs(filters?: { role?: string; agent?: string; range?: string }): VMRecord[] {
249
+ let sql = "SELECT * FROM vm_records";
250
+ const conditions: string[] = [];
251
+ const params: any[] = [];
252
+
253
+ if (filters?.role) { conditions.push("role = ?"); params.push(filters.role); }
254
+ if (filters?.agent) { conditions.push("agent = ?"); params.push(filters.agent); }
255
+ if (filters?.range) {
256
+ const ms = parseDurationMs(filters.range);
257
+ if (ms !== null) {
258
+ conditions.push("created_at >= ?");
259
+ params.push(new Date(Date.now() - ms).toISOString());
260
+ }
261
+ }
262
+
263
+ if (conditions.length) sql += ` WHERE ${conditions.join(" AND ")}`;
264
+ sql += " ORDER BY created_at DESC";
265
+
266
+ return this.db.query(sql).all(...params).map(rowToVM);
267
+ }
268
+
269
+ summary(range = "7d"): UsageSummary {
270
+ const ms = parseDurationMs(range);
271
+ const cutoff = ms !== null ? new Date(Date.now() - ms).toISOString() : new Date(0).toISOString();
272
+
273
+ const agentRows = this.db.query(`
274
+ SELECT agent, SUM(tokens_total) as tokens, ROUND(SUM(cost_total), 2) as cost, COUNT(*) as sessions
275
+ FROM sessions WHERE started_at >= ? GROUP BY agent ORDER BY cost DESC
276
+ `).all(cutoff) as any[];
277
+
278
+ const totalRow = this.db.query(`
279
+ SELECT COALESCE(SUM(tokens_total), 0) as tokens, ROUND(COALESCE(SUM(cost_total), 0), 2) as cost, COUNT(*) as sessions
280
+ FROM sessions WHERE started_at >= ?
281
+ `).get(cutoff) as any;
282
+
283
+ const vmRow = this.db.query(
284
+ "SELECT COUNT(*) as vms FROM vm_records WHERE created_at >= ?"
285
+ ).get(cutoff) as any;
286
+
287
+ const byAgent: Record<string, AgentUsage> = {};
288
+ for (const row of agentRows) {
289
+ byAgent[row.agent] = {
290
+ tokens: Number(row.tokens),
291
+ cost: Number(row.cost),
292
+ sessions: Number(row.sessions),
293
+ };
294
+ }
295
+
296
+ return {
297
+ range,
298
+ totals: {
299
+ tokens: Number(totalRow?.tokens || 0),
300
+ cost: Number(totalRow?.cost || 0),
301
+ sessions: Number(totalRow?.sessions || 0),
302
+ vms: Number(vmRow?.vms || 0),
303
+ },
304
+ byAgent,
305
+ };
306
+ }
307
+
308
+ close(): void {
309
+ this.db.close();
310
+ }
311
+ }
312
+
313
+ function rowToSession(row: any): SessionRecord {
314
+ return {
315
+ id: row.id, sessionId: row.session_id, agent: row.agent,
316
+ parentAgent: row.parent_agent || null, model: row.model,
317
+ tokens: {
318
+ input: row.tokens_input, output: row.tokens_output,
319
+ cacheRead: row.tokens_cache_read, cacheWrite: row.tokens_cache_write,
320
+ total: row.tokens_total,
321
+ },
322
+ cost: {
323
+ input: row.cost_input, output: row.cost_output,
324
+ cacheRead: row.cost_cache_read, cacheWrite: row.cost_cache_write,
325
+ total: row.cost_total,
326
+ },
327
+ turns: row.turns,
328
+ toolCalls: JSON.parse(row.tool_calls || "{}"),
329
+ startedAt: row.started_at, endedAt: row.ended_at, recordedAt: row.recorded_at,
330
+ };
331
+ }
332
+
333
+ function rowToVM(row: any): VMRecord {
334
+ const record: VMRecord = {
335
+ id: row.id, vmId: row.vm_id, role: row.role, agent: row.agent,
336
+ createdAt: row.created_at, recordedAt: row.recorded_at,
337
+ };
338
+ if (row.commit_id) record.commitId = row.commit_id;
339
+ if (row.destroyed_at) record.destroyedAt = row.destroyed_at;
340
+ return record;
341
+ }
@@ -0,0 +1,75 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import type { FleetClient } from "../src/core/types.js";
3
+ import { Type } from "@sinclair/typebox";
4
+ import { StringEnum } from "@mariozechner/pi-ai";
5
+
6
+ export function registerTools(pi: ExtensionAPI, client: FleetClient) {
7
+ pi.registerTool({
8
+ name: "usage_summary",
9
+ label: "Usage: Summary",
10
+ description: "Get cost & token usage summary across the agent fleet.",
11
+ parameters: Type.Object({
12
+ range: Type.Optional(Type.String({ description: 'Time range, e.g. "7d", "30d", "24h" (default: "7d")' })),
13
+ }),
14
+ async execute(_id, params) {
15
+ if (!client.getBaseUrl()) return client.noUrl();
16
+ try {
17
+ const qs = new URLSearchParams();
18
+ if (params.range) qs.set("range", params.range);
19
+ const query = qs.toString();
20
+ const result = await client.api("GET", `/usage${query ? `?${query}` : ""}`);
21
+ return client.ok(JSON.stringify(result, null, 2), { result });
22
+ } catch (e: any) {
23
+ return client.err(e.message);
24
+ }
25
+ },
26
+ });
27
+
28
+ pi.registerTool({
29
+ name: "usage_sessions",
30
+ label: "Usage: Sessions",
31
+ description: "List session usage records — tokens, cost, turns, tool calls per session.",
32
+ parameters: Type.Object({
33
+ agent: Type.Optional(Type.String({ description: "Filter by agent name" })),
34
+ range: Type.Optional(Type.String({ description: 'Time range, e.g. "7d", "30d", "24h"' })),
35
+ }),
36
+ async execute(_id, params) {
37
+ if (!client.getBaseUrl()) return client.noUrl();
38
+ try {
39
+ const qs = new URLSearchParams();
40
+ if (params.agent) qs.set("agent", params.agent);
41
+ if (params.range) qs.set("range", params.range);
42
+ const query = qs.toString();
43
+ const result = await client.api("GET", `/usage/sessions${query ? `?${query}` : ""}`);
44
+ return client.ok(JSON.stringify(result, null, 2), { result });
45
+ } catch (e: any) {
46
+ return client.err(e.message);
47
+ }
48
+ },
49
+ });
50
+
51
+ pi.registerTool({
52
+ name: "usage_vms",
53
+ label: "Usage: VMs",
54
+ description: "List VM lifecycle records — creation, commit, destruction events.",
55
+ parameters: Type.Object({
56
+ role: Type.Optional(StringEnum(["orchestrator", "lieutenant", "worker", "infra", "golden"] as const)),
57
+ agent: Type.Optional(Type.String({ description: "Filter by agent name" })),
58
+ range: Type.Optional(Type.String({ description: 'Time range, e.g. "7d", "30d", "24h"' })),
59
+ }),
60
+ async execute(_id, params) {
61
+ if (!client.getBaseUrl()) return client.noUrl();
62
+ try {
63
+ const qs = new URLSearchParams();
64
+ if (params.role) qs.set("role", params.role);
65
+ if (params.agent) qs.set("agent", params.agent);
66
+ if (params.range) qs.set("range", params.range);
67
+ const query = qs.toString();
68
+ const result = await client.api("GET", `/usage/vms${query ? `?${query}` : ""}`);
69
+ return client.ok(JSON.stringify(result, null, 2), { result });
70
+ } catch (e: any) {
71
+ return client.err(e.message);
72
+ }
73
+ },
74
+ });
75
+ }