From bdb1475251a19c79ae93cb1777b2f7274af11fbb Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:01:41 +0300 Subject: [PATCH] feat(quickdesk): Amazon Quick Desktop provider, parse ~/.quickwork sessions and metrics (#707) Adds an eager provider for Amazon Quick Desktop (aws.amazon.com/quick/desktop), which stores local data under ~/.quickwork. Usage source of truth is the per-day EMF metrics JSONL (Model, InputTokens, OutputTokens, CostUSD, session_id); real CostUSD passes through the cache like kiro/devin/hermes. sessions.db (SQLite, read-only) enriches calls with titles, first user message, and tool names, and provides estimated usage (quickdesk-auto, costIsEstimated) for sessions that predate metrics coverage. Multi-profile layout via profiles.json entries plus the migrated legacy root; honors QUICKWORK_HOME; schema-tolerant (sqlite_master and PRAGMA introspection, per-line JSONL error isolation). The on-disk schema is reverse-engineered and documented as such in docs/providers/quickdesk.md. --- docs/providers/README.md | 1 + docs/providers/quickdesk.md | 49 +++ src/models.ts | 2 + src/parser.ts | 2 +- src/providers/index.ts | 3 +- src/providers/quickdesk.ts | 553 ++++++++++++++++++++++++++++++ src/session-cache.ts | 2 + tests/provider-registry.test.ts | 2 +- tests/providers/quickdesk.test.ts | 445 ++++++++++++++++++++++++ 9 files changed, 1056 insertions(+), 3 deletions(-) create mode 100644 docs/providers/quickdesk.md create mode 100644 src/providers/quickdesk.ts create mode 100644 tests/providers/quickdesk.test.ts diff --git a/docs/providers/README.md b/docs/providers/README.md index 77d7529..0944636 100644 --- a/docs/providers/README.md +++ b/docs/providers/README.md @@ -29,6 +29,7 @@ For the architectural picture, see `../architecture.md`. | [Pi](pi.md) | JSONL | `src/providers/pi.ts` | `tests/providers/pi.test.ts` | | [OMP](omp.md) | JSONL | `src/providers/pi.ts` | `tests/providers/omp.test.ts` | | [Qwen](qwen.md) | JSONL | `src/providers/qwen.ts` | none | +| [Quick Desktop](quickdesk.md) | EMF JSONL + SQLite | `src/providers/quickdesk.ts` | `tests/providers/quickdesk.test.ts` | | [Roo Code](roo-code.md) | JSON | `src/providers/roo-code.ts` | `tests/providers/roo-code.test.ts` | | [Zerostack](zerostack.md) | JSON | `src/providers/zerostack.ts` | `tests/providers/zerostack.test.ts` | | [Grok Build](grok.md) | JSON/JSONL | `src/providers/grok.ts` | `tests/providers/grok.test.ts` | diff --git a/docs/providers/quickdesk.md b/docs/providers/quickdesk.md new file mode 100644 index 0000000..f4e2bcc --- /dev/null +++ b/docs/providers/quickdesk.md @@ -0,0 +1,49 @@ +# Quick Desktop + +Amazon Quick Desktop local usage and session history. + +- **Source:** `src/providers/quickdesk.ts` +- **Loading:** eager +- **Test:** `tests/providers/quickdesk.test.ts` + +## Where it reads from + +The provider reads `~/.quickwork` by default and honors `QUICKWORK_HOME`. + +When `profiles.json` contains `entries[{ id, data_path }]`, every `data_path` is scanned and the entry `id` is used as the project. Relative paths resolve from the store root; absolute paths are used as written. `last_active` is not used for filtering. If the store root also contains `sessions/sessions.db`, that migrated legacy history is scanned as the `default` profile alongside the manifest entries. If the profile manifest is missing, unreadable, malformed, or has no usable entries, the provider scans the store root as the legacy `default` profile. + +For each profile base it reads: + +- `metrics/metrics-YYYY-MM-DD.jsonl` +- `sessions/sessions.db` + +`probeRoots()` reports the same resolved profile bases for `codeburn doctor`. + +## Storage format + +Metrics files contain one AWS Embedded Metric Format JSON object per line. A usage row must have `Model`, `InputTokens`, and `OutputTokens`. `_aws.Timestamp` is milliseconds since the Unix epoch; when it is absent, the date in the metrics filename is used at midnight UTC. A numeric `CostUSD` is preserved as measured cost. Rows without `CostUSD` are priced through codeburn's existing model pricing and marked estimated. Tool-only rows in the file are linked to usage by `session_id`; `thread_id` may be present but is not required for the link. Malformed lines are skipped independently. + +The SQLite database is opened read-only through `src/sqlite.ts`. The provider introspects `sqlite_master` and table columns before querying `sessions` or `session_messages`. It uses non-deleted sessions for the first user message and tools. A non-deleted session absent from every metrics file gets one estimated `quickdesk-auto` call: user and non-assistant content is input, assistant content is output, and characters are converted at four characters per token. Missing tables or columns disable only the affected enrichment or fallback; metrics remain usable. + +The on-disk schema is reverse-engineered. AWS officially documents the `.quickwork` root, but does not publish a contract for `profiles.json`, metrics JSONL, or `sessions.db`. + +## Caching + +Quick Desktop is an eager provider using the shared session cache. Each metrics file and profile database is a separate cache source. The `QUICKWORK_HOME` value and the Quick Desktop parser version participate in the provider cache fingerprint. Sources are durable because Quick Desktop may prune its managed store; cached records are retained when a previously discovered source disappears. + +## Deduplication + +Metrics calls with a session use `quickdesk:::::`. Session-less calls use `quickdesk::::::`. Estimated database-only sessions use `quickdesk-est:`. All keys are stable across runs. + +## Quirks + +- Quick Desktop can delegate to Kiro or Claude Code. If Quick Desktop metrics meter the same delegated calls that those native stores persist, enabling all providers may double-count that traffic. No sanitized real store has yet resolved this caveat. +- The metrics directory location is reverse-engineered as `/metrics`; it is not an official AWS schema guarantee. +- Deleted sessions are excluded when `sessions.deleted_at` is available. On an older schema without that column, metrics are retained because deletion state cannot be determined. +- `sessions.db` uses WAL journaling, so the main file fingerprint can lag until checkpoint. A session estimated in one run can briefly coexist with its first real metrics rows in the next; this transient estimated-plus-real overlap self-heals when the database file changes. + +## When fixing a bug here + +1. Test both a multi-profile manifest and the legacy root layout. +2. Keep metrics parsing independent from optional SQLite enrichment. +3. Add malformed-line and missing-table coverage for every newly observed schema variant. diff --git a/src/models.ts b/src/models.ts index b314ae9..16e8f7f 100644 --- a/src/models.ts +++ b/src/models.ts @@ -266,6 +266,7 @@ const BUILTIN_ALIASES: Record = { 'openai-codex:gpt-5.5': 'gpt-5.5', 'ibm-bob-auto': 'claude-sonnet-4-5', 'kiro-auto': 'claude-sonnet-4-5', + 'quickdesk-auto': 'claude-sonnet-4-5', 'cline-auto': 'claude-sonnet-4-5', 'openclaw-auto': 'claude-sonnet-4-5', 'warp-auto-efficient': 'gpt-5.3-codex', @@ -816,6 +817,7 @@ const autoModelNames: Record = { 'copilot-anthropic-auto': 'Copilot (Anthropic)', 'ibm-bob-auto': 'IBM Bob (auto)', 'kiro-auto': 'Kiro (auto)', + 'quickdesk-auto': 'Quick Desktop (auto)', 'cline-auto': 'Cline (auto)', 'openclaw-auto': 'OpenClaw (auto)', 'qwen-auto': 'Qwen (auto)', diff --git a/src/parser.ts b/src/parser.ts index 96c72dd..8f6818a 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1826,7 +1826,7 @@ function providerCallToCachedCall(call: ParsedProviderCall): CachedCall { webSearchRequests: call.webSearchRequests, cacheCreationOneHourTokens: 0, }, - costUSD: (call.provider === 'mistral-vibe' || call.provider === 'antigravity' || call.provider === 'devin' || call.provider === 'vercel-gateway' || call.provider === 'hermes' || call.provider === 'kiro' || call.provider === 'codewhale') ? call.costUSD : undefined, + costUSD: (call.provider === 'mistral-vibe' || call.provider === 'antigravity' || call.provider === 'devin' || call.provider === 'vercel-gateway' || call.provider === 'hermes' || call.provider === 'kiro' || call.provider === 'codewhale' || call.provider === 'quickdesk') ? call.costUSD : undefined, isEstimated: call.costIsEstimated || undefined, speed: call.speed, timestamp: call.timestamp, diff --git a/src/providers/index.ts b/src/providers/index.ts index 73622a9..ad4c766 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -19,6 +19,7 @@ import { openclaw } from './openclaw.js' import { openDesign } from './open-design.js' import { pi, omp } from './pi.js' import { qwen } from './qwen.js' +import { quickdesk } from './quickdesk.js' import { rooCode } from './roo-code.js' import { zerostack } from './zerostack.js' import { grok } from './grok.js' @@ -188,7 +189,7 @@ async function loadZed(): Promise { } } -const coreProviders: Provider[] = [claude, cline, codewhale, codebuff, codex, copilot, devin, droid, gemini, hermes, ibmBob, kiloCode, kiro, kimi, lingtaiTui, mistralVibe, mux, openclaw, openDesign, pi, omp, qwen, rooCode, zerostack, grok] +const coreProviders: Provider[] = [claude, cline, codewhale, codebuff, codex, copilot, devin, droid, gemini, hermes, ibmBob, kiloCode, kiro, kimi, lingtaiTui, mistralVibe, mux, openclaw, openDesign, pi, omp, qwen, quickdesk, rooCode, zerostack, grok] // Lazily loaded providers, listed by name so --provider validation works even // when an optional module fails to load. Must stay in sync with getAllProviders. diff --git a/src/providers/quickdesk.ts b/src/providers/quickdesk.ts new file mode 100644 index 0000000..427419a --- /dev/null +++ b/src/providers/quickdesk.ts @@ -0,0 +1,553 @@ +import { readdir, readFile, stat } from 'node:fs/promises' +import { homedir } from 'node:os' +import { basename, isAbsolute, join, resolve } from 'node:path' + +import { calculateCost } from '../models.js' +import { estimateTokensFromChars } from '../token-estimate.js' +import { blobToText, isSqliteAvailable, openDatabase } from '../sqlite.js' +import type { SqliteDatabase } from '../sqlite.js' +import type { ParsedProviderCall, ProbeRoot, Provider, SessionParser, SessionSource } from './types.js' + +const METRICS_FILE_RE = /^metrics-(\d{4})-(\d{2})-(\d{2})\.jsonl$/ + +const modelDisplayNames: Record = { + 'claude-sonnet-4-5': 'Sonnet 4.5', + 'claude-sonnet-4-6': 'Sonnet 4.6', +} + +const toolNameMap: Record = { + readFile: 'Read', + read_file: 'Read', + writeFile: 'Edit', + write_file: 'Edit', + editFile: 'Edit', + edit_file: 'Edit', + runCommand: 'Bash', + run_command: 'Bash', + executeBash: 'Bash', + shell: 'Bash', + grep: 'Grep', + searchFiles: 'Grep', + search_files: 'Grep', +} + +type ProfileBase = { + path: string + profile: string +} + +type MetricsRecord = { + record: Record +} + +type SessionMetadata = { + id: string + title: string + agentMode: string + createdAt?: number + deleted: boolean + firstUserMessage: string + inputChars: number + outputChars: number + tools: string[] +} + +type DatabaseSnapshot = { + sessions: Map + canEstimate: boolean +} + +type SqliteMasterRow = { + name?: unknown +} + +type TableInfoRow = { + name?: unknown +} + +type SessionRow = Record & { + id?: unknown + title?: unknown + agent_mode?: unknown + created_at?: unknown + deleted_at?: unknown +} + +type MessageRow = Record & { + session_id?: unknown + role?: unknown + content?: unknown + tool_names?: unknown +} + +function quickworkHome(): string { + return resolve(process.env['QUICKWORK_HOME'] || join(homedir(), '.quickwork')) +} + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function finiteNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +function nonNegativeNumber(value: unknown): number | undefined { + const number = finiteNumber(value) + return number !== undefined && number >= 0 ? number : undefined +} + +async function resolveProfileBases(): Promise { + const root = quickworkHome() + try { + const parsed = asRecord(JSON.parse(await readFile(join(root, 'profiles.json'), 'utf8'))) + const entries = parsed?.['entries'] + if (Array.isArray(entries)) { + const bases: ProfileBase[] = [] + const seenPaths = new Set() + for (const rawEntry of entries) { + const entry = asRecord(rawEntry) + const profile = stringValue(entry?.['id']) + const dataPath = stringValue(entry?.['data_path']) + if (!profile || !dataPath) continue + const basePath = isAbsolute(dataPath) ? resolve(dataPath) : resolve(root, dataPath) + if (seenPaths.has(basePath)) continue + seenPaths.add(basePath) + bases.push({ path: basePath, profile }) + } + if (bases.length > 0) { + const legacyDbPath = join(root, 'sessions', 'sessions.db') + if (!seenPaths.has(root) && await isFile(legacyDbPath)) { + bases.push({ path: root, profile: 'default' }) + } + return bases + } + } + } catch { + // Missing, unreadable, or malformed profiles.json is the legacy root layout. + } + return [{ path: root, profile: 'default' }] +} + +async function isFile(path: string): Promise { + try { + return (await stat(path)).isFile() + } catch { + return false + } +} + +async function metricsFiles(basePath: string): Promise { + const metricsDir = join(basePath, 'metrics') + try { + const entries = await readdir(metricsDir, { withFileTypes: true }) + return entries + .filter(entry => entry.isFile() && METRICS_FILE_RE.test(entry.name)) + .map(entry => join(metricsDir, entry.name)) + .sort() + } catch { + return [] + } +} + +async function discoverSources(): Promise { + const sources: SessionSource[] = [] + for (const base of await resolveProfileBases()) { + for (const metricsPath of await metricsFiles(base.path)) { + sources.push({ + path: metricsPath, + project: base.profile, + provider: 'quickdesk', + sourceId: 'metrics', + sourcePath: base.path, + }) + } + const dbPath = join(base.path, 'sessions', 'sessions.db') + if (await isFile(dbPath)) { + sources.push({ + path: dbPath, + project: base.profile, + provider: 'quickdesk', + sourceId: 'sessions-db', + sourcePath: base.path, + }) + } + } + return sources +} + +function tableNames(db: SqliteDatabase): Set { + const rows = db.query( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('sessions', 'session_messages')", + ) + return new Set(rows.map(row => stringValue(row.name)).filter(Boolean)) +} + +function tableColumns(db: SqliteDatabase, table: 'sessions' | 'session_messages'): Set { + const rows = db.query(`PRAGMA table_info(${table})`) + return new Set(rows.map(row => stringValue(row.name)).filter(Boolean)) +} + +function selectColumn(columns: Set, name: string, fallback = 'NULL'): string { + return columns.has(name) ? name : `${fallback} AS ${name}` +} + +function toolNames(value: unknown): string[] { + const text = value instanceof Uint8Array ? blobToText(value) : stringValue(value) + if (!text) return [] + try { + const parsed: unknown = JSON.parse(text) + if (Array.isArray(parsed)) { + return parsed.flatMap(entry => { + if (typeof entry === 'string') return entry.trim() ? [entry.trim()] : [] + const record = asRecord(entry) + const name = stringValue(record?.['name']) || stringValue(record?.['tool_name']) || stringValue(record?.['toolName']) + return name ? [name] : [] + }) + } + } catch { + // Older stores may use a comma-separated tool_names value instead of JSON. + } + return text.split(',').map(name => name.trim()).filter(Boolean) +} + +function uniqueMappedTools(values: string[]): string[] { + return [...new Set(values.map(value => toolNameMap[value] ?? value).filter(Boolean))] +} + +function timestampSeconds(value: unknown): number | undefined { + const number = finiteNumber(value) + return number !== undefined && number >= 0 ? number : undefined +} + +function unixSecondsIso(value: number): string | null { + const date = new Date(value * 1000) + return Number.isNaN(date.getTime()) ? null : date.toISOString() +} + +function loadDatabaseSnapshot(basePath: string): DatabaseSnapshot { + const empty: DatabaseSnapshot = { sessions: new Map(), canEstimate: false } + if (!isSqliteAvailable()) return empty + + let db: SqliteDatabase + try { + db = openDatabase(join(basePath, 'sessions', 'sessions.db')) + } catch { + return empty + } + + try { + const tables = tableNames(db) + if (!tables.has('sessions')) return empty + + const sessionColumns = tableColumns(db, 'sessions') + if (!sessionColumns.has('id')) return empty + const deletionKnown = sessionColumns.has('deleted_at') + const sessionRows = db.query( + `SELECT id, + ${selectColumn(sessionColumns, 'title')}, + ${selectColumn(sessionColumns, 'agent_mode')}, + ${selectColumn(sessionColumns, 'created_at')}, + ${selectColumn(sessionColumns, 'deleted_at')} + FROM sessions`, + ) + + const sessions = new Map() + for (const row of sessionRows) { + const id = stringValue(row.id) + if (!id) continue + sessions.set(id, { + id, + title: stringValue(row.title), + agentMode: stringValue(row.agent_mode), + createdAt: timestampSeconds(row.created_at), + deleted: deletionKnown && row.deleted_at !== null && row.deleted_at !== undefined, + firstUserMessage: '', + inputChars: 0, + outputChars: 0, + tools: [], + }) + } + + if (!tables.has('session_messages')) { + return { sessions, canEstimate: false } + } + + const messageColumns = tableColumns(db, 'session_messages') + if (!messageColumns.has('session_id') || !messageColumns.has('role') || !messageColumns.has('content')) { + return { sessions, canEstimate: false } + } + + try { + const orderBy = messageColumns.has('timestamp') ? 'ORDER BY timestamp ASC' : '' + const rows = db.query( + `SELECT session_id, + role, + CAST(content AS BLOB) AS content, + ${messageColumns.has('tool_names') ? 'CAST(tool_names AS BLOB) AS tool_names' : 'NULL AS tool_names'} + FROM session_messages + ${orderBy}`, + ) + for (const row of rows) { + const sessionId = stringValue(row.session_id) + const session = sessions.get(sessionId) + if (!session) continue + const role = stringValue(row.role).toLowerCase() + const content = row.content instanceof Uint8Array ? blobToText(row.content) : stringValue(row.content) + if (role === 'assistant') session.outputChars += content.length + else session.inputChars += content.length + if (role === 'user' && !session.firstUserMessage && content.trim()) { + session.firstUserMessage = content.trim() + } + session.tools.push(...toolNames(row.tool_names)) + } + } catch { + return { sessions, canEstimate: false } + } + for (const session of sessions.values()) session.tools = uniqueMappedTools(session.tools) + + return { sessions, canEstimate: true } + } catch { + return empty + } finally { + db.close() + } +} + +async function readMetricsRecords(path: string): Promise { + let contents: string + try { + contents = await readFile(path, 'utf8') + } catch { + return [] + } + + const records: MetricsRecord[] = [] + const lines = contents.split(/\r?\n/) + for (let index = 0; index < lines.length; index++) { + const line = lines[index]!.trim() + if (!line) continue + try { + const record = asRecord(JSON.parse(line)) + if (record) records.push({ record }) + } catch { + // A partially-written or malformed JSONL line must not hide later records. + } + } + return records +} + +function usageRecord(record: Record): boolean { + return Boolean(stringValue(record['Model'])) + && nonNegativeNumber(record['InputTokens']) !== undefined + && nonNegativeNumber(record['OutputTokens']) !== undefined +} + +function sessionId(record: Record): string { + return stringValue(record['session_id']) +} + +function toolsKey(record: Record): string { + return sessionId(record) +} + +function collectMetricTools(records: MetricsRecord[]): Map { + const tools = new Map() + for (const { record } of records) { + const key = toolsKey(record) + const tool = stringValue(record['ToolName']) + if (!key || !tool) continue + const current = tools.get(key) ?? [] + current.push(toolNameMap[tool] ?? tool) + tools.set(key, current) + } + for (const [key, values] of tools) tools.set(key, [...new Set(values)]) + return tools +} + +function fallbackTimestamp(path: string): string | null { + const match = METRICS_FILE_RE.exec(basename(path)) + if (!match) return null + const year = Number(match[1]) + const month = Number(match[2]) + const day = Number(match[3]) + const date = new Date(Date.UTC(year, month - 1, day)) + if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) return null + return date.toISOString() +} + +function metricsTimestamp(record: Record, path: string): string | null { + const aws = asRecord(record['_aws']) + const timestampMs = finiteNumber(aws?.['Timestamp']) + if (timestampMs !== undefined) { + const date = new Date(timestampMs) + if (!Number.isNaN(date.getTime())) return date.toISOString() + } + return fallbackTimestamp(path) +} + +async function metricSessionIds(basePath: string): Promise> { + const ids = new Set() + for (const path of await metricsFiles(basePath)) { + for (const { record } of await readMetricsRecords(path)) { + if (!usageRecord(record)) continue + const id = sessionId(record) + if (id) ids.add(id) + } + } + return ids +} + +async function allMetricSessionIds(): Promise> { + const ids = new Set() + for (const base of await resolveProfileBases()) { + for (const id of await metricSessionIds(base.path)) ids.add(id) + } + return ids +} + +function basePathFor(source: SessionSource): string { + if (source.sourcePath) return source.sourcePath + return resolve(source.path, '..', '..') +} + +function commonCallFields(source: SessionSource, basePath: string) { + return { + provider: 'quickdesk', + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + bashCommands: [] as string[], + speed: 'standard' as const, + project: source.project, + projectPath: basePath, + } +} + +function createMetricsParser(source: SessionSource, seenKeys: Set): SessionParser { + return { + async *parse(): AsyncGenerator { + const records = await readMetricsRecords(source.path) + const basePath = basePathFor(source) + const linkedTools = collectMetricTools(records) + const snapshot = loadDatabaseSnapshot(basePath) + const fileId = basename(source.path) + + for (const { record } of records) { + if (!usageRecord(record)) continue + const model = stringValue(record['Model']) + const inputTokens = nonNegativeNumber(record['InputTokens'])! + const outputTokens = nonNegativeNumber(record['OutputTokens'])! + const timestamp = metricsTimestamp(record, source.path) + if (!timestamp) continue + + const linkedSessionId = sessionId(record) + const metadata = linkedSessionId ? snapshot.sessions.get(linkedSessionId) : undefined + if (metadata?.deleted) continue + + const fallbackId = `${source.project}:${fileId}` + const deduplicationKey = `quickdesk:${linkedSessionId || fallbackId}:${timestamp}:${model}:${inputTokens}:${outputTokens}` + if (seenKeys.has(deduplicationKey)) continue + seenKeys.add(deduplicationKey) + + const recordedCost = nonNegativeNumber(record['CostUSD']) + const costIsEstimated = recordedCost === undefined + const metricTools = linkedTools.get(toolsKey(record)) ?? [] + const tools = uniqueMappedTools([...metricTools, ...(metadata?.tools ?? [])]) + + yield { + ...commonCallFields(source, basePath), + model, + inputTokens, + outputTokens, + costUSD: recordedCost ?? calculateCost(model, inputTokens, outputTokens, 0, 0, 0), + costIsEstimated, + tools, + timestamp, + deduplicationKey, + userMessage: metadata?.firstUserMessage ?? '', + sessionId: linkedSessionId || fileId, + } + } + }, + } +} + +function createDatabaseParser(source: SessionSource, seenKeys: Set): SessionParser { + return { + async *parse(): AsyncGenerator { + const basePath = basePathFor(source) + const snapshot = loadDatabaseSnapshot(basePath) + if (!snapshot.canEstimate) return + const meteredSessions = await allMetricSessionIds() + + for (const metadata of snapshot.sessions.values()) { + if (metadata.deleted || meteredSessions.has(metadata.id) || metadata.createdAt === undefined) continue + const createdAtSeconds = metadata.createdAt > 1_000_000_000_000 + ? metadata.createdAt / 1000 + : metadata.createdAt + const timestamp = unixSecondsIso(createdAtSeconds) + if (!timestamp) continue + const inputTokens = estimateTokensFromChars(metadata.inputChars) + const outputTokens = estimateTokensFromChars(metadata.outputChars) + if (inputTokens + outputTokens === 0) continue + + const deduplicationKey = `quickdesk-est:${metadata.id}` + if (seenKeys.has(deduplicationKey)) continue + seenKeys.add(deduplicationKey) + const model = 'quickdesk-auto' + + yield { + ...commonCallFields(source, basePath), + model, + inputTokens, + outputTokens, + costUSD: calculateCost(model, inputTokens, outputTokens, 0, 0, 0), + costIsEstimated: true, + tools: metadata.tools, + timestamp, + deduplicationKey, + userMessage: metadata.firstUserMessage, + sessionId: metadata.id, + } + } + }, + } +} + +export const quickdesk: Provider = { + name: 'quickdesk', + displayName: 'Quick Desktop', + durableSources: true, + + modelDisplayName(model: string): string { + if (model === 'quickdesk-auto') return 'Quick Desktop (auto)' + return modelDisplayNames[model] ?? model + }, + + toolDisplayName(rawTool: string): string { + return toolNameMap[rawTool] ?? rawTool + }, + + async probeRoots(): Promise { + return (await resolveProfileBases()).map(base => ({ path: base.path, label: base.profile })) + }, + + async discoverSessions(): Promise { + return discoverSources() + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return source.sourceId === 'sessions-db' || basename(source.path) === 'sessions.db' + ? createDatabaseParser(source, seenKeys) + : createMetricsParser(source, seenKeys) + }, +} diff --git a/src/session-cache.ts b/src/session-cache.ts index d189251..507ad9e 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -128,6 +128,7 @@ export const PROVIDER_ENV_VARS: Record = { antigravity: ['CODEBURN_CACHE_DIR'], qwen: ['QWEN_DATA_DIR'], 'ibm-bob': ['XDG_CONFIG_HOME'], + quickdesk: ['QUICKWORK_HOME'], } // Names of providers whose cache entries are never evicted when source files @@ -157,6 +158,7 @@ export const PROVIDER_PARSE_VERSIONS: Record = { 'lingtai-tui': 'token-ledger-registry-activity-v3', 'ibm-bob': 'worktree-project-grouping-v1', kiro: 'ide-parsing-v1-est-cost', + quickdesk: 'emf-sqlite-v2-est-cost', 'kilo-code': 'worktree-project-grouping-v1', 'roo-code': 'worktree-project-grouping-v1', warp: 'worktree-project-grouping-v1-est-cost', diff --git a/tests/provider-registry.test.ts b/tests/provider-registry.test.ts index 7dad610..e84d0cc 100644 --- a/tests/provider-registry.test.ts +++ b/tests/provider-registry.test.ts @@ -14,7 +14,7 @@ function fakeProvider(name: string, discover: Provider['discoverSessions']): Pro describe('provider registry', () => { it('has core providers registered synchronously', () => { - expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'codewhale', 'codebuff', 'codex', 'copilot', 'devin', 'droid', 'gemini', 'hermes', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'lingtai-tui', 'mistral-vibe', 'mux', 'openclaw', 'open-design', 'pi', 'omp', 'qwen', 'roo-code', 'zerostack', 'grok']) + expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'codewhale', 'codebuff', 'codex', 'copilot', 'devin', 'droid', 'gemini', 'hermes', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'lingtai-tui', 'mistral-vibe', 'mux', 'openclaw', 'open-design', 'pi', 'omp', 'qwen', 'quickdesk', 'roo-code', 'zerostack', 'grok']) }) it('codebuff tool display names normalize codebuff-native names to canonical set', () => { diff --git a/tests/providers/quickdesk.test.ts b/tests/providers/quickdesk.test.ts new file mode 100644 index 0000000..f750c75 --- /dev/null +++ b/tests/providers/quickdesk.test.ts @@ -0,0 +1,445 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { calculateCost, getShortModelName } from '../../src/models.js' +import { providers } from '../../src/providers/index.js' +import { quickdesk } from '../../src/providers/quickdesk.js' +import { isSqliteAvailable } from '../../src/sqlite.js' +import type { ParsedProviderCall, SessionSource } from '../../src/providers/types.js' + +const requireForTest = createRequire(import.meta.url) + +type TestDb = { + exec(sql: string): void + prepare(sql: string): { run(...params: unknown[]): void } + close(): void +} + +let quickworkHome: string +let originalQuickworkHome: string | undefined +let externalDirs: string[] + +beforeEach(async () => { + quickworkHome = await mkdtemp(join(tmpdir(), 'quickdesk-provider-test-')) + originalQuickworkHome = process.env['QUICKWORK_HOME'] + process.env['QUICKWORK_HOME'] = quickworkHome + externalDirs = [] +}) + +afterEach(async () => { + if (originalQuickworkHome === undefined) delete process.env['QUICKWORK_HOME'] + else process.env['QUICKWORK_HOME'] = originalQuickworkHome + await rm(quickworkHome, { recursive: true, force: true }) + for (const path of externalDirs) await rm(path, { recursive: true, force: true }) +}) + +async function writeMetrics(basePath: string, date: string, lines: Array | string>): Promise { + const metricsDir = join(basePath, 'metrics') + await mkdir(metricsDir, { recursive: true }) + const path = join(metricsDir, `metrics-${date}.jsonl`) + await writeFile(path, lines.map(line => typeof line === 'string' ? line : JSON.stringify(line)).join('\n') + '\n') + return path +} + +async function writeProfiles(entries: Array<{ id: string, data_path: string }>): Promise { + await writeFile(join(quickworkHome, 'profiles.json'), JSON.stringify({ last_active: entries[0]?.id, entries })) +} + +async function createSessionsDb(basePath: string, withMessages = true): Promise { + const sessionsDir = join(basePath, 'sessions') + await mkdir(sessionsDir, { recursive: true }) + const dbPath = join(sessionsDir, 'sessions.db') + const { DatabaseSync: Database } = requireForTest('node:sqlite') as { + DatabaseSync: new (path: string) => TestDb + } + const db = new Database(dbPath) + db.exec('PRAGMA journal_mode = WAL') + db.exec(` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + title TEXT, + created_at REAL, + updated_at REAL, + message_count INTEGER, + agent_mode TEXT, + deleted_at REAL + ) + `) + if (withMessages) { + db.exec(` + CREATE TABLE session_messages ( + session_id TEXT, + role TEXT, + content TEXT, + timestamp REAL, + tool_names TEXT + ) + `) + } + db.close() + return dbPath +} + +function withDb(dbPath: string, fn: (db: TestDb) => void): void { + const { DatabaseSync: Database } = requireForTest('node:sqlite') as { + DatabaseSync: new (path: string) => TestDb + } + const db = new Database(dbPath) + try { + fn(db) + } finally { + db.close() + } +} + +async function collectCalls(sources: SessionSource[]): Promise { + const calls: ParsedProviderCall[] = [] + const seenKeys = new Set() + for (const source of sources) { + for await (const call of quickdesk.createSessionParser(source, seenKeys).parse()) calls.push(call) + } + return calls +} + +describe('quickdesk pure metrics', () => { + it('parses metrics without requiring a SQLite fixture', async () => { + await writeMetrics(quickworkHome, '2026-07-17', [ + { Model: 'claude-sonnet-4-5', InputTokens: 12, OutputTokens: 6, CostUSD: 0.002 }, + ]) + + const calls = await collectCalls(await quickdesk.discoverSessions()) + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + provider: 'quickdesk', + inputTokens: 12, + outputTokens: 6, + costUSD: 0.002, + costIsEstimated: false, + }) + }) + + it('discovers an absolute data_path outside the store root', async () => { + const absoluteBase = await mkdtemp(join(tmpdir(), 'quickdesk-absolute-profile-')) + externalDirs.push(absoluteBase) + await writeProfiles([{ id: 'absolute-profile', data_path: absoluteBase }]) + const metricsPath = await writeMetrics(absoluteBase, '2026-07-18', [ + { Model: 'claude-sonnet-4-5', InputTokens: 9, OutputTokens: 3, CostUSD: 0.001 }, + ]) + + expect(await quickdesk.discoverSessions()).toEqual([{ + path: metricsPath, + project: 'absolute-profile', + provider: 'quickdesk', + sourceId: 'metrics', + sourcePath: absoluteBase, + }]) + expect(await quickdesk.probeRoots?.()).toEqual([{ path: absoluteBase, label: 'absolute-profile' }]) + }) + + it('namespaces session-less metric keys by profile', async () => { + const firstBase = join(quickworkHome, 'profiles', 'first') + const secondBase = join(quickworkHome, 'profiles', 'second') + await writeProfiles([ + { id: 'first-profile', data_path: 'profiles/first' }, + { id: 'second-profile', data_path: 'profiles/second' }, + ]) + const row = { Model: 'claude-sonnet-4-5', InputTokens: 20, OutputTokens: 5, CostUSD: 0.003 } + await writeMetrics(firstBase, '2026-07-19', [row]) + await writeMetrics(secondBase, '2026-07-19', [row]) + + const calls = await collectCalls(await quickdesk.discoverSessions()) + expect(calls).toHaveLength(2) + expect(calls.map(call => call.deduplicationKey)).toEqual([ + 'quickdesk:first-profile:metrics-2026-07-19.jsonl:2026-07-19T00:00:00.000Z:claude-sonnet-4-5:20:5', + 'quickdesk:second-profile:metrics-2026-07-19.jsonl:2026-07-19T00:00:00.000Z:claude-sonnet-4-5:20:5', + ]) + }) +}) + +const skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip + +skipUnlessSqlite('quickdesk provider', () => { + it('discovers every profile data_path and parses EMF usage, linked tools, real cost, and malformed lines', async () => { + const firstBase = join(quickworkHome, 'stores', 'alpha-data') + const secondBase = join(quickworkHome, 'custom-location', 'beta-data') + await writeProfiles([ + { id: 'profile-alpha', data_path: 'stores/alpha-data' }, + { id: 'profile-beta', data_path: 'custom-location/beta-data' }, + ]) + await writeMetrics(firstBase, '2026-07-14', [ + '{malformed json', + { _aws: { Timestamp: 1783987200000 } }, + { session_id: 'session-alpha', ToolName: 'read_file', ToolCallCount: 1 }, + { + _aws: { Timestamp: 1783987200123 }, + session_id: 'session-alpha', + thread_id: 'thread-1', + Model: 'claude-sonnet-4-5', + InputTokens: 120, + OutputTokens: 30, + CostUSD: 0.0042, + }, + ]) + await writeMetrics(secondBase, '2026-07-15', [ + { session_id: 'session-beta', Model: 'claude-sonnet-4-5', InputTokens: 40, OutputTokens: 10 }, + ]) + + const sources = await quickdesk.discoverSessions() + expect(sources.map(source => [source.project, source.sourcePath])).toEqual([ + ['profile-alpha', firstBase], + ['profile-beta', secondBase], + ]) + expect(await quickdesk.probeRoots?.()).toEqual([ + { path: firstBase, label: 'profile-alpha' }, + { path: secondBase, label: 'profile-beta' }, + ]) + + const calls = await collectCalls(sources) + expect(calls).toHaveLength(2) + expect(calls[0]).toMatchObject({ + provider: 'quickdesk', + model: 'claude-sonnet-4-5', + inputTokens: 120, + outputTokens: 30, + costUSD: 0.0042, + costIsEstimated: false, + tools: ['Read'], + timestamp: '2026-07-14T00:00:00.123Z', + sessionId: 'session-alpha', + project: 'profile-alpha', + }) + expect(calls[0]!.deduplicationKey).toBe( + 'quickdesk:session-alpha:2026-07-14T00:00:00.123Z:claude-sonnet-4-5:120:30', + ) + expect(calls[1]).toMatchObject({ + inputTokens: 40, + outputTokens: 10, + costIsEstimated: true, + timestamp: '2026-07-15T00:00:00.000Z', + project: 'profile-beta', + }) + expect(calls[1]!.costUSD).toBe(calculateCost('claude-sonnet-4-5', 40, 10, 0, 0, 0)) + }) + + it('falls back to the legacy store root when profiles.json is absent', async () => { + const metricsPath = await writeMetrics(quickworkHome, '2026-07-16', [ + { Model: 'claude-sonnet-4-5', InputTokens: 8, OutputTokens: 4, CostUSD: 0.001 }, + ]) + + const sources = await quickdesk.discoverSessions() + expect(sources).toEqual([{ + path: metricsPath, + project: 'default', + provider: 'quickdesk', + sourceId: 'metrics', + sourcePath: quickworkHome, + }]) + expect(await quickdesk.probeRoots?.()).toEqual([{ path: quickworkHome, label: 'default' }]) + + const calls = await collectCalls(sources) + expect(calls[0]).toMatchObject({ + sessionId: 'metrics-2026-07-16.jsonl', + project: 'default', + userMessage: '', + }) + }) + + it('emits sessions from active profiles and a coexisting migrated legacy root database', async () => { + const profileBase = join(quickworkHome, 'profiles', 'active-data') + await writeProfiles([{ id: 'active-profile', data_path: 'profiles/active-data' }]) + const profileDbPath = await createSessionsDb(profileBase) + const legacyDbPath = await createSessionsDb(quickworkHome) + + withDb(profileDbPath, db => { + db.prepare( + 'INSERT INTO sessions (id, title, created_at, updated_at, message_count, agent_mode, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?)', + ).run('profile-session', 'Profile session', 1783987200, 1783987300, 2, 'agent', null) + db.prepare( + 'INSERT INTO session_messages (session_id, role, content, timestamp, tool_names) VALUES (?, ?, ?, ?, ?)', + ).run('profile-session', 'user', 'profile prompt', 1783987201, null) + db.prepare( + 'INSERT INTO session_messages (session_id, role, content, timestamp, tool_names) VALUES (?, ?, ?, ?, ?)', + ).run('profile-session', 'assistant', 'profile answer', 1783987202, null) + }) + withDb(legacyDbPath, db => { + db.prepare( + 'INSERT INTO sessions (id, title, created_at, updated_at, message_count, agent_mode, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?)', + ).run('legacy-session', 'Legacy session', 1777507200, 1777507300, 2, 'agent', null) + db.prepare( + 'INSERT INTO session_messages (session_id, role, content, timestamp, tool_names) VALUES (?, ?, ?, ?, ?)', + ).run('legacy-session', 'user', 'legacy prompt', 1777507201, null) + db.prepare( + 'INSERT INTO session_messages (session_id, role, content, timestamp, tool_names) VALUES (?, ?, ?, ?, ?)', + ).run('legacy-session', 'assistant', 'legacy answer', 1777507202, null) + }) + + const sources = await quickdesk.discoverSessions() + expect(sources.map(source => [source.project, source.path])).toEqual([ + ['active-profile', profileDbPath], + ['default', legacyDbPath], + ]) + const calls = await collectCalls(sources) + expect(calls.map(call => [call.project, call.sessionId])).toEqual([ + ['active-profile', 'profile-session'], + ['default', 'legacy-session'], + ]) + }) + + it('suppresses database estimates globally when profile metrics contain the migrated session id', async () => { + const profileBase = join(quickworkHome, 'profiles', 'active-data') + await writeProfiles([{ id: 'active-profile', data_path: 'profiles/active-data' }]) + const profileDbPath = await createSessionsDb(profileBase) + const legacyDbPath = await createSessionsDb(quickworkHome) + + for (const dbPath of [profileDbPath, legacyDbPath]) { + withDb(dbPath, db => { + db.prepare( + 'INSERT INTO sessions (id, title, created_at, updated_at, message_count, agent_mode, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?)', + ).run('shared-session', 'Shared session', 1783987200, 1783987300, 2, 'agent', null) + db.prepare( + 'INSERT INTO session_messages (session_id, role, content, timestamp, tool_names) VALUES (?, ?, ?, ?, ?)', + ).run('shared-session', 'user', 'shared prompt', 1783987201, null) + db.prepare( + 'INSERT INTO session_messages (session_id, role, content, timestamp, tool_names) VALUES (?, ?, ?, ?, ?)', + ).run('shared-session', 'assistant', 'shared answer', 1783987202, null) + }) + } + await writeMetrics(profileBase, '2026-07-14', [{ + session_id: 'shared-session', + Model: 'claude-sonnet-4-5', + InputTokens: 40, + OutputTokens: 10, + CostUSD: 0.004, + }]) + + const calls = await collectCalls(await quickdesk.discoverSessions()) + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + sessionId: 'shared-session', + costUSD: 0.004, + costIsEstimated: false, + }) + expect(calls.filter(call => call.costIsEstimated)).toHaveLength(0) + }) + + it('enriches metrics and estimates only non-deleted sessions absent from all metrics', async () => { + const dbPath = await createSessionsDb(quickworkHome) + withDb(dbPath, db => { + const insertSession = db.prepare( + 'INSERT INTO sessions (id, title, created_at, updated_at, message_count, agent_mode, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?)', + ) + insertSession.run('metered', 'Metered session', 1783900800, 1783900900, 2, 'agent', null) + insertSession.run('fallback', 'Fallback session', 1783987200, 1783987300, 3, 'agent', null) + insertSession.run('deleted', 'Deleted session', 1784073600, 1784073700, 2, 'agent', 1784073800) + + const insertMessage = db.prepare( + 'INSERT INTO session_messages (session_id, role, content, timestamp, tool_names) VALUES (?, ?, ?, ?, ?)', + ) + insertMessage.run('metered', 'user', 'metered prompt', 1783900801, null) + insertMessage.run('metered', 'assistant', 'metered answer', 1783900802, '["write_file"]') + insertMessage.run('fallback', 'user', '12345678', 1783987201, null) + insertMessage.run('fallback', 'tool', '1234', 1783987202, '["run_command"]') + insertMessage.run('fallback', 'assistant', '123456789', 1783987203, null) + insertMessage.run('deleted', 'user', 'must not appear', 1784073601, null) + insertMessage.run('deleted', 'assistant', 'deleted answer', 1784073602, null) + }) + await writeMetrics(quickworkHome, '2026-07-13', [ + { session_id: 'metered', thread_id: 'thread-a', ToolName: 'read_file' }, + { + session_id: 'metered', + thread_id: 'thread-a', + Model: 'claude-sonnet-4-5', + InputTokens: 50, + OutputTokens: 20, + CostUSD: 0.003, + }, + { + session_id: 'deleted', + Model: 'claude-sonnet-4-5', + InputTokens: 99, + OutputTokens: 99, + CostUSD: 0.9, + }, + ]) + + const calls = await collectCalls(await quickdesk.discoverSessions()) + expect(calls.map(call => call.sessionId)).toEqual(['metered', 'fallback']) + expect(calls[0]).toMatchObject({ + userMessage: 'metered prompt', + tools: ['Read', 'Edit'], + costIsEstimated: false, + }) + expect(calls[1]).toMatchObject({ + model: 'quickdesk-auto', + inputTokens: 3, + outputTokens: 3, + costIsEstimated: true, + tools: ['Bash'], + timestamp: '2026-07-14T00:00:00.000Z', + deduplicationKey: 'quickdesk-est:fallback', + userMessage: '12345678', + }) + expect(calls[1]!.costUSD).toBeGreaterThan(0) + }) + + it('treats millisecond sessions.created_at values as milliseconds', async () => { + const dbPath = await createSessionsDb(quickworkHome) + withDb(dbPath, db => { + db.prepare( + 'INSERT INTO sessions (id, title, created_at, updated_at, message_count, agent_mode, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?)', + ).run('milliseconds-session', 'Milliseconds', 1783987200000, 1783987300000, 2, 'agent', null) + db.prepare( + 'INSERT INTO session_messages (session_id, role, content, timestamp, tool_names) VALUES (?, ?, ?, ?, ?)', + ).run('milliseconds-session', 'user', 'millisecond prompt', 1783987201, null) + db.prepare( + 'INSERT INTO session_messages (session_id, role, content, timestamp, tool_names) VALUES (?, ?, ?, ?, ?)', + ).run('milliseconds-session', 'assistant', 'millisecond answer', 1783987202, null) + }) + + const calls = await collectCalls(await quickdesk.discoverSessions()) + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + sessionId: 'milliseconds-session', + timestamp: '2026-07-14T00:00:00.000Z', + costIsEstimated: true, + }) + }) + + it('keeps metrics when sessions.db has no session_messages table', async () => { + const dbPath = await createSessionsDb(quickworkHome, false) + withDb(dbPath, db => { + db.prepare( + 'INSERT INTO sessions (id, title, created_at, updated_at, message_count, agent_mode, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?)', + ).run('metrics-only', 'Metrics only', 1783987200, 1783987300, 0, 'agent', null) + }) + await writeMetrics(quickworkHome, '2026-07-14', [ + { + session_id: 'metrics-only', + Model: 'claude-sonnet-4-5', + InputTokens: 16, + OutputTokens: 4, + CostUSD: 0.002, + }, + ]) + + await expect(collectCalls(await quickdesk.discoverSessions())).resolves.toMatchObject([{ + sessionId: 'metrics-only', + inputTokens: 16, + outputTokens: 4, + userMessage: '', + }]) + }) + + it('uses raw display values outside the small obvious maps', () => { + expect(providers).toContain(quickdesk) + expect(quickdesk.modelDisplayName('quickdesk-auto')).toBe('Quick Desktop (auto)') + expect(getShortModelName('quickdesk-auto')).toBe('Quick Desktop (auto)') + expect(quickdesk.modelDisplayName('claude-sonnet-4-5')).toBe('Sonnet 4.5') + expect(quickdesk.modelDisplayName('future-model')).toBe('future-model') + expect(quickdesk.toolDisplayName('read_file')).toBe('Read') + expect(quickdesk.toolDisplayName('future_tool')).toBe('future_tool') + }) +})