diff --git a/.claude/rules/typescript.md b/.claude/rules/typescript.md index bbf9a92..333a90d 100644 --- a/.claude/rules/typescript.md +++ b/.claude/rules/typescript.md @@ -13,7 +13,7 @@ description: TypeScript and React code style conventions ## JSDoc -JSDoc block above every function/component — no `@param`/`@returns` tags; TypeScript is the source of truth. Document each param inline in the type. +JSDoc block above every function/component — no `@param`/`@returns` tags; TypeScript is the source of truth. Document each param inline in the type. Also do the same for `interface` and `type` declarations. ```typescript /** @@ -25,7 +25,30 @@ const myFunc = ({ }: { /** What bar represents */ bar: number; + /** What foo represents */ foo?: string; }): void => {}; + +/** + * Represents an animal of the feline species + */ +interface Cat { + /** The color of the fur **/ + color: string; + + /** The weight in pounds **/ + weight: number; +} + +/** + * Represents an animal within our system + */ +type Animal = { + /** The id of the animal in our system **/ + id: string; + + /** The type of animal **/ + type: Cat | Dog; +}; ``` diff --git a/docs/testing-locally.md b/docs/testing-locally.md new file mode 100644 index 0000000..edea3e9 --- /dev/null +++ b/docs/testing-locally.md @@ -0,0 +1,61 @@ +# Testing the CLI locally against localhost + +Use these steps to test `install` / `uninstall` against a local AgentMeter server and verify the service survives reboots. + +## 1. Build and init + +```bash +pnpm build + +AGENTMETER_API_KEY= \ +AGENTMETER_API_URL=http://localhost:3000 \ +pnpm cli init +``` + +This writes both values to `~/.agentmeter/config.json`. + +## 2. Install the service + +```bash +pnpm cli install +``` + +This bakes `AGENTMETER_API_KEY` and `AGENTMETER_API_URL` into the launchd plist (macOS) or systemd unit (Linux) so the service is self-contained after reboot. + +## 3. Verify the plist looks right (macOS) + +```bash +cat ~/Library/LaunchAgents/com.agentmeter.sync.plist +``` + +Both env vars should appear in the `EnvironmentVariables` dict. + +## 4. Check it's running + +```bash +pnpm cli status +launchctl list com.agentmeter.sync +``` + +## 5. Watch the logs + +```bash +tail -f ~/.agentmeter/sync.log +``` + +## 6. Test reboot persistence + +Restart your machine. After login: + +```bash +launchctl list com.agentmeter.sync # should show a PID +tail ~/.agentmeter/sync.log # should show sync activity +``` + +> **Note:** `localhost:3000` won't be running automatically after reboot, so the service will log connection errors until your Next.js server is started. That's expected — it confirms the reboot persistence mechanism is working correctly. + +## Uninstall when done + +```bash +pnpm cli uninstall +``` diff --git a/packages/cli/__tests__/commands/sync.test.ts b/packages/cli/__tests__/commands/sync.test.ts index 7864efb..66e2e1c 100644 --- a/packages/cli/__tests__/commands/sync.test.ts +++ b/packages/cli/__tests__/commands/sync.test.ts @@ -15,6 +15,20 @@ vi.mock('../../src/utils/platform.js', () => ({ getPlatform: () => 'macos', })); +// Prevent the Cursor scanner from hitting real system files in tests — +// it would scan the developer's actual Cursor data dir if not mocked. +vi.mock('../../src/scanners/cursor.js', () => ({ + CursorScanner: class { + readonly name = 'cursor'; + async isAvailable() { + return false; + } + async scan() { + return []; + } + }, +})); + vi.mock('../../src/services/logger.js', () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, setForegroundMode: vi.fn(), diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index f3d5d75..4a12a19 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -6,6 +6,9 @@ import pc from 'picocolors'; import { ApiClient } from '../services/api.js'; import { writeConfig } from '../services/config.js'; +/** + * Returns a truncated preview of an API key safe for display in terminal output + */ function maskKey(key: string): string { if (key.length <= 8) return `${key.slice(0, 4)}...`; return `${key.slice(0, 8)}...`; diff --git a/packages/cli/src/commands/sync.ts b/packages/cli/src/commands/sync.ts index d5da584..ca36f35 100644 --- a/packages/cli/src/commands/sync.ts +++ b/packages/cli/src/commands/sync.ts @@ -11,23 +11,51 @@ import { logger } from '../services/logger.js'; import { readSyncState, writeSyncState } from '../services/sync-state.js'; import { formatCost, formatDuration } from '../utils/format.js'; +/** + * Runtime options for a sync run, controlling output and filtering + */ export interface SyncOptions { + /** Whether to print a row for every session processed */ verbose: boolean; + + /** When true, reports what would be submitted without sending any data */ dryRun: boolean; + + /** ISO 8601 date string; only sessions starting on or after this date are included */ since?: string; + + /** Scanner name filter (e.g. "claude"); omit to include all available scanners */ engine?: string; } +/** + * Aggregated counts and cost totals returned from a completed sync run + */ export interface SyncResult { + /** Number of sessions submitted for the first time */ newCount: number; + + /** Number of sessions re-submitted because their status or endTime changed */ updatedCount: number; + + /** Number of sessions that were already up-to-date and skipped */ skippedCount: number; + + /** Number of sessions that failed to submit */ errorCount: number; + + /** Sum of costCents across all successfully submitted sessions */ totalCostCents: number; } +/** + * Sessions split into those needing submission and those already up-to-date + */ interface SessionClassification { + /** Sessions to submit, tagged with whether they are new or just updated */ toSync: Array<{ session: LocalSession; isNew: boolean }>; + + /** Sessions that match their already-synced state and can be skipped */ skipped: LocalSession[]; } @@ -66,7 +94,11 @@ function classifySessions(sessions: LocalSession[], syncState: SyncState): Sessi const existing = syncState.sessions[session.sessionId]; if (!existing) { toSync.push({ session, isNew: true }); - } else if (existing.status !== session.status) { + } else if ( + existing.status !== session.status || + existing.endTime !== (session.endTime ?? null) || + (existing.title ?? null) !== (session.title ?? null) + ) { toSync.push({ session, isNew: false }); } else { skipped.push(session); @@ -139,6 +171,8 @@ async function submitAll( status: session.status, submittedAt: new Date().toISOString(), costCents: result.costCents ?? null, + endTime: session.endTime ?? null, + title: session.title ?? null, }; if (result.costCents) totalCostCents += result.costCents; diff --git a/packages/cli/src/scanners/claude.ts b/packages/cli/src/scanners/claude.ts index 183886b..1f6d9fb 100644 --- a/packages/cli/src/scanners/claude.ts +++ b/packages/cli/src/scanners/claude.ts @@ -37,10 +37,12 @@ const JournalEntrySchema = z uuid: z.string().optional(), timestamp: z.string().optional(), cwd: z.string().optional(), + aiTitle: z.string().optional(), message: MessageSchema.optional(), }) .passthrough(); +/** A single line parsed from a Claude Code JSONL session file */ type JournalEntry = z.infer; /** @@ -81,11 +83,19 @@ function stripMarkdownHeading(text: string): string { } /** - * Finds the first meaningful user message to use as the session title. - * Skips entries whose content begins with an XML-style tag (e.g. ). - * Strips leading markdown heading syntax (e.g. # My Title → My Title). + * Extracts the session title, preferring Claude Code's AI-generated title + * (from ai-title entries) over the first meaningful user message. */ function extractTitle(entries: JournalEntry[]): string | null { + // Prefer the AI-generated title Claude Code writes to the JSONL. + // Iterate in reverse — Claude Code rewrites the title as the conversation progresses, + // so the last ai-title entry is the most accurate. + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + if (entry?.type === 'ai-title' && entry.aiTitle) return entry.aiTitle.slice(0, 120); + } + + // Fall back to first meaningful user message for (const entry of entries) { if (entry.type !== 'user' || !entry.message) continue; const content = entry.message.content; @@ -199,10 +209,19 @@ function extractTiming(entries: JournalEntry[]): { return { startTime, endTime, durationSeconds }; } +const RUNNING_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes + /** - * Determines session status from the last assistant stop_reason + * Determines session status. Returns 'running' if the last entry timestamp is + * within 30 minutes — the session is likely still active. Otherwise uses the + * last assistant stop_reason to distinguish success from failure. */ function extractStatus(entries: JournalEntry[]): LocalSession['status'] { + const lastTimestamp = [...entries].reverse().find((e) => e.timestamp)?.timestamp; + if (lastTimestamp && Date.now() - new Date(lastTimestamp).getTime() < RUNNING_THRESHOLD_MS) { + return 'running'; + } + for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; if (entry?.type === 'assistant' && entry.message?.stop_reason) { diff --git a/packages/cli/src/scanners/cursor.ts b/packages/cli/src/scanners/cursor.ts index 6bb3c7b..abaa5ea 100644 --- a/packages/cli/src/scanners/cursor.ts +++ b/packages/cli/src/scanners/cursor.ts @@ -1,10 +1,216 @@ import fs from 'node:fs'; +import { createRequire } from 'node:module'; import os from 'node:os'; import path from 'node:path'; +// Type-only import: erased at compile time so Vite/esbuild never statically +// resolves node:sqlite. The actual class is loaded at runtime via createRequire +// (a CJS-style require that bypasses esbuild's static import analysis). +import type { DatabaseSync } from 'node:sqlite'; +import { z } from 'zod'; import type { LocalSession } from '../schemas/session.js'; import { logger } from '../services/logger.js'; +import { resolveRepoFullName } from '../utils/repo.js'; import type { SessionScanner } from './types.js'; +// createRequire lets us load node:sqlite at runtime without esbuild stripping +// the node: prefix (which it does for ESM dynamic imports of experimental modules). +const _require = createRequire(import.meta.url); + +// ─── Zod schemas ─────────────────────────────────────────────────────────── + +const WorkspaceUriSchema = z + .object({ + /** Absolute filesystem path to the workspace folder */ + fsPath: z.string().optional(), + /** Workspace folder path (may differ from fsPath on non-macOS) */ + path: z.string().optional(), + /** Full file URI, e.g. "file:///Users/adam/Projects/my-app" */ + external: z.string().optional(), + }) + .passthrough(); + +const WorkspaceIdentifierSchema = z + .object({ + uri: WorkspaceUriSchema.optional(), + }) + .passthrough(); + +const ComposerHeaderSchema = z + .object({ + /** UUID that links to agentKv:bubbleCheckpoint:{composerId}:* keys */ + composerId: z.string(), + /** Unix milliseconds */ + createdAt: z.number().optional(), + /** Unix milliseconds */ + lastUpdatedAt: z.number().optional(), + /** AI-generated session title */ + name: z.string().optional(), + /** Session interaction mode */ + unifiedMode: z.enum(['chat', 'agent', 'plan']).catch('chat').optional(), + /** Whether the session was archived by the user */ + isArchived: z.boolean().optional(), + workspaceIdentifier: WorkspaceIdentifierSchema.optional(), + }) + .passthrough(); + +const ComposerHeadersSchema = z + .object({ + allComposers: z.array(ComposerHeaderSchema).optional(), + }) + .passthrough(); + +/** Decoded data extracted from a single bubble checkpoint protobuf blob */ +interface BubbleData { + modelName: string | null; + contextTokens: number | null; +} + +// ─── Minimal protobuf decoder ────────────────────────────────────────────── + +/** + * Reads a protobuf varint from buf starting at pos. + * Returns [value, nextPos]. Safe for values up to 2^53. + */ +function readVarint(buf: Buffer, pos: number): [value: number, nextPos: number] { + let result = 0; + let multiplier = 1; + let offset = pos; + while (offset < buf.length) { + const byte = buf[offset]; + if (byte === undefined) break; + offset++; + result += (byte & 0x7f) * multiplier; + multiplier *= 128; + if ((byte & 0x80) === 0) break; + } + return [result, offset]; +} + +/** + * Extracts the modelName from a Cursor assistant message JSON blob (field 4). + * The JSON has shape: { role, content: [{ providerOptions: { cursor: { modelName } } }] } + */ +function extractModelFromJson(jsonStr: string): string | null { + let parsed: unknown; + try { + parsed = JSON.parse(jsonStr); + } catch { + return null; + } + if (!parsed || typeof parsed !== 'object') return null; + const p = parsed as Record; + const content = p.content; + if (!Array.isArray(content)) return null; + for (const item of content) { + if (!item || typeof item !== 'object') continue; + const opts = (item as Record).providerOptions; + if (!opts || typeof opts !== 'object') continue; + const cursor = (opts as Record).cursor; + if (!cursor || typeof cursor !== 'object') continue; + const mn = (cursor as Record).modelName; + if (typeof mn === 'string' && mn.length > 0) return mn; + } + return null; +} + +/** + * Decodes a bubble checkpoint protobuf hex blob to extract the model name + * (from field 4 JSON) and cumulative context token count (from field 5, sub-field 1). + * Gracefully returns nulls for any field that can't be extracted. + */ +function decodeBubbleCheckpoint(hex: string): BubbleData { + let buf: Buffer; + try { + buf = Buffer.from(hex, 'hex'); + } catch { + return { modelName: null, contextTokens: null }; + } + + let modelName: string | null = null; + let contextTokens: number | null = null; + let offset = 0; + + while (offset < buf.length) { + let tag: number; + [tag, offset] = readVarint(buf, offset); + if (offset >= buf.length && tag === 0) break; + + const fieldNumber = tag >>> 3; + const wireType = tag & 0x7; + + if (wireType === 0) { + [, offset] = readVarint(buf, offset); + } else if (wireType === 2) { + const [len, dataStart] = readVarint(buf, offset); + const dataEnd = dataStart + len; + + if (fieldNumber === 4 && modelName === null) { + const jsonStr = buf.toString('utf8', dataStart, dataEnd); + modelName = extractModelFromJson(jsonStr); + } else if (fieldNumber === 5 && contextTokens === null) { + const nested = buf.subarray(dataStart, dataEnd); + let nOffset = 0; + while (nOffset < nested.length) { + let nTag: number; + [nTag, nOffset] = readVarint(nested, nOffset); + const nField = nTag >>> 3; + const nWire = nTag & 0x7; + if (nWire === 0) { + let val: number; + [val, nOffset] = readVarint(nested, nOffset); + if (nField === 1) { + contextTokens = val; + break; + } + } else { + break; + } + } + } + + offset = dataEnd; + } else if (wireType === 1) { + offset += 8; + } else if (wireType === 5) { + offset += 4; + } else { + break; + } + } + + return { modelName, contextTokens }; +} + +// ─── Model name normalization ────────────────────────────────────────────── + +/** + * Normalizes a Cursor model name to the AgentMeter pricing matrix key format. + * + * Cursor uses `claude-{version}-{family}[-{variant}]` (e.g. "claude-4.5-sonnet-thinking"), + * while the AgentMeter matrix uses `claude-{family}-{version}` (e.g. "claude-sonnet-4-5"). + * GPT models may have trailing quality qualifiers (-high/-medium/-low) that are stripped. + */ +function normalizeCursorModel(raw: string): string { + const claudeMatch = raw.match(/^claude-(\d+\.\d+)-(sonnet|haiku|opus)/i); + if (claudeMatch) { + const version = claudeMatch[1]?.replace('.', '-') ?? ''; + const family = claudeMatch[2]?.toLowerCase() ?? ''; + return `claude-${family}-${version}`; + } + if (raw.startsWith('gpt-')) { + return raw.replace(/-(high|medium|low)$/, ''); + } + return raw; +} + +// ─── Platform paths ──────────────────────────────────────────────────────── + +/** + * Returns the platform-specific path to Cursor's application data directory. + * macOS: ~/Library/Application Support/Cursor + * Linux: ~/.config/Cursor + * Windows: ~/AppData/Roaming/Cursor + */ function getCursorDataDir(): string { if (process.platform === 'darwin') { return path.join(os.homedir(), 'Library', 'Application Support', 'Cursor'); @@ -15,9 +221,249 @@ function getCursorDataDir(): string { return path.join(os.homedir(), 'AppData', 'Roaming', 'Cursor'); } +/** + * Returns the path to Cursor's global state DB. + * This DB holds both session headers (composer.composerHeaders in ItemTable) + * and all bubble checkpoint blobs (agentKv:* in cursorDiskKV). + */ +function getGlobalDbPath(): string { + return path.join(getCursorDataDir(), 'User', 'globalStorage', 'state.vscdb'); +} + +// ─── Database helpers ────────────────────────────────────────────────────── + +/** + * Opens a SQLite DB at the given path. Returns null if the file doesn't exist + * or cannot be opened (e.g. locked by Cursor). + */ +function openDb(DbClass: typeof DatabaseSync, dbPath: string): DatabaseSync | null { + try { + return new DbClass(dbPath); + } catch { + return null; + } +} + +/** + * Reads a single text value from ItemTable by key. Returns null if missing or on error. + */ +function readItemTableValue(db: DatabaseSync, key: string): string | null { + try { + const stmt = db.prepare('SELECT value FROM ItemTable WHERE key = ?'); + const row = stmt.get(key) as Record | undefined; + const val = row?.value; + return typeof val === 'string' ? val : null; + } catch { + return null; + } +} + +/** + * Reads ALL bubble checkpoint hashes from cursorDiskKV in a single query, + * returning a Map from composerId to the list of SHA256 hash strings for that session. + * + * Key format: agentKv:bubbleCheckpoint:{composerId}:{bubbleId} → SHA256 hash + * Per-session LIKE queries are avoided because cursorDiskKV has ~148K rows and the + * index is not used efficiently for LIKE, making per-session scans ~1s each (230s total). + * One batch query returning all ~305 rows takes ~2s regardless of session count. + */ +function readAllBubbleHashesBySession(db: DatabaseSync): Map { + const result = new Map(); + try { + const rows = db + .prepare("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'agentKv:bubbleCheckpoint:%'") + .all() as Record[]; + for (const row of rows) { + const key = row.key; + const val = row.value; + if (typeof key !== 'string' || typeof val !== 'string' || val.length === 0) continue; + // key format: agentKv:bubbleCheckpoint:{composerId}:{bubbleId} + const parts = key.split(':'); + const composerId = parts[2]; + if (!composerId) continue; + const existing = result.get(composerId); + if (existing) { + existing.push(val); + } else { + result.set(composerId, [val]); + } + } + } catch { + // Return empty map — caller will skip sessions with no hashes + } + return result; +} + +/** + * Reads token totals for a single session from the older `bubbleId:{composerId}:{bubbleId}` + * storage format. Uses a BETWEEN range scan (O(log n)) rather than LIKE (O(n table scan)). + * Returns null when the session has no token data in this format. + */ +function readBubbleIdTokens( + stmt: ReturnType, + composerId: string, +): { input: number; output: number } | null { + try { + const lo = `bubbleId:${composerId}:`; + const hi = `bubbleId:${composerId}:￿`; + const row = stmt.get(lo, hi) as Record | undefined; + const input = typeof row?.totalInput === 'number' ? row.totalInput : 0; + const output = typeof row?.totalOutput === 'number' ? row.totalOutput : 0; + return input > 0 || output > 0 ? { input, output } : null; + } catch { + return null; + } +} + +/** + * Fetches the hex-encoded protobuf blobs for all content hashes in a single batch query. + * Returns a Map from hash to blob hex string. + */ +function readAllBlobs(db: DatabaseSync, hashes: string[]): Map { + const result = new Map(); + if (hashes.length === 0) return result; + try { + const placeholders = hashes.map(() => '?').join(', '); + const keys = hashes.map((h) => `agentKv:blob:${h}`); + const rows = db + .prepare(`SELECT key, value FROM cursorDiskKV WHERE key IN (${placeholders})`) + .all(...keys) as Record[]; + for (const row of rows) { + const key = row.key; + const val = row.value; + if (typeof key !== 'string' || typeof val !== 'string' || val.length === 0) continue; + const hash = key.slice('agentKv:blob:'.length); + result.set(hash, val); + } + } catch { + // Return partial result — caller will skip sessions with no blobs + } + return result; +} + +/** + * Reads the context-window token count from `composerData:{composerId}` in cursorDiskKV. + * This is the final context-window size at the end of the session — used as a fallback + * when neither the agentKv protobuf nor the older bubbleId JSON format has data. + * Returns null if the entry is missing or contextTokensUsed is zero. + */ +function readComposerDataTokens(db: DatabaseSync, composerId: string): number | null { + try { + const row = db + .prepare('SELECT value FROM cursorDiskKV WHERE key = ?') + .get(`composerData:${composerId}`) as Record | undefined; + if (!row) return null; + const val = row.value; + if (typeof val !== 'string') return null; + const parsed: unknown = JSON.parse(val); + if (!parsed || typeof parsed !== 'object') return null; + const tokens = (parsed as Record).contextTokensUsed; + return typeof tokens === 'number' && tokens > 0 ? tokens : null; + } catch { + return null; + } +} + +// ─── Session building ────────────────────────────────────────────────────── + +const RUNNING_THRESHOLD_MS = 30 * 60 * 1000; + +/** + * Aggregates token counts and picks the first non-null model name + * from all decoded bubble checkpoint blobs for a session. + */ +function aggregateBubbles(blobs: string[]): { totalTokens: number; model: string | null } { + let totalTokens = 0; + let model: string | null = null; + for (const hex of blobs) { + const { modelName, contextTokens } = decodeBubbleCheckpoint(hex); + if (contextTokens !== null) totalTokens += contextTokens; + if (modelName !== null && model === null) model = modelName; + } + return { totalTokens, model }; +} + +/** + * Converts a file:// URI to an absolute local path, or returns the input unchanged. + */ +function fileUriToPath(uri: string): string { + if (uri.startsWith('file://')) return uri.slice('file://'.length); + return uri; +} + +/** + * Extracts the best available workspace path from a WorkspaceIdentifier URI. + */ +function resolveWorkspacePath(wsId: z.infer | undefined): string { + if (!wsId?.uri) return ''; + const uri = wsId.uri; + if (uri.fsPath) return uri.fsPath; + if (uri.path) return uri.path; + if (uri.external) return fileUriToPath(uri.external); + return ''; +} + +/** + * Builds a LocalSession from a Cursor composer header and its aggregated bubble data. + */ +function buildCursorSession({ + composerId, + title, + createdAt, + lastUpdatedAt, + workspacePath, + totalTokens, + rawModel, +}: { + composerId: string; + title: string | null; + createdAt: number; + lastUpdatedAt: number; + workspacePath: string; + totalTokens: number; + rawModel: string | null; +}): LocalSession { + const startTime = new Date(createdAt).toISOString(); + const endTime = new Date(lastUpdatedAt).toISOString(); + const durationSeconds = Math.max(0, Math.round((lastUpdatedAt - createdAt) / 1000)); + const isRunning = Date.now() - lastUpdatedAt < RUNNING_THRESHOLD_MS; + + return { + sessionId: composerId, + repoFullName: workspacePath ? resolveRepoFullName(workspacePath) : 'unknown', + engine: 'cursor', + model: rawModel !== null ? normalizeCursorModel(rawModel) : null, + status: isRunning ? 'running' : 'success', + title, + startTime, + endTime, + durationSeconds, + tokens: { + input: totalTokens, + output: 0, + cacheRead: 0, + cacheWrite: 0, + isApproximate: true, + }, + }; +} + +// ─── Scanner ─────────────────────────────────────────────────────────────── + +/** + * Scanner for Cursor AI coding agent sessions (agent mode only). + * + * Reads session metadata from `composer.composerHeaders` in the global state DB + * and token/model data from protobuf bubble checkpoint blobs in cursorDiskKV. + * Only sessions that have per-turn bubble checkpoints (agent-mode sessions with + * local token data) are reported; chat-only sessions have no local token records. + */ export class CursorScanner implements SessionScanner { readonly name = 'cursor'; + /** + * Returns true if the Cursor application data directory exists on this machine + */ async isAvailable(): Promise { try { return fs.statSync(getCursorDataDir()).isDirectory(); @@ -26,8 +472,120 @@ export class CursorScanner implements SessionScanner { } } + /** + * Scans Cursor agent sessions and returns them as normalized LocalSession objects. + * Token counts are summed from per-turn protobuf bubble checkpoints (approximate). + * Sessions with no bubble checkpoint data are skipped. + */ async scan(): Promise { - logger.info('Cursor support coming soon — skipping'); - return []; + // Load node:sqlite at runtime via createRequire. This avoids the esbuild + // issue where dynamic import('node:sqlite') has the node: prefix stripped. + let DatabaseSync: typeof import('node:sqlite').DatabaseSync; + try { + const sqlite = _require('node:sqlite') as typeof import('node:sqlite'); + DatabaseSync = sqlite.DatabaseSync; + } catch { + logger.warn('node:sqlite not available — Cursor scanning disabled'); + return []; + } + + const globalDb = openDb(DatabaseSync, getGlobalDbPath()); + if (!globalDb) { + logger.warn('Cursor global DB unavailable — skipping Cursor scan'); + return []; + } + + try { + return this._scanWithDb(globalDb); + } finally { + globalDb.close(); + } + } + + /** + * Internal scan implementation that operates on an open global DB connection. + * + * Cursor uses two different storage schemes depending on its version: + * - Newer: agentKv:bubbleCheckpoint:{composerId}:{bubbleId} → SHA256 → protobuf blob + * - Older: bubbleId:{composerId}:{bubbleId} → JSON with tokenCount field + * + * We batch the newer format into 2 queries, then fall back to per-session BETWEEN + * range scans for the older format (BETWEEN uses the B-tree index; LIKE does not). + */ + private _scanWithDb(globalDb: DatabaseSync): LocalSession[] { + const headersRaw = readItemTableValue(globalDb, 'composer.composerHeaders'); + if (!headersRaw) return []; + + let parsedHeaders: z.infer; + try { + const result = ComposerHeadersSchema.safeParse(JSON.parse(headersRaw)); + if (!result.success) return []; + parsedHeaders = result.data; + } catch { + return []; + } + + // Newer format: batch load all agentKv bubble checkpoints and blobs. + const hashesBySession = readAllBubbleHashesBySession(globalDb); + const allHashes = [...hashesBySession.values()].flat(); + const blobByHash = readAllBlobs(globalDb, allHashes); + + // Older format: prepared statement for per-session BETWEEN range scan. + const bubbleIdStmt = globalDb.prepare(` + SELECT + SUM(COALESCE(json_extract(value, '$.tokenCount.inputTokens'), 0)) AS totalInput, + SUM(COALESCE(json_extract(value, '$.tokenCount.outputTokens'), 0)) AS totalOutput + FROM cursorDiskKV + WHERE key BETWEEN ? AND ? AND typeof(value) = 'text' + `); + + const sessions: LocalSession[] = []; + + for (const composer of parsedHeaders.allComposers ?? []) { + if (!composer.composerId || !composer.createdAt) continue; + + // Only agent-mode sessions have per-turn bubble checkpoint data + if (composer.unifiedMode !== 'agent') continue; + if (composer.isArchived) continue; + + let totalTokens = 0; + let model: string | null = null; + + const hashes = hashesBySession.get(composer.composerId); + if (hashes && hashes.length > 0) { + // Newer agentKv format: decode protobuf blobs + const blobs = hashes + .map((h) => blobByHash.get(h)) + .filter((b): b is string => b !== undefined); + const agg = aggregateBubbles(blobs); + totalTokens = agg.totalTokens; + model = agg.model; + } else { + // Older bubbleId JSON format: sum tokenCount fields via BETWEEN range scan + const bubbleTokens = readBubbleIdTokens(bubbleIdStmt, composer.composerId); + if (bubbleTokens) { + totalTokens = bubbleTokens.input + bubbleTokens.output; + } else { + // Newest format: contextTokensUsed in composerData (final context-window size) + totalTokens = readComposerDataTokens(globalDb, composer.composerId) ?? 0; + } + } + + if (totalTokens === 0) continue; + + sessions.push( + buildCursorSession({ + composerId: composer.composerId, + title: composer.name ?? null, + createdAt: composer.createdAt, + lastUpdatedAt: composer.lastUpdatedAt ?? composer.createdAt, + workspacePath: resolveWorkspacePath(composer.workspaceIdentifier), + totalTokens, + rawModel: model, + }), + ); + } + + return sessions; } } diff --git a/packages/cli/src/scanners/types.ts b/packages/cli/src/scanners/types.ts index e971d77..3c9cb7c 100644 --- a/packages/cli/src/scanners/types.ts +++ b/packages/cli/src/scanners/types.ts @@ -1,7 +1,15 @@ export type { LocalSession } from '../schemas/session.js'; +/** + * Common contract for all AI coding agent session scanners + */ export interface SessionScanner { + /** Human-readable scanner identifier (e.g. "claude", "cursor") */ readonly name: string; + + /** Returns true if the scanner's data directory exists on this machine */ isAvailable(): Promise; + + /** Scans all available sessions and returns them as normalized LocalSession objects */ scan(): Promise; } diff --git a/packages/cli/src/schemas/api-response.ts b/packages/cli/src/schemas/api-response.ts index 43fdf97..bde1a4a 100644 --- a/packages/cli/src/schemas/api-response.ts +++ b/packages/cli/src/schemas/api-response.ts @@ -12,5 +12,8 @@ export const ValidateKeyResponseSchema = z.object({ keyType: z.enum(['personal', 'org']).optional(), }); +/** Successful API response from POST /api/ingest/local */ export type ApiSuccessResponse = z.infer; + +/** Response from GET /api/auth/me key validation endpoint */ export type ValidateKeyResponse = z.infer; diff --git a/packages/cli/src/schemas/config.ts b/packages/cli/src/schemas/config.ts index 617c402..02b2089 100644 --- a/packages/cli/src/schemas/config.ts +++ b/packages/cli/src/schemas/config.ts @@ -6,4 +6,5 @@ export const ConfigSchema = z.object({ apiUrl: z.string().url().default('https://agentmeter.app'), }); +/** Validated CLI configuration stored in ~/.agentmeter/config.json */ export type Config = z.infer; diff --git a/packages/cli/src/schemas/session.ts b/packages/cli/src/schemas/session.ts index 0a20460..fa9582b 100644 --- a/packages/cli/src/schemas/session.ts +++ b/packages/cli/src/schemas/session.ts @@ -5,6 +5,8 @@ export const TokensSchema = z.object({ output: z.number().int().nonnegative(), cacheRead: z.number().int().nonnegative(), cacheWrite: z.number().int().nonnegative(), + /** When true, token counts are estimates rather than exact API-reported values */ + isApproximate: z.boolean().optional(), }); export const LocalSessionSchema = z.object({ @@ -12,7 +14,7 @@ export const LocalSessionSchema = z.object({ repoFullName: z.string(), engine: z.string(), model: z.string().nullable(), - status: z.enum(['success', 'failure', 'cancelled']), + status: z.enum(['running', 'success', 'failure', 'cancelled']), title: z.string().nullable(), startTime: z.string(), endTime: z.string().nullable(), @@ -20,5 +22,8 @@ export const LocalSessionSchema = z.object({ tokens: TokensSchema, }); +/** Aggregated token counts for a session */ export type Tokens = z.infer; + +/** Normalized session record produced by a scanner and submitted to the API */ export type LocalSession = z.infer; diff --git a/packages/cli/src/schemas/sync-state.ts b/packages/cli/src/schemas/sync-state.ts index d314c93..40145a9 100644 --- a/packages/cli/src/schemas/sync-state.ts +++ b/packages/cli/src/schemas/sync-state.ts @@ -1,9 +1,11 @@ import { z } from 'zod'; export const SyncedSessionSchema = z.object({ - status: z.enum(['success', 'failure', 'cancelled']), + status: z.enum(['running', 'success', 'failure', 'cancelled']), submittedAt: z.string(), costCents: z.number().int().nullable().optional(), + endTime: z.string().nullable().optional(), + title: z.string().nullable().optional(), }); export const SyncStateSchema = z.object({ @@ -11,5 +13,8 @@ export const SyncStateSchema = z.object({ sessions: z.record(z.string(), SyncedSessionSchema).default({}), }); +/** Persisted record of a session that has been submitted to the API, used to detect re-sync-worthy changes */ export type SyncedSession = z.infer; + +/** Contents of the ~/.agentmeter/sync-state.json file */ export type SyncState = z.infer; diff --git a/packages/cli/src/services/api.ts b/packages/cli/src/services/api.ts index 095de55..4728022 100644 --- a/packages/cli/src/services/api.ts +++ b/packages/cli/src/services/api.ts @@ -4,17 +4,37 @@ import type { LocalSession } from '../schemas/session.js'; import { withRetry } from '../utils/retry.js'; import { logger } from './logger.js'; +/** + * Outcome of submitting a single session to POST /api/ingest/local + */ export interface SubmitResult { + /** The session ID that was submitted */ sessionId: string; + + /** Cost in cents returned by the API, or null if not yet calculated */ costCents: number | null; + + /** Whether the session was newly created, updated, a duplicate, or failed */ status: 'created' | 'updated' | 'duplicate' | 'error'; + + /** Error message when status is 'error' */ error?: string; } +/** + * Outcome of validating an API key against GET /api/auth/me + */ export interface ValidateKeyResult { + /** Whether the key was accepted by the API */ valid: boolean; + + /** Organization name associated with the key, or null */ orgName: string | null; + + /** User display name associated with the key, or null */ userName: string | null; + + /** Whether the key is scoped to a personal user or an org, or null if unknown */ keyType: 'personal' | 'org' | null; } @@ -88,7 +108,7 @@ export class ApiClient { outputTokens: t.output, cacheReadTokens: t.cacheRead, cacheWriteTokens: t.cacheWrite, - isApproximate: false, + isApproximate: t.isApproximate ?? false, } : null, }; diff --git a/packages/cli/src/services/logger.ts b/packages/cli/src/services/logger.ts index 40dd6b4..44b7f8c 100644 --- a/packages/cli/src/services/logger.ts +++ b/packages/cli/src/services/logger.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import { getLogDir, getLogPath } from '../utils/platform.js'; +/** Severity levels for log entries */ type LogLevel = 'info' | 'warn' | 'error'; const MAX_LOG_SIZE = 10 * 1024 * 1024; diff --git a/packages/cli/src/services/service-installer.ts b/packages/cli/src/services/service-installer.ts index e6a0ad3..b4676a4 100644 --- a/packages/cli/src/services/service-installer.ts +++ b/packages/cli/src/services/service-installer.ts @@ -24,16 +24,31 @@ function getSystemdServicePath(): string { } /** - * Resolves the installed agentmeter binary path, falling back to argv[1] + * Returns the program + arguments array for the service watch command. + * When running from TypeScript source (dev mode via tsx), uses the tsx binary + * as the program so launchd/systemd can execute the .ts file directly. */ -function findBinaryPath(): string { +function getServiceProgramArgs(): string[] { + const scriptPath = process.argv[1] ?? ''; + + if (scriptPath.endsWith('.ts')) { + // Dev mode: find tsx binary and use it as the runner + try { + const tsx = execFileSync('which', ['tsx'], { encoding: 'utf8' }).trim(); + if (tsx) return [tsx, scriptPath, 'watch']; + } catch { + // tsx not in PATH — fall through to production path + } + } + + // Production: agentmeter global binary, or fall back to argv[1] try { - const result = execFileSync('which', ['agentmeter'], { encoding: 'utf8' }).trim(); - if (result) return result; + const binary = execFileSync('which', ['agentmeter'], { encoding: 'utf8' }).trim(); + if (binary) return [process.execPath, binary, 'watch']; } catch { - // which failed — fall back to current script path + // not installed globally } - return process.argv[1] ?? 'agentmeter'; + return [process.execPath, scriptPath, 'watch']; } /** @@ -49,10 +64,14 @@ function escapeXml(str: string): string { } /** - * Generates the launchd plist XML content for the agentmeter sync service + * Generates the launchd plist XML content for the agentmeter sync service. + * Includes the node binary's directory in PATH so tsx shell wrappers can find node, + * since launchd runs with a minimal environment that omits /usr/local/bin. */ -function generatePlist(binaryPath: string, apiKey: string, logPath: string): string { - const nodePath = process.execPath; +function generatePlist(programArgs: string[], config: Config, logPath: string): string { + const argsXml = programArgs.map((a) => ` ${escapeXml(a)}`).join('\n'); + const nodeBinDir = path.dirname(process.execPath); + const servicePath = `${nodeBinDir}:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`; return ` @@ -61,9 +80,7 @@ function generatePlist(binaryPath: string, apiKey: string, logPath: string): str ${escapeXml(LAUNCHD_LABEL)} ProgramArguments - ${escapeXml(nodePath)} - ${escapeXml(binaryPath)} - watch +${argsXml} RunAtLoad @@ -75,8 +92,12 @@ function generatePlist(binaryPath: string, apiKey: string, logPath: string): str ${escapeXml(logPath)} EnvironmentVariables + PATH + ${escapeXml(servicePath)} AGENTMETER_API_KEY - ${escapeXml(apiKey)} + ${escapeXml(config.apiKey)} + AGENTMETER_API_URL + ${escapeXml(config.apiUrl)} @@ -86,17 +107,17 @@ function generatePlist(binaryPath: string, apiKey: string, logPath: string): str /** * Generates the systemd unit file content for the agentmeter sync service */ -function generateSystemdUnit(binaryPath: string, apiKey: string): string { - const nodePath = process.execPath; +function generateSystemdUnit(programArgs: string[], config: Config): string { return `[Unit] Description=AgentMeter Session Sync [Service] Type=simple -ExecStart=${nodePath} ${binaryPath} watch +ExecStart=${programArgs.join(' ')} Restart=on-failure RestartSec=30 -Environment=AGENTMETER_API_KEY=${apiKey} +Environment=AGENTMETER_API_KEY=${config.apiKey} +Environment=AGENTMETER_API_URL=${config.apiUrl} [Install] WantedBy=default.target @@ -107,7 +128,7 @@ WantedBy=default.target * Installs and starts the agentmeter launchd service on macOS */ export function installMacos(config: Config): void { - const binaryPath = findBinaryPath(); + const programArgs = getServiceProgramArgs(); const logDir = getLogDir(); const logPath = getLogPath(); const plistPath = getLaunchdPlistPath(); @@ -115,7 +136,7 @@ export function installMacos(config: Config): void { fs.mkdirSync(logDir, { recursive: true }); fs.mkdirSync(path.dirname(plistPath), { recursive: true }); - const plist = generatePlist(binaryPath, config.apiKey, logPath); + const plist = generatePlist(programArgs, config, logPath); fs.writeFileSync(plistPath, plist, 'utf8'); // Unload first in case it was previously installed @@ -138,12 +159,12 @@ export function uninstallMacos(): void { * Installs and starts the agentmeter systemd user service on Linux */ export function installLinux(config: Config): void { - const binaryPath = findBinaryPath(); + const programArgs = getServiceProgramArgs(); const servicePath = getSystemdServicePath(); fs.mkdirSync(path.dirname(servicePath), { recursive: true }); - const unit = generateSystemdUnit(binaryPath, config.apiKey); + const unit = generateSystemdUnit(programArgs, config); fs.writeFileSync(servicePath, unit, 'utf8'); spawnSync('systemctl', ['--user', 'daemon-reload']); diff --git a/packages/cli/src/utils/platform.ts b/packages/cli/src/utils/platform.ts index 9568a3e..8339ac2 100644 --- a/packages/cli/src/utils/platform.ts +++ b/packages/cli/src/utils/platform.ts @@ -50,6 +50,7 @@ export function getClaudeProjectsDir(): string { return path.join(getHomeDir(), '.claude', 'projects'); } +/** Normalized platform identifier returned by getPlatform */ export type Platform = 'macos' | 'linux' | 'windows' | 'unsupported'; /** diff --git a/packages/cli/src/utils/retry.ts b/packages/cli/src/utils/retry.ts index 21fe462..3d11389 100644 --- a/packages/cli/src/utils/retry.ts +++ b/packages/cli/src/utils/retry.ts @@ -1,6 +1,14 @@ +/** + * Configuration for exponential-backoff retry behavior + */ export interface RetryOptions { + /** Maximum number of attempts before throwing the last error */ maxAttempts: number; + + /** Initial delay in milliseconds before the first retry */ baseDelayMs: number; + + /** Upper bound on the computed delay between retries */ maxDelayMs: number; } diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 6142857..7f6e084 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -3,10 +3,13 @@ import { defineConfig } from 'tsup'; export default defineConfig({ entry: ['src/index.ts'], format: ['esm'], - target: 'node20', + target: 'node22', clean: true, dts: true, sourcemap: true, + // node:sqlite is experimental and not in esbuild's built-in list, so we mark + // it as external explicitly to prevent the node: prefix from being stripped. + external: ['node:sqlite'], banner: { js: '#!/usr/bin/env node', },