diff --git a/src/providers/dsh.ts b/src/providers/dsh.ts new file mode 100644 index 00000000..7595e106 --- /dev/null +++ b/src/providers/dsh.ts @@ -0,0 +1,481 @@ +import { open, readdir, readFile, stat } from 'fs/promises' +import { join } from 'path' +import { homedir } from 'os' +import zlib from 'zlib' + +import { readSessionFile } from '../fs-utils.js' +import { calculateCost, getShortModelName } from '../models.js' +import { extractBashCommands } from '../bash-utils.js' +import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' + +// DeepSeek Harness (dsh) stores one session per directory: +// /sessions//session-/session.jsonl.zstd +// (or an uncompressed session.jsonl when compression=none). The .zstd file is +// a concatenation of INDEPENDENT zstd frames — one per appended event batch — +// so node:zlib's one-shot zstdDecompressSync (which decodes a single frame) +// must be driven frame-by-frame behind a structural frame-boundary scan. The +// scan below is a port of scanZstdFrames from the official +// @deepseek-ai/dsh-session-persistence-jsonl package. + +// zstd landed in node:zlib in 22.15 / 23.8; the package floor is lower, so the +// provider degrades with a notice instead of assuming the export exists. +const zstdDecompress = (zlib as { zstdDecompressSync?: (buf: Buffer) => Buffer }).zstdDecompressSync + +const ZSTD_MAGIC = 0xfd2fb528 + +type ZstdFrame = { start: number; end: number } + +// Locate complete frames without decompressing their blocks. An EOF inside the +// final frame (a torn append from a crashed writer) returns its start so the +// caller can ignore the tail; invalid complete structure rejects. +function scanZstdFrames(buffer: Buffer, maxFrames = Number.POSITIVE_INFINITY): { frames: ZstdFrame[]; tornStart?: number } { + const frames: ZstdFrame[] = [] + let offset = 0 + while (offset < buffer.length) { + const start = offset + if (buffer.length - offset < 4) return { frames, tornStart: start } + if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) { + throw new Error(`invalid zstd frame magic at byte ${offset}`) + } + offset += 4 + if (offset === buffer.length) return { frames, tornStart: start } + const descriptor = buffer.readUInt8(offset)! + offset += 1 + if ((descriptor & 24) !== 0) throw new Error(`reserved frame-header bit at byte ${offset - 1}`) + const contentSizeFlag = descriptor >>> 6 + const singleSegment = (descriptor & 32) !== 0 + const checksum = (descriptor & 4) !== 0 + const dictionaryFlag = descriptor & 3 + const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag + const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag + const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes + if (buffer.length - offset < remainingHeaderBytes) return { frames, tornStart: start } + offset += remainingHeaderBytes + for (;;) { + if (buffer.length - offset < 3) return { frames, tornStart: start } + const blockHeader = buffer.readUIntLE(offset, 3) + offset += 3 + const lastBlock = (blockHeader & 1) !== 0 + const blockType = (blockHeader >>> 1) & 3 + const blockSize = blockHeader >>> 3 + if (blockType === 3) throw new Error(`reserved block type at byte ${offset - 3}`) + const payloadBytes = blockType === 1 ? 1 : blockSize + if (buffer.length - offset < payloadBytes) return { frames, tornStart: start } + offset += payloadBytes + if (lastBlock) break + } + if (checksum) { + if (buffer.length - offset < 4) return { frames, tornStart: start } + offset += 4 + } + frames.push({ start, end: offset }) + if (frames.length === maxFrames) return { frames } + } + return { frames } +} + +type DshUsage = { + inputTokens?: number + outputTokens?: number + cacheReadTokens?: number + cacheWriteTokens?: number + reasoningTokens?: number +} + +type DshEvent = { + type?: string + seq?: number + time?: number + // Session header fields live at the top level of the first event. + id?: string + cwd?: string + data?: { + turn?: number + step?: number + content?: Array<{ type?: string; text?: string }> + header?: { config?: { model?: string; provider?: string } } + chunk?: { type?: string; usage?: DshUsage } + usage?: DshUsage + name?: string + arguments?: string + } +} + +type StepBucket = { + usage: DshUsage + // A usage report from assistant/message is the final value for its + // (turn, step) and replaces an earlier assistant/chunk sample (the two are + // adjacent reports of the same API call, per dsh-token-meter's usage + // projection). Time follows the winning report. + final: boolean + time?: number + // Model in force when this step's usage was reported (the most recent + // request/header config at that point in the log). + model: string + tools: string[] + skills: string[] + bashCommands: string[] +} + +const toolNameMap: Record = { + bash: 'Bash', + pwsh: 'Bash', + read: 'Read', + write: 'Write', + edit: 'Edit', + str_replace_editor: 'Edit', + glob: 'Glob', + grep: 'Grep', + todo_write: 'TodoWrite', + todo: 'TodoWrite', + web_search: 'WebSearch', + skill: 'Skill', + agent: 'Agent', + ask_user_question: 'AskUserQuestion', +} + +function mapToolName(raw: string): string { + return toolNameMap[raw] ?? raw +} + +function getDshHome(override?: string): string { + // An empty-string DSH_HOME is treated as unset. + return override ?? (process.env['DSH_HOME'] || undefined) ?? join(homedir(), '.dsh') +} + +// DSH writes native-platform paths into the header (backslashes on Windows); +// split on both separators so discovery is correct on any host. +function projectFromCwd(cwd: string, fallback: string): string { + const segments = cwd.split(/[\\/]/).filter(Boolean) + return segments[segments.length - 1] ?? fallback +} + +// Decode every complete frame and yield its JSONL lines. A torn final frame is +// ignored; a structurally corrupt file throws for the caller to report. +function* readZstdLines(buffer: Buffer, maxFrames = Number.POSITIVE_INFINITY): Generator { + const { frames } = scanZstdFrames(buffer, maxFrames) + for (const frame of frames) { + const text = zstdDecompress!(buffer.subarray(frame.start, frame.end)).toString('utf-8') + for (const line of text.split('\n')) { + if (line.trim()) yield line + } + } +} + +async function readEventLines(filePath: string): Promise { + if (filePath.endsWith('.zstd')) { + if (!zstdDecompress) { + process.stderr.write('codeburn: DSH sessions need Node >= 22.15 (zstd support); skipping DSH usage.\n') + return null + } + let buffer: Buffer + try { + buffer = await readFile(filePath) + } catch { + return null + } + try { + return [...readZstdLines(buffer)] + } catch (err) { + process.stderr.write(`codeburn: skipped corrupt DSH session log ${filePath}: ${err instanceof Error ? err.message : err}\n`) + return null + } + } + const content = await readSessionFile(filePath) + if (content === null) return null + return content.split('\n').filter(l => l.trim()) +} + +// Cheap discovery probe: decompress ONLY the first frame (the session header +// batch) instead of the whole log. The header frame is tiny, so a bounded head +// read almost always contains it; fall back to a full read when it does not. +async function readSessionHeader(filePath: string): Promise { + const firstLine = async (): Promise => { + if (filePath.endsWith('.zstd')) { + if (!zstdDecompress) return null + let head: Buffer + try { + const handle = await open(filePath, 'r') + try { + const size = (await handle.stat()).size + const length = Math.min(size, 256 * 1024) + head = Buffer.alloc(length) + await handle.read(head, 0, length, 0) + } finally { + await handle.close() + } + } catch { + return null + } + let { frames } = scanZstdFrames(head, 1) + if (frames.length === 0) { + // Head read did not cover one full frame; take the whole file. + try { + const full = await readFile(filePath) + frames = scanZstdFrames(full, 1).frames + if (frames.length === 0) return null + head = full + } catch { + return null + } + } + const text = zstdDecompress(head.subarray(frames[0]!.start, frames[0]!.end)).toString('utf-8') + return text.split('\n').find(l => l.trim()) ?? null + } + const content = await readSessionFile(filePath) + return content?.split('\n').find(l => l.trim()) ?? null + } + + try { + const line = await firstLine() + if (!line) return null + const event = JSON.parse(line) as DshEvent + return event.type === 'session' ? event : null + } catch { + return null + } +} + +async function discoverSessionsInDir(sessionsDir: string): Promise { + const sources: SessionSource[] = [] + + let projectDirs: string[] + try { + projectDirs = await readdir(sessionsDir) + } catch { + return sources + } + + for (const dirName of projectDirs) { + const dirPath = join(sessionsDir, dirName) + const dirStat = await stat(dirPath).catch(() => null) + if (!dirStat?.isDirectory()) continue + + let sessionDirs: string[] + try { + sessionDirs = await readdir(dirPath) + } catch { + continue + } + + for (const sessionDir of sessionDirs) { + const sessionPath = join(dirPath, sessionDir) + const sessionStat = await stat(sessionPath).catch(() => null) + if (!sessionStat?.isDirectory()) continue + + // Compressed log first; the uncompressed variant exists when + // compression=none. Never both for the same session. + let filePath: string | null = null + for (const name of ['session.jsonl.zstd', 'session.jsonl']) { + const candidate = join(sessionPath, name) + const fileStat = await stat(candidate).catch(() => null) + if (fileStat?.isFile()) { + filePath = candidate + break + } + } + if (!filePath) continue + + const header = await readSessionHeader(filePath) + if (!header) continue + + const cwd = typeof header.cwd === 'string' && header.cwd.trim() ? header.cwd : dirName + sources.push({ path: filePath, project: projectFromCwd(cwd, dirName), provider: 'dsh' }) + } + } + + return sources +} + +function parseToolArguments(raw: string | undefined): Record | null { + if (!raw) return null + try { + const parsed = JSON.parse(raw) as unknown + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : null + } catch { + return null + } +} + +function createParser(source: SessionSource, seenKeys: Set): SessionParser { + return { + async *parse(): AsyncGenerator { + const lines = await readEventLines(source.path) + if (!lines) return + + let sessionId = '' + let cwd = '' + let model = 'unknown' + let currentTurn = 0 + const userMessageByTurn = new Map() + const buckets = new Map() + + for (const line of lines) { + let event: DshEvent + try { + event = JSON.parse(line) as DshEvent + } catch { + continue + } + + if (event.type === 'session') { + sessionId = event.id ?? sessionId + cwd = event.cwd ?? cwd + continue + } + + if (event.type === 'turn/start') { + currentTurn = event.data?.turn ?? currentTurn + continue + } + + if (event.type === 'request/header') { + // Emitted at most once per request; steps after the last header + // inherit its config as their model. + const headerModel = event.data?.header?.config?.model + if (typeof headerModel === 'string' && headerModel) model = headerModel + continue + } + + if (event.type === 'user/message') { + const texts = (event.data?.content ?? []) + .filter(c => c.type === 'text' && typeof c.text === 'string' && c.text) + .map(c => c.text!) + if (texts.length > 0) userMessageByTurn.set(currentTurn, texts.join(' ')) + continue + } + + if (event.type === 'tool/call') { + const turn = event.data?.turn ?? currentTurn + const step = event.data?.step ?? 0 + const rawName = event.data?.name + if (!rawName) continue + const key = `${turn}:${step}` + let bucket = buckets.get(key) + if (!bucket) { + bucket = { usage: {}, final: false, model, tools: [], skills: [], bashCommands: [] } + buckets.set(key, bucket) + } + bucket.tools.push(mapToolName(rawName)) + const args = parseToolArguments(event.data?.arguments) + if ((rawName === 'bash' || rawName === 'pwsh') && typeof args?.['command'] === 'string') { + bucket.bashCommands.push(...extractBashCommands(args['command'])) + } + if (rawName === 'skill' && typeof args?.['name'] === 'string') { + bucket.skills.push(args['name']) + } + continue + } + + let usage: DshUsage | undefined + let isFinal = false + if (event.type === 'assistant/chunk' && event.data?.chunk?.type === 'usage') { + usage = event.data.chunk.usage + } else if (event.type === 'assistant/message' && event.data?.usage) { + usage = event.data.usage + isFinal = true + } else { + continue + } + if (!usage) continue + + const turn = event.data?.turn ?? currentTurn + const step = event.data?.step ?? 0 + const key = `${turn}:${step}` + let bucket = buckets.get(key) + if (!bucket) { + bucket = { usage: {}, final: false, model, tools: [], skills: [], bashCommands: [] } + buckets.set(key, bucket) + } + // A final report replaces an earlier sample; a late sample never + // overwrites a final one. The model snapshot follows the winning + // report (a header can change the model mid-turn between steps). + if (isFinal || !bucket.final) { + bucket.usage = usage + bucket.final = isFinal + bucket.time = event.time + bucket.model = model + } + } + + const sortedKeys = [...buckets.keys()].sort((a, b) => { + const [ta, sa] = a.split(':').map(Number) + const [tb, sb] = b.split(':').map(Number) + return ta! - tb! || sa! - sb! + }) + + for (const key of sortedKeys) { + const bucket = buckets.get(key)! + const input = bucket.usage.inputTokens ?? 0 + const output = bucket.usage.outputTokens ?? 0 + const cacheRead = bucket.usage.cacheReadTokens ?? 0 + const cacheWrite = bucket.usage.cacheWriteTokens ?? 0 + const reasoning = bucket.usage.reasoningTokens ?? 0 + if (input + output + cacheRead + cacheWrite + reasoning === 0) continue + + const dedupKey = `dsh:${sessionId || source.path}:${key}` + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + // DSH bills reasoning tokens at the output rate (same as Gemini). + const costUSD = calculateCost(bucket.model, input, output + reasoning, cacheWrite, cacheRead, 0) + const [turn] = key.split(':').map(Number) + + yield { + provider: 'dsh', + model: bucket.model, + inputTokens: input, + outputTokens: output, + cacheCreationInputTokens: cacheWrite, + cacheReadInputTokens: cacheRead, + cachedInputTokens: cacheRead, + reasoningTokens: reasoning, + webSearchRequests: 0, + costUSD, + tools: [...new Set(bucket.tools)], + bashCommands: bucket.bashCommands, + skills: bucket.skills.length > 0 ? [...new Set(bucket.skills)] : undefined, + timestamp: typeof bucket.time === 'number' ? new Date(bucket.time).toISOString() : '', + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: userMessageByTurn.get(turn!) ?? '', + sessionId: sessionId || source.path, + project: cwd ? projectFromCwd(cwd, source.project) : source.project, + projectPath: cwd || undefined, + } + } + }, + } +} + +export function createDshProvider(dshHomeOverride?: string): Provider { + const dshHome = getDshHome(dshHomeOverride) + const sessionsDir = join(dshHome, 'sessions') + + return { + name: 'dsh', + displayName: 'DeepSeek Harness', + + modelDisplayName(model: string): string { + return getShortModelName(model) + }, + + toolDisplayName(rawTool: string): string { + return mapToolName(rawTool) + }, + + async probeRoots(): Promise { + return [{ path: sessionsDir, label: 'sessions' }] + }, + + async discoverSessions(): Promise { + return discoverSessionsInDir(sessionsDir) + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const dsh = createDshProvider() diff --git a/src/providers/index.ts b/src/providers/index.ts index bf035f06..70af252c 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -7,6 +7,7 @@ import { codex } from './codex.js' import { copilot } from './copilot.js' import { droid } from './droid.js' import { devin } from './devin.js' +import { dsh } from './dsh.js' import { gemini } from './gemini.js' import { hermes } from './hermes.js' import { ibmBob } from './ibm-bob.js' @@ -192,7 +193,7 @@ async function loadZed(): Promise { } } -const coreProviders: Provider[] = [claude, cline, clineCli, codewhale, codebuff, codex, copilot, devin, droid, gemini, hermes, ibmBob, kiloCode, kiro, kimi, kimicode, lingtaiTui, mistralVibe, mux, openclaw, openclaude, openDesign, pi, omp, qwen, quickdesk, rooCode, zerostack, grok] +const coreProviders: Provider[] = [claude, cline, clineCli, codewhale, codebuff, codex, copilot, devin, droid, dsh, gemini, hermes, ibmBob, kiloCode, kiro, kimi, kimicode, lingtaiTui, mistralVibe, mux, openclaw, openclaude, 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/session-cache.ts b/src/session-cache.ts index 2759520f..242551d1 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -198,6 +198,7 @@ export const PROVIDER_ENV_VARS: Record = { hermes: ['HERMES_HOME'], 'lingtai-tui': ['LINGTAI_HOME', 'LINGTAI_TUI_HOME', 'LINGTAI_TUI_GLOBAL_DIR'], droid: ['FACTORY_DIR'], + dsh: ['DSH_HOME'], cursor: ['CODEBURN_CURSOR_MAX_BUBBLES'], // XDG_DATA_HOME is stale here (cursor-agent never reads it) but deliberately // kept: removing it would force a re-parse to fix nothing. @@ -266,6 +267,7 @@ export const PROVIDER_PARSE_VERSIONS: Record = { // input/cache rollup; this bump re-parses them so the missing tokens land. copilot: 'cli-shutdown-cost-v1-skills-source-provenance-v1', grok: 'estimated-cost-v1', + dsh: 'v1', hermes: 'reasoning-output-accounting-v1-est-cost', 'lingtai-tui': 'token-ledger-registry-activity-v3', 'ibm-bob': 'worktree-project-grouping-v1', diff --git a/tests/provider-env-declarations.test.ts b/tests/provider-env-declarations.test.ts index 2044571f..9e374057 100644 --- a/tests/provider-env-declarations.test.ts +++ b/tests/provider-env-declarations.test.ts @@ -34,6 +34,7 @@ const FILE_PROVIDERS: Record = { 'codex.ts': ['codex'], 'copilot.ts': ['copilot'], 'droid.ts': ['droid'], + 'dsh.ts': ['dsh'], 'hermes.ts': ['hermes'], 'lingtai-tui.ts': ['lingtai-tui'], // Its only literal read is CODEBURN_CURSOR_MAX_BUBBLES (cursor.ts:692). diff --git a/tests/provider-registry.test.ts b/tests/provider-registry.test.ts index 23b383e4..f2481e5c 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', 'cline-cli', 'codewhale', 'codebuff', 'codex', 'copilot', 'devin', 'droid', 'gemini', 'hermes', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'kimicode', 'lingtai-tui', 'mistral-vibe', 'mux', 'openclaw', 'openclaude', 'open-design', 'pi', 'omp', 'qwen', 'quickdesk', 'roo-code', 'zerostack', 'grok']) + expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'cline-cli', 'codewhale', 'codebuff', 'codex', 'copilot', 'devin', 'droid', 'dsh', 'gemini', 'hermes', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'kimicode', 'lingtai-tui', 'mistral-vibe', 'mux', 'openclaw', 'openclaude', '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/dsh.test.ts b/tests/providers/dsh.test.ts new file mode 100644 index 00000000..b5fbfcb0 --- /dev/null +++ b/tests/providers/dsh.test.ts @@ -0,0 +1,406 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import { join } from 'path' +import { homedir, tmpdir } from 'os' +import zlib from 'zlib' + +import { createDshProvider } from '../../src/providers/dsh.js' +import { calculateCost } from '../../src/models.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +// DSH session logs are concatenations of INDEPENDENT zstd frames (one per +// appended event batch), so fixtures must compress each batch separately — +// a single zstdCompressSync over the whole file is a different (single-frame) +// format than what DSH writes. + +const zstdCompress = (zlib as { zstdCompressSync?: (buf: Buffer) => Buffer }).zstdCompressSync + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'dsh-test-')) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +function sessionHeader(opts: { id?: string; cwd?: string } = {}) { + return JSON.stringify({ + type: 'session', + version: 0, + id: opts.id ?? 'session-00000000-0000-0000-0000-000000000001', + createdAt: 1786707336131, + cwd: opts.cwd ?? 'C:\\Users\\test\\myproject', + delegationDepth: 0, + agentPreset: 'cordis', + }) +} + +function requestHeader(model: string, time = 1786707337000) { + return JSON.stringify({ + type: 'request/header', + seq: 10, + time, + data: { header: { config: { provider: 'deepseek-official', model, reasoningEffort: 'max', maxTokens: 256000 } } }, + }) +} + +function turnStart(turn: number, time: number) { + return JSON.stringify({ type: 'turn/start', seq: 1, time, data: { turn } }) +} + +function userMessage(text: string, time: number) { + return JSON.stringify({ + type: 'user/message', + seq: 2, + time, + data: { content: [{ type: 'text', text }], source: { kind: 'user' }, role: 'user', id: 'msg-1' }, + }) +} + +function chunkUsage(turn: number, step: number, usage: Record, time: number) { + return JSON.stringify({ + type: 'assistant/chunk', + seq: 3, + time, + data: { turn, step, chunk: { type: 'usage', usage } }, + }) +} + +function assistantMessage(turn: number, step: number, usage: Record | undefined, time: number) { + return JSON.stringify({ + type: 'assistant/message', + seq: 4, + time, + data: { + turn, + step, + message: { role: 'assistant', content: [{ type: 'text', text: 'done' }] }, + ...(usage ? { usage } : {}), + }, + }) +} + +function toolCall(turn: number, step: number, name: string, args: Record, time: number) { + return JSON.stringify({ + type: 'tool/call', + seq: 5, + time, + data: { turn, step, callId: `call_${name}`, name, arguments: JSON.stringify(args) }, + }) +} + +// Write one frame per batch of lines, matching DSH's append-per-batch layout. +async function writeZstdSession(projectDirName: string, sessionDirName: string, batches: string[][]) { + const dir = join(tmpDir, 'sessions', projectDirName, sessionDirName) + await mkdir(dir, { recursive: true }) + const filePath = join(dir, 'session.jsonl.zstd') + const frames = batches.map(lines => zstdCompress!(Buffer.from(lines.join('\n') + '\n', 'utf-8'))) + await writeFile(filePath, Buffer.concat(frames)) + return filePath +} + +async function writePlainSession(projectDirName: string, sessionDirName: string, lines: string[]) { + const dir = join(tmpDir, 'sessions', projectDirName, sessionDirName) + await mkdir(dir, { recursive: true }) + const filePath = join(dir, 'session.jsonl') + await writeFile(filePath, lines.join('\n') + '\n') + return filePath +} + +async function parseAll(provider: ReturnType, filePath: string): Promise { + const source = { path: filePath, project: 'myproject', provider: 'dsh' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + return calls +} + +describe('dsh provider - session discovery', () => { + it('discovers a multi-frame zstd session, project from the header cwd', async () => { + await writeZstdSession('--C-Users-test-myproject--', 'session-abc', [ + [sessionHeader({ cwd: 'C:\\Users\\test\\myproject' })], + [assistantMessage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000)], + ]) + + const provider = createDshProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.provider).toBe('dsh') + expect(sessions[0]!.project).toBe('myproject') + expect(sessions[0]!.path).toContain('session.jsonl.zstd') + }) + + it('discovers the uncompressed session.jsonl variant (compression=none)', async () => { + await writePlainSession('--home-u-proj--', 'session-plain', [ + sessionHeader({ cwd: '/home/u/proj' }), + assistantMessage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000), + ]) + + const provider = createDshProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.path).toContain('session.jsonl') + expect(sessions[0]!.path).not.toContain('zstd') + expect(sessions[0]!.project).toBe('proj') + }) + + it('returns empty for a non-existent home', async () => { + const provider = createDshProvider('/nonexistent/dsh/home') + expect(await provider.discoverSessions()).toEqual([]) + }) + + it('skips session dirs without a session log', async () => { + await mkdir(join(tmpDir, 'sessions', '--x--', 'session-empty'), { recursive: true }) + const provider = createDshProvider(tmpDir) + expect(await provider.discoverSessions()).toEqual([]) + }) + + it('DSH_HOME relocates discovery; an empty string is treated as unset', async () => { + const home = join(tmpDir, 'dsh-home') + await mkdir(join(home, 'sessions', '--x--', 'session-env'), { recursive: true }) + await writeFile( + join(home, 'sessions', '--x--', 'session-env', 'session.jsonl'), + sessionHeader({ cwd: '/x' }) + '\n', + ) + + const saved = process.env['DSH_HOME'] + process.env['DSH_HOME'] = home + try { + const sessions = await createDshProvider().discoverSessions() + expect(sessions).toHaveLength(1) + } finally { + if (saved === undefined) delete process.env['DSH_HOME'] + else process.env['DSH_HOME'] = saved + } + + process.env['DSH_HOME'] = '' + try { + const roots = await createDshProvider().probeRoots!() + expect(roots).toEqual([{ path: join(homedir(), '.dsh', 'sessions'), label: 'sessions' }]) + } finally { + if (saved === undefined) delete process.env['DSH_HOME'] + else process.env['DSH_HOME'] = saved + } + }) + + it('probeRoots reports the sessions dir under the factory root', async () => { + expect(await createDshProvider('/tmp/dsh-a').probeRoots!()).toEqual([ + { path: join('/tmp/dsh-a', 'sessions'), label: 'sessions' }, + ]) + }) +}) + +describe('dsh provider - parsing', () => { + it('decodes events spread across multiple independent zstd frames', async () => { + const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-multi', [ + [sessionHeader({ id: 'session-multi', cwd: 'C:\\Users\\test\\myproject' })], + [turnStart(1, 1786707339000), userMessage('build the thing', 1786707339100)], + [chunkUsage(1, 1, { inputTokens: 500, outputTokens: 50 }, 1786707340000)], + [chunkUsage(1, 2, { inputTokens: 800, outputTokens: 80 }, 1786707341000)], + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(2) + expect(calls[0]!.inputTokens).toBe(500) + expect(calls[1]!.inputTokens).toBe(800) + }) + + it('a final assistant/message usage REPLACES the earlier chunk sample for the same turn/step', async () => { + const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-replace', [ + [sessionHeader({ id: 'session-replace' })], + [turnStart(1, 1786707339000)], + // Early sample, then the final report of the SAME API call: the totals + // must come from the final report only, not the sum of both. + [chunkUsage(1, 1, { inputTokens: 14900, outputTokens: 600, reasoningTokens: 500 }, 1786707340000)], + [assistantMessage(1, 1, { inputTokens: 14981, outputTokens: 656, cacheReadTokens: 0, reasoningTokens: 609 }, 1786707340050)], + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(14981) + expect(calls[0]!.outputTokens).toBe(656) + expect(calls[0]!.reasoningTokens).toBe(609) + expect(calls[0]!.timestamp).toBe(new Date(1786707340050).toISOString()) + }) + + it('a chunk sample arriving after the final report does not overwrite it', async () => { + const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-late', [ + [sessionHeader({ id: 'session-late' })], + [assistantMessage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340050)], + [chunkUsage(1, 1, { inputTokens: 999, outputTokens: 99 }, 1786707340100)], + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(100) + }) + + it('falls back to the chunk sample when no assistant/message usage arrives', async () => { + const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-sample', [ + [sessionHeader({ id: 'session-sample' })], + [chunkUsage(2, 3, { inputTokens: 42, outputTokens: 7 }, 1786707340000)], + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(42) + expect(calls[0]!.deduplicationKey).toBe('dsh:session-sample:2:3') + }) + + it('steps inherit the model of the most recent request/header', async () => { + const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-model', [ + [sessionHeader({ id: 'session-model' })], + [requestHeader('deepseek-v4-pro', 1786707337000)], + [assistantMessage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000)], + [assistantMessage(1, 2, { inputTokens: 200, outputTokens: 20 }, 1786707341000)], + [requestHeader('deepseek-v4-flash', 1786707342000)], + [assistantMessage(2, 1, { inputTokens: 300, outputTokens: 30 }, 1786707343000)], + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls.map(c => c.model)).toEqual(['deepseek-v4-pro', 'deepseek-v4-pro', 'deepseek-v4-flash']) + }) + + it('bills reasoning tokens at the output rate', async () => { + const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-reason', [ + [sessionHeader({ id: 'session-reason' })], + [requestHeader('deepseek-v4-pro')], + [assistantMessage(1, 1, { inputTokens: 1000, outputTokens: 100, cacheWriteTokens: 50, cacheReadTokens: 500, reasoningTokens: 400 }, 1786707340000)], + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(1) + expect(calls[0]!.costUSD).toBeCloseTo(calculateCost('deepseek-v4-pro', 1000, 500, 50, 500, 0), 12) + }) + + it('collects mapped tools, skill names and bash commands from tool/call events', async () => { + const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-tools', [ + [sessionHeader({ id: 'session-tools' })], + [ + toolCall(1, 1, 'read', { path: '/x/a.ts' }, 1786707339500), + toolCall(1, 1, 'edit', { path: '/x/a.ts' }, 1786707339600), + toolCall(1, 1, 'bash', { command: 'git status && bun test' }, 1786707339700), + toolCall(1, 1, 'skill', { name: 'coding-agent-orchestration' }, 1786707339800), + toolCall(1, 1, 'cordis_run', { id: 'j1' }, 1786707339900), + chunkUsage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000), + ], + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(1) + expect(calls[0]!.tools).toEqual(['Read', 'Edit', 'Bash', 'Skill', 'cordis_run']) + expect(calls[0]!.bashCommands).toEqual(['git', 'bun']) + expect(calls[0]!.skills).toEqual(['coding-agent-orchestration']) + }) + + it('pairs the user message of the turn and carries session id and project', async () => { + const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-ctx', [ + [sessionHeader({ id: 'session-ctx', cwd: 'C:\\Users\\test\\myproject' })], + [turnStart(1, 1786707339000), userMessage('first question', 1786707339100)], + [chunkUsage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000)], + [turnStart(2, 1786707350000), userMessage('second question', 1786707350100)], + [chunkUsage(2, 1, { inputTokens: 200, outputTokens: 20 }, 1786707351000)], + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(2) + expect(calls[0]!.userMessage).toBe('first question') + expect(calls[1]!.userMessage).toBe('second question') + expect(calls[0]!.sessionId).toBe('session-ctx') + expect(calls[0]!.project).toBe('myproject') + expect(calls[0]!.projectPath).toBe('C:\\Users\\test\\myproject') + }) + + it('parses the uncompressed session.jsonl variant', async () => { + const filePath = await writePlainSession('--home-u-proj--', 'session-plain', [ + sessionHeader({ id: 'session-plain', cwd: '/home/u/proj' }), + turnStart(1, 1786707339000), + userMessage('hello', 1786707339100), + chunkUsage(1, 1, { inputTokens: 123, outputTokens: 45 }, 1786707340000), + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(123) + expect(calls[0]!.outputTokens).toBe(45) + }) + + it('skips buckets whose usage is all zero', async () => { + const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-zero', [ + [sessionHeader({ id: 'session-zero' })], + [assistantMessage(1, 1, { inputTokens: 0, outputTokens: 0 }, 1786707340000)], + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(0) + }) + + it('ignores a torn final frame appended by a crashed writer', async () => { + const dir = join(tmpDir, 'sessions', '--C-Users-test-myproject--', 'session-torn') + await mkdir(dir, { recursive: true }) + const filePath = join(dir, 'session.jsonl.zstd') + const good = zstdCompress!(Buffer.from( + sessionHeader({ id: 'session-torn' }) + '\n' + + chunkUsage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000) + '\n', + )) + const torn = zstdCompress!(Buffer.from(chunkUsage(1, 2, { inputTokens: 1, outputTokens: 1 }, 1786707341000) + '\n')) + await writeFile(filePath, Buffer.concat([good, torn.subarray(0, Math.floor(torn.length / 2))])) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(100) + }) + + it('deduplicates (turn, step) calls seen across multiple parses', async () => { + const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-dedup', [ + [sessionHeader({ id: 'session-dedup' })], + [chunkUsage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000)], + ]) + + const provider = createDshProvider(tmpDir) + const source = { path: filePath, project: 'myproject', provider: 'dsh' } + const seenKeys = new Set() + + const firstRun: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seenKeys).parse()) firstRun.push(call) + const secondRun: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seenKeys).parse()) secondRun.push(call) + + expect(firstRun).toHaveLength(1) + expect(secondRun).toHaveLength(0) + }) + + it('handles a missing session file gracefully', async () => { + const provider = createDshProvider(tmpDir) + const source = { path: join(tmpDir, 'nope', 'session.jsonl.zstd'), project: 'test', provider: 'dsh' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call) + expect(calls).toHaveLength(0) + }) +}) + +describe('dsh provider - display names', () => { + const provider = createDshProvider('/tmp') + + it('has correct name and displayName', () => { + expect(provider.name).toBe('dsh') + expect(provider.displayName).toBe('DeepSeek Harness') + }) + + it('maps deepseek models to readable names and passes unknown ids through', () => { + expect(provider.modelDisplayName('deepseek-v4-pro')).toBe('DeepSeek v4 Pro') + expect(provider.modelDisplayName('some-future-model')).toBe('some-future-model') + }) + + it('normalizes tool names, keeping unknown names raw', () => { + expect(provider.toolDisplayName('bash')).toBe('Bash') + expect(provider.toolDisplayName('pwsh')).toBe('Bash') + expect(provider.toolDisplayName('todo_write')).toBe('TodoWrite') + expect(provider.toolDisplayName('cordis_run')).toBe('cordis_run') + }) +})