Merge pull request #735 from ozymandiashh/feat/quickdesk-provider

feat(quickdesk): Amazon Quick Desktop provider (~/.quickwork)
This commit is contained in:
Resham Joshi 2026-07-18 12:57:08 -07:00 committed by GitHub
commit 906cb2d550
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1056 additions and 3 deletions

View file

@ -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` |

View file

@ -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_id>:<timestamp>:<model>:<input>:<output>`. Session-less calls use `quickdesk:<profile>:<file>:<timestamp>:<model>:<input>:<output>`. Estimated database-only sessions use `quickdesk-est:<session_id>`. 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 `<profile_base>/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.

View file

@ -266,6 +266,7 @@ const BUILTIN_ALIASES: Record<string, string> = {
'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<string, string> = {
'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)',

View file

@ -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,

View file

@ -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<Provider | null> {
}
}
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.

553
src/providers/quickdesk.ts Normal file
View file

@ -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<string, string> = {
'claude-sonnet-4-5': 'Sonnet 4.5',
'claude-sonnet-4-6': 'Sonnet 4.6',
}
const toolNameMap: Record<string, string> = {
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<string, unknown>
}
type SessionMetadata = {
id: string
title: string
agentMode: string
createdAt?: number
deleted: boolean
firstUserMessage: string
inputChars: number
outputChars: number
tools: string[]
}
type DatabaseSnapshot = {
sessions: Map<string, SessionMetadata>
canEstimate: boolean
}
type SqliteMasterRow = {
name?: unknown
}
type TableInfoRow = {
name?: unknown
}
type SessionRow = Record<string, unknown> & {
id?: unknown
title?: unknown
agent_mode?: unknown
created_at?: unknown
deleted_at?: unknown
}
type MessageRow = Record<string, unknown> & {
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<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: 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<ProfileBase[]> {
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<string>()
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<boolean> {
try {
return (await stat(path)).isFile()
} catch {
return false
}
}
async function metricsFiles(basePath: string): Promise<string[]> {
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<SessionSource[]> {
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<string> {
const rows = db.query<SqliteMasterRow>(
"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<string> {
const rows = db.query<TableInfoRow>(`PRAGMA table_info(${table})`)
return new Set(rows.map(row => stringValue(row.name)).filter(Boolean))
}
function selectColumn(columns: Set<string>, 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<SessionRow>(
`SELECT id,
${selectColumn(sessionColumns, 'title')},
${selectColumn(sessionColumns, 'agent_mode')},
${selectColumn(sessionColumns, 'created_at')},
${selectColumn(sessionColumns, 'deleted_at')}
FROM sessions`,
)
const sessions = new Map<string, SessionMetadata>()
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<MessageRow>(
`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<MetricsRecord[]> {
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<string, unknown>): boolean {
return Boolean(stringValue(record['Model']))
&& nonNegativeNumber(record['InputTokens']) !== undefined
&& nonNegativeNumber(record['OutputTokens']) !== undefined
}
function sessionId(record: Record<string, unknown>): string {
return stringValue(record['session_id'])
}
function toolsKey(record: Record<string, unknown>): string {
return sessionId(record)
}
function collectMetricTools(records: MetricsRecord[]): Map<string, string[]> {
const tools = new Map<string, string[]>()
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<string, unknown>, 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<Set<string>> {
const ids = new Set<string>()
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<Set<string>> {
const ids = new Set<string>()
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<string>): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
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<string>): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
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<ProbeRoot[]> {
return (await resolveProfileBases()).map(base => ({ path: base.path, label: base.profile }))
},
async discoverSessions(): Promise<SessionSource[]> {
return discoverSources()
},
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
return source.sourceId === 'sessions-db' || basename(source.path) === 'sessions.db'
? createDatabaseParser(source, seenKeys)
: createMetricsParser(source, seenKeys)
},
}

View file

@ -128,6 +128,7 @@ export const PROVIDER_ENV_VARS: Record<string, string[]> = {
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<string, string> = {
'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',

View file

@ -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', () => {

View file

@ -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<Record<string, unknown> | string>): Promise<string> {
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<void> {
await writeFile(join(quickworkHome, 'profiles.json'), JSON.stringify({ last_active: entries[0]?.id, entries }))
}
async function createSessionsDb(basePath: string, withMessages = true): Promise<string> {
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<ParsedProviderCall[]> {
const calls: ParsedProviderCall[] = []
const seenKeys = new Set<string>()
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')
})
})