diff --git a/README.md b/README.md index 775a704..04c48fd 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,9 @@ structured, typed tools. `get_session`, `search_sessions`, `get_top_models`, `get_top_providers`, `get_weekly_activity` +`get_overview` accepts an optional `directory` string — when provided, returns +aggregate stats for only that directory. Omit for all directories. + Register with OpenCode by adding to `.opencode/opencode.jsonc` in any project: ```json diff --git a/deno.json b/deno.json index 14becf8..6cc773a 100644 --- a/deno.json +++ b/deno.json @@ -13,7 +13,8 @@ "compile": "deno compile --allow-read --allow-write --allow-env --allow-ffi --output ocv main.ts", "compile-all": "deno compile --allow-read --allow-write --allow-env --allow-ffi --output ocv-x86_64-linux --target x86_64-unknown-linux-gnu main.ts && deno compile --allow-read --allow-write --allow-env --allow-ffi --output ocv-x86_64-macos --target x86_64-apple-darwin main.ts && deno compile --allow-read --allow-write --allow-env --allow-ffi --output ocv-aarch64-macos --target aarch64-apple-darwin main.ts && deno compile --allow-read --allow-write --allow-env --allow-ffi --output ocv-aarch64-linux --target aarch64-unknown-linux-gnu main.ts", "start": "deno run --allow-read --allow-write --allow-env --allow-ffi --allow-net main.ts", - "check": "deno check main.ts" + "check": "deno check main.ts", + "test": "deno test --allow-read --allow-write --allow-env --allow-ffi" }, "imports": { "@cliffy/command": "jsr:@cliffy/command@^1.0.0-rc.7", diff --git a/deno.lock b/deno.lock index 8bc2e35..9003ba7 100644 --- a/deno.lock +++ b/deno.lock @@ -10,11 +10,13 @@ "jsr:@db/sqlite@0.12.0": "0.12.0", "jsr:@denosaurs/plug@1": "1.1.0", "jsr:@std/assert@0.217": "0.217.0", + "jsr:@std/assert@1": "1.0.19", "jsr:@std/encoding@1": "1.0.10", "jsr:@std/fmt@*": "1.0.10", "jsr:@std/fmt@1": "1.0.10", "jsr:@std/fmt@^1.0.9": "1.0.10", "jsr:@std/fs@1": "1.0.24", + "jsr:@std/internal@^1.0.12": "1.0.14", "jsr:@std/internal@^1.0.14": "1.0.14", "jsr:@std/path@0.217": "0.217.0", "jsr:@std/path@1": "1.1.5", @@ -70,6 +72,12 @@ "@std/assert@0.217.0": { "integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642" }, + "@std/assert@1.0.19": { + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", + "dependencies": [ + "jsr:@std/internal@^1.0.12" + ] + }, "@std/encoding@1.0.10": { "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" }, @@ -79,7 +87,7 @@ "@std/fs@1.0.24": { "integrity": "f3061b45b81673a2bece689da041df32d174be064c89eb6397fb5718d3fb7877", "dependencies": [ - "jsr:@std/internal", + "jsr:@std/internal@^1.0.14", "jsr:@std/path@^1.1.5" ] }, @@ -89,13 +97,13 @@ "@std/path@0.217.0": { "integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11", "dependencies": [ - "jsr:@std/assert" + "jsr:@std/assert@0.217" ] }, "@std/path@1.1.5": { "integrity": "ccea00982ea28c36becaf6e62f855406c76a8c32d462f66f415bbb7d83a271bc", "dependencies": [ - "jsr:@std/internal" + "jsr:@std/internal@^1.0.14" ] }, "@std/text@1.0.19": { diff --git a/lib/db.ts b/lib/db.ts index 2a96a6d..4f640ab 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -269,9 +269,12 @@ export async function getDbStats( /** * Group sessions by directory returning aggregate stats per directory. */ -export function getDirectoryOverview(db: Database): DirectoryOverviewRow[] { - return convertRow( - db.prepare(` +export function getDirectoryOverview( + db: Database, + directory?: string, +): DirectoryOverviewRow[] { + const sql = directory + ? ` SELECT directory, COUNT(*) AS total, @@ -287,9 +290,31 @@ export function getDirectoryOverview(db: Database): DirectoryOverviewRow[] { COALESCE(SUM(cost), 0) AS cost, MAX(time_created) AS last_active FROM session + WHERE directory = ? GROUP BY directory ORDER BY total DESC - `).all(), + ` + : ` + SELECT + directory, + COUNT(*) AS total, + SUM(CASE WHEN time_archived IS NULL THEN 1 ELSE 0 END) AS active, + SUM(CASE WHEN time_archived IS NOT NULL THEN 1 ELSE 0 END) AS archived, + SUM(CASE WHEN parent_id IS NULL THEN 1 ELSE 0 END) AS main_count, + SUM(CASE WHEN parent_id IS NOT NULL THEN 1 ELSE 0 END) AS sub_count, + COALESCE(SUM(tokens_input), 0) AS tokens_input, + COALESCE(SUM(tokens_output), 0) AS tokens_output, + COALESCE(SUM(tokens_reasoning), 0) AS tokens_reasoning, + COALESCE(SUM(tokens_cache_read), 0) AS tokens_cache_read, + COALESCE(SUM(tokens_cache_write), 0) AS tokens_cache_write, + COALESCE(SUM(cost), 0) AS cost, + MAX(time_created) AS last_active + FROM session + GROUP BY directory + ORDER BY total DESC + `; + return convertRow( + directory ? db.prepare(sql).all(directory) : db.prepare(sql).all(), ) as DirectoryOverviewRow[]; } diff --git a/lib/db_test.ts b/lib/db_test.ts new file mode 100644 index 0000000..3fc5ada --- /dev/null +++ b/lib/db_test.ts @@ -0,0 +1,155 @@ +// deno-lint-ignore-file no-import-prefix + +import { Database } from "@db/sqlite"; +import { assertAlmostEquals, assertEquals } from "jsr:@std/assert@^1.0.0"; +import { getDirectoryOverview } from "./db.ts"; + +function createTestDb(): Database { + const db = new Database(":memory:", { int64: true }); + db.exec(` + CREATE TABLE session ( + id TEXT PRIMARY KEY, + directory TEXT NOT NULL, + parent_id TEXT, + time_archived INTEGER, + tokens_input INTEGER DEFAULT 0, + tokens_output INTEGER DEFAULT 0, + tokens_reasoning INTEGER DEFAULT 0, + tokens_cache_read INTEGER DEFAULT 0, + tokens_cache_write INTEGER DEFAULT 0, + cost REAL DEFAULT 0, + time_created INTEGER + ) + `); + + // dir-a: 2 sessions (1 main + 1 sub), both active + db.prepare( + `INSERT INTO session (id, directory, parent_id, time_archived, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write, cost, time_created) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + "s1", + "dir-a", + null, + null, + 100, + 50, + 10, + 20, + 5, + 0.0015, + 1000, + ); + + db.prepare( + `INSERT INTO session (id, directory, parent_id, time_archived, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write, cost, time_created) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + "s2", + "dir-a", + "s1", + null, + 200, + 100, + 20, + 40, + 10, + 0.003, + 2000, + ); + + // dir-b: 1 session (main), archived + db.prepare( + `INSERT INTO session (id, directory, parent_id, time_archived, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write, cost, time_created) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + "s3", + "dir-b", + null, + 5000, + 50, + 25, + 5, + 10, + 2, + 0.00075, + 3000, + ); + + return db; +} + +Deno.test("returns all directories when no filter", () => { + const db = createTestDb(); + try { + const rows = getDirectoryOverview(db); + + assertEquals(rows.length, 2); + + const row0 = rows[0]; + assertEquals(row0.directory, "dir-a"); + assertEquals(row0.total, 2); + assertEquals(row0.active, 2); + assertEquals(row0.archived, 0); + assertEquals(row0.main_count, 1); + assertEquals(row0.sub_count, 1); + assertEquals(row0.tokens_input, 300); + assertEquals(row0.tokens_output, 150); + assertEquals(row0.tokens_reasoning, 30); + assertEquals(row0.tokens_cache_read, 60); + assertEquals(row0.tokens_cache_write, 15); + assertAlmostEquals(row0.cost, 0.0045); + assertEquals(row0.last_active, 2000); + + const row1 = rows[1]; + assertEquals(row1.directory, "dir-b"); + assertEquals(row1.total, 1); + assertEquals(row1.active, 0); + assertEquals(row1.archived, 1); + assertEquals(row1.main_count, 1); + assertEquals(row1.sub_count, 0); + assertEquals(row1.tokens_input, 50); + assertEquals(row1.tokens_output, 25); + assertEquals(row1.tokens_reasoning, 5); + assertEquals(row1.tokens_cache_read, 10); + assertEquals(row1.tokens_cache_write, 2); + assertAlmostEquals(row1.cost, 0.00075); + assertEquals(row1.last_active, 3000); + } finally { + db.close(); + } +}); + +Deno.test("filters to one directory when filter provided", () => { + const db = createTestDb(); + try { + const rows = getDirectoryOverview(db, "dir-a"); + + assertEquals(rows.length, 1); + const row = rows[0]; + assertEquals(row.directory, "dir-a"); + assertEquals(row.total, 2); + assertEquals(row.active, 2); + assertEquals(row.archived, 0); + assertEquals(row.main_count, 1); + assertEquals(row.sub_count, 1); + assertEquals(row.tokens_input, 300); + assertEquals(row.tokens_output, 150); + assertEquals(row.tokens_reasoning, 30); + assertEquals(row.tokens_cache_read, 60); + assertEquals(row.tokens_cache_write, 15); + assertAlmostEquals(row.cost, 0.0045); + assertEquals(row.last_active, 2000); + } finally { + db.close(); + } +}); + +Deno.test("returns empty for non-existent directory", () => { + const db = createTestDb(); + try { + const rows = getDirectoryOverview(db, "__nonexistent__"); + assertEquals(rows.length, 0); + } finally { + db.close(); + } +}); diff --git a/lib/mcp.ts b/lib/mcp.ts index c77a263..1643ee3 100644 --- a/lib/mcp.ts +++ b/lib/mcp.ts @@ -51,7 +51,12 @@ export function createMcpServer(dbPath: string): Server { description: "Get per-directory session overview with aggregate stats", inputSchema: { type: "object", - properties: {}, + properties: { + directory: { + type: "string", + description: "Optional. Filter to one directory. Omit for all.", + }, + }, required: [], }, }, @@ -153,7 +158,10 @@ export function createMcpServer(dbPath: string): Server { result = await getDbStats(db, dbPath); break; case "get_overview": - result = getDirectoryOverview(db); + result = getDirectoryOverview( + db, + typeof args?.directory === "string" ? args.directory : undefined, + ); break; case "list_sessions": if (!args?.directory || typeof args.directory !== "string") { diff --git a/version.ts b/version.ts index b44ee38..28047c0 100644 --- a/version.ts +++ b/version.ts @@ -1 +1 @@ -export const VERSION = '1.4.0'; +export const VERSION = "1.4.0";