From bae016c05008b3e34764f8c7b836136ed09dfed1 Mon Sep 17 00:00:00 2001 From: Nihal Jain Date: Fri, 3 Jul 2026 15:27:15 +0530 Subject: [PATCH 1/5] feat(copilot): track GitHub Copilot JetBrains IDE usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What & why The JetBrains Copilot plugin (IntelliJ, PyCharm, RubyMine, …) stores its chat/agent sessions under `~/.config/github-copilot////` — a location none of the existing Copilot sources (CLI JSONL, VS Code chat sessions/transcripts, OTel SQLite) read. As a result all JetBrains Copilot usage was silently uncounted in every CodeBurn report. This adds a reader for that store so those sessions are discovered, priced, and attributed to the right project. ## How it works - **Reader.** The store's session content is a Nitrite `.db` — an H2 MVStore of Java-serialized documents. It is scanned as `latin1` for byte-offset stability: no Java deserializer, no new dependency, and it is not SQLite so `node:sqlite` is not involved. - **Reply text.** Assistant replies live in nested-escaped `{"__first__":{"type":"Subgraph"…}}` blobs. The text is recovered by unescaping one level at a time and, at the depth where the Markdown record's `data` field is a well-formed one-level-escaped JSON document, reading it structurally — so a reply containing its own quotes is never truncated or duplicated (which would otherwise inflate the estimate). - **Tokens/cost.** The store records no token counts, so output tokens are estimated from the reply text (`CHARS_PER_TOKEN = 4`, re-decoded latin1→utf8 so multibyte replies count by codepoint) and every call is marked `costIsEstimated`. Failed generations (error status, no reply) are billed $0. - **Sessions.** One `.db` holds many chat tabs; turns are grouped back to their conversation GUID so the UI shows one session per tab, deduped by reply content per conversation. - **Project attribution**, most authoritative first: 1. the plugin-recorded `projectName` field (JetBrains Copilot 1.12+), joined across kind dirs by store id — the billable turns live in `chat-agent-sessions`, but the label is usually written into the sibling `chat-sessions`/`chat-edit-sessions` store. Read length-delimited and re-decoded latin1→utf8 so non-ASCII repo names round-trip. 2. the `.git` repo root of a referenced `file://` path. 3. a generic `copilot-jetbrains` bucket when neither signal exists. The conversation title is a chat-thread name, not a project, so it is kept out of the project field and surfaced as the session label instead. Override the JetBrains github-copilot root with `CODEBURN_COPILOT_JETBRAINS_DIR`. ## Docs - `docs/providers/copilot.md` — full JetBrains section (store layout, latin1 scan, reply extraction, projectName precedence + cross-kind join). - `docs/providers/README.md` — Copilot storage updated to note the Nitrite .db. ## How to verify - `npm test -- copilot` and `npx tsc --noEmit` (fixtures reproduce the real nested-escaped .db framing, including quote- and multibyte-bearing replies). - End to end against a real install: `CODEBURN_CACHE_DIR=$(mktemp -d) node dist/cli.js status --provider copilot \ --period all --format menubar-json` — JetBrains sessions appear By-Project under their real repo names. - Set `CODEBURN_COPILOT_JETBRAINS_DIR` to a fixture root to parse a controlled store without touching the real config dir. --- docs/providers/README.md | 2 +- docs/providers/copilot.md | 87 +++- src/fs-utils.ts | 7 +- src/providers/copilot.ts | 698 +++++++++++++++++++++++++++++++- tests/providers/copilot.test.ts | 424 +++++++++++++++++++ 5 files changed, 1211 insertions(+), 7 deletions(-) diff --git a/docs/providers/README.md b/docs/providers/README.md index 9eb60671..2ef1c5fe 100644 --- a/docs/providers/README.md +++ b/docs/providers/README.md @@ -13,7 +13,7 @@ For the architectural picture, see `../architecture.md`. | [Claude](claude.md) | JSONL (no parser) | `src/providers/claude.ts` | none (covered indirectly) | | [Cline](cline.md) | JSON | `src/providers/cline.ts` | `tests/providers/cline.test.ts` | | [Codex](codex.md) | JSONL | `src/providers/codex.ts` | `tests/providers/codex.test.ts` | -| [Copilot](copilot.md) | JSONL | `src/providers/copilot.ts` | `tests/providers/copilot.test.ts` | +| [Copilot](copilot.md) | JSONL + SQLite (OTel) + Nitrite .db (JetBrains) | `src/providers/copilot.ts` | `tests/providers/copilot.test.ts` | | [Devin](devin.md) | JSON + SQLite enrichment | `src/providers/devin.ts` | `tests/providers/devin.test.ts` | | [Droid](droid.md) | JSONL | `src/providers/droid.ts` | `tests/providers/droid.test.ts` | | [Gemini](gemini.md) | JSON / JSONL | `src/providers/gemini.ts` | none | diff --git a/docs/providers/copilot.md b/docs/providers/copilot.md index f78b62f6..843b9cbe 100644 --- a/docs/providers/copilot.md +++ b/docs/providers/copilot.md @@ -1,6 +1,6 @@ # Copilot -GitHub Copilot Chat (CLI, VS Code core chat sessions, and VS Code extension transcripts). +GitHub Copilot Chat (CLI, VS Code core chat sessions, VS Code extension transcripts, and JetBrains IDE sessions). - **Source:** `src/providers/copilot.ts` - **Loading:** eager (`src/providers/index.ts:3`) @@ -16,10 +16,11 @@ Other discovered sources are walked on every run; results merge and dedupe. 2. **VS Code core chat sessions:** `~/Library/Application Support/Code/User/workspaceStorage//chatSessions/*.jsonl` plus `~/Library/Application Support/Code/User/globalStorage/emptyWindowChatSessions/*.jsonl` and equivalents on Windows / Linux 3. **VS Code transcripts:** `~/Library/Application Support/Code/User/workspaceStorage//GitHub.copilot-chat/transcripts/` and equivalents on Windows / Linux 4. **OTel SQLite store:** VS Code Copilot Chat's `agent-traces.db` (see the OTel section). Preferred when present because it carries full input / output / cache token counts; legacy JSONL sources only record output tokens. +5. **JetBrains IDE sessions:** `~/.config/github-copilot////copilot-*-nitrite.db` (see the JetBrains section). Covers IntelliJ IDEA, PyCharm, RubyMine, etc. ## Storage format -JSONL in the first three locations (schemas differ; the parser switches by source type / event shape), and a SQLite DB for the OTel source. VS Code core chat sessions use a delta journal: `kind:0` sets the root object, `kind:1` writes a value at path `k`, and `kind:2` appends items to an array path. +JSONL in the first three locations (schemas differ; the parser switches by source type / event shape), a SQLite DB for the OTel source, and a Nitrite (H2 MVStore) `.db` for the JetBrains source. VS Code core chat sessions use a delta journal: `kind:0` sets the root object, `kind:1` writes a value at path `k`, and `kind:2` appends items to an array path. ## OpenTelemetry (OTel) source @@ -44,13 +45,93 @@ instead of trying to dedupe across stores. before the upgrade cannot be recovered, so monotonicity starts from the upgrade point, not retroactively. +## JetBrains IDEs (IntelliJ, PyCharm, …) + +The JetBrains Copilot plugin does **not** write to any of the VS Code or CLI +locations above. It persists chat/agent sessions under the shared GitHub Copilot +config root, in one store directory per session store: + +``` +~/.config/github-copilot//// + copilot-*-nitrite.db # Nitrite (H2 MVStore) — the session content + blobs/ +``` + +`` is a per-IDE dir (`iu` for IntelliJ IDEA Ultimate, `intellij` for the +community edition, `PyCharm2025.2`, …). `` ∈ `chat-agent-sessions`, +`chat-sessions`, `chat-edit-sessions` (agent / ask / edit mode). The root follows +XDG rules: `$XDG_CONFIG_HOME/github-copilot` when set, else +`~/.config/github-copilot` (macOS / Linux) or `%LOCALAPPDATA%\github-copilot` +(Windows). + +**Storage: the Nitrite `.db`.** An H2 MVStore file (header +`H:2,block:9,…format:3`) of Java-serialized Nitrite documents (`NtAgentSession`, +`NtAgentTurn`). It is read as `latin1` (byte-offset-stable, lossless) and scanned +— no Java deserializer, no new deps, and it is **not** SQLite so `node:sqlite` is +not used. Each assistant reply is a `{"__first__":{"type":"Subgraph",…}}` blob; +the reply text is recovered by unescaping to a fixed point and then collecting +`Markdown` `"text"` fields once (`extractResponseText`). User prompts are the +simpler `{"":{"type":"Value",…}}` value-maps. + +(Store dirs may also contain a legacy `00000000000.xd` Xodus log from older +plugin versions. On every installation observed it is either empty or shadowed +by the `.db`, so CodeBurn reads only the `.db`. If a real `.xd`-only session ever +surfaces, add a reader with a captured fixture.) + +- **No token accounting.** No store records token counts. Output tokens are + **estimated** from the reply text via `estimateTokens` (`CHARS_PER_TOKEN = 4`, + as for Cursor and legacy Copilot JSONL); input tokens are 0; every JetBrains + call is marked `costIsEstimated: true`. +- **Errored turns.** A failed generation ("Sorry, an error occurred …") is stored + as an assistant blob with an error status and no reply text; it is detected and + billed **$0** (not conflated with an empty success). +- **Per-turn model.** The model varies per turn within one `.db`. It is recovered + from inside the assistant blob when present, else a store-wide default, else a + generic Copilot bucket. Dotted Claude names are normalised to canonical ids + (`claude-opus-4.5` → `claude-opus-4-5`); GPT/Gemini names kept verbatim. +- **Duplicates.** The store keeps several byte-copies of each reply (original, + lowercased, revisions); assistant turns are de-duplicated by reply content. +- **One `.db` holds many chat tabs.** A single store `.db` contains multiple + conversations, each with an internal GUID and an evolving title + (`New Agent Session` → auto-name → final title). CodeBurn recovers the + `GUID → title` map (`extractJetBrainsConversations`, keeping the latest + non-default title), attributes each turn to the nearest preceding conversation + GUID, and emits **one session per conversation** (not one per `.db`). Reply + content is de-duplicated per conversation. +- **Project.** Resolved in three tiers, most authoritative first: + 1. **`projectName` field (plugin 1.12+).** Recent plugins serialize the repo + label directly on the session doc (`extractJetBrainsProjectName`) — the + JetBrains analogue of the OTel source's `github.copilot.git.repository`. + **Cross-kind join:** the billable turns live in `chat-agent-sessions`, but + the `projectName` is usually written only into the sibling + `chat-sessions` / `chat-edit-sessions` store. Discovery + (`resolveJetBrainsProjectNames`) joins them by **store id** so the agent + session inherits the label from whichever store recorded it. Read + length-prefixed (Java `TC_STRING`) so an embedded quote/newline can't + truncate it. + 2. **`.git` walk-up (older plugins / no `projectName`).** For each `file://` + URI a chat referenced, walk UP the real filesystem to the nearest ancestor + containing a `.git` and use that repo's basename (e.g. `pinot`). + 3. **`copilot-jetbrains`** bucket when neither signal exists (chat referenced + no files and no `projectName` was recorded, or the repo no longer exists on + disk). + + The conversation **title** is a chat-thread name, NOT a project — it is the + session label (`userMessage`) and deliberately kept out of `project` so it does + not pollute the By-Project view. Note that `bg-agent-sessions/` (a newer kind + dir holding `copilot-agent-snapshots.db` / `copilot-session-metadata.db`) is + **not** scanned: those DBs carry file snapshots and metadata, not billable + turns, and the same session's turns are already read from + `chat-agent-sessions`. +- **Override the root** with `CODEBURN_COPILOT_JETBRAINS_DIR`. + ## Caching None for the JSONL sources. The OTel source uses a durable cache (see above). ## Deduplication -Legacy JSONL and transcript sessions dedupe per `messageId`. Core chat sessions dedupe per `copilot-chatsession::`, and are not discovered when an OTel source is present. +Legacy JSONL and transcript sessions dedupe per `messageId`. Core chat sessions dedupe per `copilot-chatsession::`, and are not discovered when an OTel source is present. JetBrains `.db` turns dedupe per `copilot:jb::` (a per-conversation index, plus reply-content dedup within each conversation). These sources otherwise touch disjoint locations from the VS Code / CLI sources. If a workspace hash contains at least one `chatSessions/*.jsonl` file, the provider skips that hash's legacy `GitHub.copilot-chat/transcripts/` directory. The core chat session journal is the modern token-bearing source for the same conversations, so reading both would inflate call counts. diff --git a/src/fs-utils.ts b/src/fs-utils.ts index c7627081..757bdb48 100644 --- a/src/fs-utils.ts +++ b/src/fs-utils.ts @@ -29,7 +29,10 @@ function notice(msg: string): void { process.stderr.write(`codeburn: ${msg}\n`) } -export async function readSessionFile(filePath: string): Promise { +export async function readSessionFile( + filePath: string, + encoding: BufferEncoding = 'utf-8' +): Promise { let size: number try { size = (await stat(filePath)).size @@ -44,7 +47,7 @@ export async function readSessionFile(filePath: string): Promise } try { - return await readFile(filePath, 'utf-8') + return await readFile(filePath, encoding) } catch (err) { warn(`read failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`) return null diff --git a/src/providers/copilot.ts b/src/providers/copilot.ts index ce1263d9..18245511 100644 --- a/src/providers/copilot.ts +++ b/src/providers/copilot.ts @@ -39,6 +39,7 @@ // CODEBURN_COPILOT_DISABLE_OTEL=1 — Skip OTel entirely, use only JSONL // CODEBURN_COPILOT_WS_STORAGE_DIR — Override VS Code workspaceStorage // CODEBURN_COPILOT_GLOBAL_STORAGE_DIR — Override VS Code globalStorage +// CODEBURN_COPILOT_JETBRAINS_DIR — Override the JetBrains github-copilot root // // ARCHITECTURE: // discoverSessions() returns OTel sessions and legacy JSONL sessions. When @@ -63,6 +64,7 @@ import { existsSync } from 'fs' import { readSessionFile } from '../fs-utils.js' import { calculateCost } from '../models.js' import { extractBashCommands } from '../bash-utils.js' +import { estimateTokens } from '../context-tree.js' import type { Provider, SessionSource, @@ -100,6 +102,15 @@ const toolNameMap: Record = { github_repo: 'GitHub', web_search: 'WebSearch', run_in_terminal: 'Shell', + // JetBrains Copilot agent tool names (snake_case) + insert_edit_into_file: 'Edit', + create_file: 'Edit', + get_errors: 'Diagnostics', + file_search: 'Search', + grep_search: 'Search', + semantic_search: 'Search', + list_dir: 'Search', + fetch_webpage: 'Web', // OTel execute_tool span names from Copilot Chat: readFile: 'Read', writeFile: 'Edit', @@ -277,6 +288,40 @@ function getAgentTracesDbPath(): string | null { return null } +/** + * Locate the GitHub Copilot config root used by the JetBrains IDE plugin + * (IntelliJ IDEA, PyCharm, RubyMine, …). The JetBrains Copilot agent persists + * chat/agent sessions here — a location none of the VS Code or CLI sources + * touch, so this is the only way JetBrains-driven Copilot usage becomes + * visible to CodeBurn. + * + * The path mirrors the plugin's own `getXdgConfigPath` logic (observed in the + * bundled copilot-agent language server): + * - $XDG_CONFIG_HOME/github-copilot (when set to an absolute path) + * - macOS / Linux: ~/.config/github-copilot + * - Windows: %USERPROFILE%\AppData\Local\github-copilot + * + * Under this root, each IDE has its own subdir (e.g. `iu` for IntelliJ IDEA + * Ultimate, `intellij` for the community edition) containing + * chat-agent-sessions/, chat-sessions/, and chat-edit-sessions/. + */ +function getJetBrainsCopilotRoot(override?: string): string { + const envOverride = override ?? process.env['CODEBURN_COPILOT_JETBRAINS_DIR'] + if (envOverride) return envOverride + + const xdg = process.env['XDG_CONFIG_HOME'] + if (xdg && (posix.isAbsolute(xdg) || win32.isAbsolute(xdg))) { + return join(xdg, 'github-copilot') + } + + if (platform() === 'win32') { + const local = process.env['LOCALAPPDATA'] ?? join(homedir(), 'AppData', 'Local') + return join(local, 'github-copilot') + } + + return join(homedir(), '.config', 'github-copilot') +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -840,6 +885,490 @@ function createChatSessionParser( } } +// --------------------------------------------------------------------------- +// JetBrains parser (Nitrite .db from ~/.config/github-copilot) +// --------------------------------------------------------------------------- +// +// The JetBrains Copilot plugin stores each chat/agent session in a Nitrite +// (H2 MVStore) .db of Java-serialized documents. There is NO token accounting +// anywhere in the store, so we estimate output tokens from the assistant reply +// text (the same char-count approach CodeBurn already uses for Cursor and +// legacy Copilot JSONL). Cost is therefore marked costIsEstimated. +// +// The model (e.g. "claude-opus-4.5", "gpt-4.1") is not always tagged on each +// turn, so we recover it by scanning the raw buffer for a known model token. + +// Known JetBrains Copilot model tokens, longest-first so we match the most +// specific name (e.g. "gpt-4.1-mini" before "gpt-4.1"). +const JETBRAINS_MODEL_TOKENS = [ + 'claude-opus-4.5', + 'claude-opus-4.1', + 'claude-opus-4', + 'claude-sonnet-4.5', + 'claude-sonnet-4', + 'gpt-5.3-codex', + 'gpt-5.3', + 'gpt-5.2', + 'gpt-5.1', + 'gpt-5-mini', + 'gpt-5', + 'gpt-4.1-mini', + 'gpt-4.1-nano', + 'gpt-4.1', + 'gpt-4o-mini', + 'gpt-4o', + 'gemini-2.5-pro', + 'gemini-2.0-flash', + 'o3-mini', + 'o4-mini', + 'o3', +] + +/** + * Normalise a raw JetBrains model token to CodeBurn's canonical model id. + * Claude names use dots on disk (claude-opus-4.5) but dashes in the pricing + * tables (claude-opus-4-5); GPT/Gemini names are kept verbatim. + */ +function normalizeJetBrainsModelName(raw: string): string { + const t = raw.trim() + if (!t) return '' + if (t.startsWith('claude-')) return t.replace(/\./g, '-') + return t +} + +/** Match a known model token at an alnum boundary anywhere in a string. */ +function findJetBrainsModelToken(s: string): string { + for (const token of JETBRAINS_MODEL_TOKENS) { + const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + // "o3" etc. must not match inside words like "iso3166". + if (new RegExp(`(?() + let m: RegExpExecArray | null + while ((m = re.exec(raw))) { + // Decode %20 etc. and strip a trailing .rej/.orig suffix noise; keep the dir. + let p = m[1] + try { p = decodeURIComponent(p) } catch { /* leave as-is */ } + const dir = p.slice(0, p.lastIndexOf('/')) + if (dir.startsWith('/')) seen.add(dir) + } + if (seen.size === 0) return undefined + + for (const dir of seen) { + const repo = findGitRepoRoot(dir) + if (repo) return repo + } + return undefined +} + +/** Walk up from `dir` to the nearest ancestor containing `.git`; return its basename. */ +function findGitRepoRoot(dir: string): string | undefined { + let cur = dir + // Bound the walk to avoid pathological loops; repos are never this deep. + for (let i = 0; i < 40 && cur && cur !== '/'; i++) { + if (existsSync(join(cur, '.git'))) { + const name = basename(cur) + return name || undefined + } + const parent = dirname(cur) + if (parent === cur) break + cur = parent + } + return undefined +} + +/** + * Recover the plugin-recorded project label from a Nitrite .db. + * + * JetBrains Copilot 1.12+ serialises a `projectName` field on the session doc + * (e.g. `my-service`, `codeburn`). It is the plugin's OWN authoritative + * label — the JetBrains analogue of the OTel source's + * `github.copilot.git.repository` — so it is preferred over the file-path + * git-walk heuristic when present. + * + * The field is a Java-serialized string: the key bytes `projectName` are + * followed immediately by TC_STRING framing `0x74 + * `. We read exactly `length` bytes (so an embedded newline or + * quote can't truncate it) and accept the first occurrence whose value is a + * plausible short, printable repo name. Older plugins that don't write the + * field simply yield undefined (callers fall back to the git-walk). + * + * Note: the field lives on the session doc, which the plugin writes into the + * `chat-sessions` / `chat-edit-sessions` stores — often NOT the + * `chat-agent-sessions` store where the billable turns live. Discovery joins + * the two by store id; see resolveJetBrainsProjectNames. + */ +function extractJetBrainsProjectName(raw: string): string | undefined { + const re = /projectName\x74([\x00-\xff])([\x00-\xff])/g + let m: RegExpExecArray | null + while ((m = re.exec(raw))) { + const len = (m[1]!.charCodeAt(0) << 8) | m[2]!.charCodeAt(0) + // Repo names are short; a huge length means we matched a schema/key + // occurrence rather than a value-bearing one — skip it. + if (len < 1 || len > 128) continue + const start = m.index + m[0].length + // The .db is read as latin1, so re-interpret the length-delimited bytes as + // UTF-8 (repo names can contain non-ASCII). Reject only if the decoded value + // holds control chars — a sign we matched a non-value occurrence, not a name. + const val = Buffer.from(raw.slice(start, start + len), 'latin1').toString('utf8') + // eslint-disable-next-line no-control-regex + if (val.length > 0 && !/[\x00-\x1f]/.test(val)) return val + } + return undefined +} + +// --------------------------------------------------------------------------- +// Nitrite .db (H2 MVStore) extraction +// --------------------------------------------------------------------------- +// +// JetBrains Copilot sessions store their conversation in the Nitrite .db +// (copilot-*-nitrite.db). One .db holds many conversations. Assistant replies +// are stored as a distinct blob shape: +// +// {"__first__":{"type":"Subgraph","value":"..."}, ...} +// +// which is more deeply escaped than the user-message value-maps. The reply text +// is recovered by progressive unescaping and collecting "text":"..." fields. +// Failed turns ("Sorry, an error occurred …") carry an error status and no reply +// text — they are detected and billed as $0. + +// One assistant turn recovered from a .db. +type JBDbTurn = { + replyText: string + model: string + errored: boolean + // The owning conversation (chat tab): its internal GUID and title. One .db + // holds many conversations; turns are grouped back to their tab by this id. + conversationId: string + conversationTitle: string + // The file path this conversation referenced (home-relative common dir), or + // '' if the chat touched no files. Used as the project label. + conversationProject: string +} + +// A conversation (chat tab) recovered from a .db: internal GUID → title. +type JBConversation = { id: string; title: string } + +/** + * Recover the conversation (chat-tab) records from a raw .db buffer. Each is + * stored as `$ … name … value … source copilot`. Returns the + * GUID→title map so turns can be grouped back to the tab the user sees. + */ +function extractJetBrainsConversations(raw: string): JBConversation[] { + // A conversation's title EVOLVES as the user chats: it starts as "New Agent + // Session", may pass through an auto-generated name, and ends at the final + // title shown in the UI. The same `$<GUID> … name … value <title> … source` + // record is rewritten each time, so we collect every occurrence per GUID and + // keep the LAST meaningful (non-default) one. + const DEFAULT_TITLES = new Set(['New Agent Session', 'New Session', 'New Chat']) + const byId = new Map<string, string>() + const re = /\$([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})[\s\S]{0,8}name/g + let m: RegExpExecArray | null + while ((m = re.exec(raw))) { + const id = m[1] + const window = raw.slice(m.index, m.index + 400) + // The title is the Java-UTF string between the `value` marker and `source`. + const tm = window.match(/value.{1,6}?([\x20-\x7e]{3,80}?)t\x00\x06source/) + if (!tm) continue + const title = Buffer.from(tm[1].replace(/^[^A-Za-z0-9]*/, ''), 'latin1').toString('utf8').trim() + if (!title) continue + // Keep the latest non-default title; only fall back to a default if no + // meaningful title has been seen for this conversation yet. + const existing = byId.get(id) + if (existing && !DEFAULT_TITLES.has(existing) && DEFAULT_TITLES.has(title)) continue + byId.set(id, title) + } + return [...byId.entries()].map(([id, title]) => ({ id, title })) +} + +/** Brace-match a JSON object starting at `start`, tolerating escaped quotes. */ +function matchJsonObject(raw: string, start: number): { chunk: string; end: number } { + let depth = 0 + let inStr = false + let esc = false + let i = start + for (; i < raw.length; i++) { + const c = raw[i] + if (esc) { esc = false; continue } + if (c === '\\') { esc = true; continue } + if (c === '"') { inStr = !inStr; continue } + if (inStr) continue + if (c === '{') depth++ + else if (c === '}') { depth--; if (depth === 0) { i++; break } } + } + return { chunk: raw.slice(start, i), end: i } +} + +/** + * Recover the assistant reply text from a `__first__`/Subgraph response blob. + * + * Recovers the `text` of each `Markdown` record. A Markdown record is + * `…"type":"Markdown"…"data":"<json-string>"…` where <json-string> is an + * escaped JSON document `{"text":"…","annotations":…}`. Rather than fully + * unescaping the whole blob (which strips the reply's own quotes and makes + * regex extraction ambiguous — the reply's `"` becomes indistinguishable from a + * JSON delimiter), we locate each `data` value, read it as a properly-delimited + * JSON-string literal (honouring escaping), unescape that ONE level, and + * `JSON.parse` it to read `.text` structurally. + * + * Scoping to Markdown matters: failed turns store their error under a + * `type:"Error"` record with a `"message"` field, and `Steps`/status records + * carry their own strings — none of which are billable assistant output. + * Steps/error/progress-only blobs therefore yield ''. + */ +function extractResponseText(blob: string): string { + // The reply lives inside a Markdown record whose `data` value is itself an + // escaped JSON document `{"text":"…"}`. The blob is escaped several levels + // deep, so we unescape ONE level at a time and, at each depth, try to read the + // `data` payload STRUCTURALLY. Extraction succeeds only at the exact depth + // where `data` is a well-formed one-level-escaped JSON string; deeper, the + // reply's own quotes go bare and the structure is destroyed. We take the first + // depth that yields text — never accumulating across depths (which would union + // a quote-truncated half-unescaped capture with the full one and garble the + // reply, inflating the token/cost estimate). + let s = blob + for (let depth = 0; depth < 8; depth++) { + const texts = extractMarkdownTexts(s) + if (texts.length > 0) { + const decoded = texts.join('\n').trim() + // The .db is read as latin1 (byte-stable), so multibyte UTF-8 characters + // are split into separate code units. Re-interpret as UTF-8 so the char + // count (→ token estimate) reflects real content length, not byte count. + return Buffer.from(decoded, 'latin1').toString('utf8') + } + // Unescape one level in a single left-to-right pass so `\\` and `\"` resolve + // together — a two-pass replace would turn `\\"` into `\"` not `\\` + `"`. + const next = s.replace(/\\([\\"])/g, '$1') + if (next === s) break + s = next + } + return '' +} + +/** + * Collect the `text` of every `Markdown` record in `s`, treating each record's + * `data` value as a one-level-escaped JSON string parsed structurally (so the + * reply's own quotes never truncate it). Returns [] if `s` is not yet at the + * right unescape depth (no bare `"type":"Markdown"` with a parseable `data`). + * Scoping to Markdown skips `Error` (`message`) and `Steps` records — not + * billable output. Revisions repeat a reply, so identical texts are de-duped. + */ +function extractMarkdownTexts(s: string): string[] { + const texts: string[] = [] + const seen = new Set<string>() + const marker = /"type":"Markdown"/g + let m: RegExpExecArray | null + while ((m = marker.exec(s))) { + const dataKey = s.indexOf('"data":"', m.index) + if (dataKey === -1 || dataKey - m.index > 200) continue + // The data value runs from after `"data":"` to the first UNescaped quote (an + // odd run of preceding backslashes escapes it). + const start = dataKey + '"data":"'.length + let i = start + for (; i < s.length; i++) { + if (s[i] !== '"') continue + let bs = 0 + for (let j = i - 1; j >= start && s[j] === '\\'; j--) bs++ + if (bs % 2 === 0) break + } + const literal = s.slice(start, i) + try { + // Wrapping in quotes + parsing unescapes exactly one level → the inner + // JSON document as a string; parsing THAT reaches { text, … }. + const doc = JSON.parse(JSON.parse('"' + literal + '"') as string) as { text?: unknown } + const text = typeof doc.text === 'string' ? doc.text : '' + if (text && !seen.has(text)) { + seen.add(text) + texts.push(text) + } + } catch { + // Not the right depth (or not a Markdown-text record) — skip. + } + } + return texts +} + +/** + * Extract assistant turns from a raw (latin1) Nitrite .db buffer. Each turn is + * one `{"__first__":{"type":"Subgraph"…}` blob; the per-turn model is recovered + * from inside the blob when present, else the whole-store default. Each turn is + * grouped back to its owning conversation (chat tab) by the nearest preceding + * conversation GUID. Duplicate byte-copies of the same reply (the store keeps + * several) are de-duplicated by content, per conversation. + */ +function extractJetBrainsDbTurns(raw: string): JBDbTurn[] { + const conversations = extractJetBrainsConversations(raw) + // Precompute the byte offset of each conversation GUID's full form so a turn + // can be attributed to the conversation whose id most recently precedes it. + const convById = new Map(conversations.map((c) => [c.id, c])) + + const turns: JBDbTurn[] = [] + const seenReplies = new Set<string>() // keyed by `${conversationId}\0${reply}` + const re = /\{"__first__":\{"type":"Subgraph"/g + let m: RegExpExecArray | null + while ((m = re.exec(raw))) { + const { chunk, end } = matchJsonObject(raw, m.index) + re.lastIndex = end + + // Attribute this turn to the conversation whose GUID last appears before it. + let conversationId = '' + let conversationTitle = '' + let bestPos = -1 + for (const c of convById.values()) { + const p = raw.lastIndexOf(c.id, m.index) + if (p > bestPos) { + bestPos = p + conversationId = c.id + conversationTitle = c.title + } + } + + const replyText = extractResponseText(chunk) + // The files this turn referenced (home-relative common dir) → project label. + const conversationProject = inferJetBrainsProject(chunk) ?? '' + // A per-turn model token sometimes appears inside the blob. + const model = findJetBrainsModelToken(chunk) + // A failed turn carries an error status / phrase AND produces no reply text. + // Requiring empty text avoids misclassifying a genuine reply that merely + // *discusses* an error (e.g. explaining a stack trace) as a failed turn. + const hasErrorMarker = /error occurred|"isError":true|\\+"status\\+":\\+"(?:error|failed)\\+"/i.test(chunk) + if (hasErrorMarker && !replyText) { + turns.push({ replyText: '', model, errored: true, conversationId, conversationTitle, conversationProject }) + continue + } + if (!replyText) continue // Steps/progress-only blob — no billable output + const dedupeKey = `${conversationId}::${replyText}` + if (seenReplies.has(dedupeKey)) continue + seenReplies.add(dedupeKey) + turns.push({ replyText, model, errored: false, conversationId, conversationTitle, conversationProject }) + } + + // A project derived from ANY turn of a conversation applies to all its turns + // (the files are usually referenced in the first substantive turn only). + const projByConv = new Map<string, string>() + for (const t of turns) { + if (t.conversationProject && !projByConv.has(t.conversationId)) { + projByConv.set(t.conversationId, t.conversationProject) + } + } + for (const t of turns) { + if (!t.conversationProject) t.conversationProject = projByConv.get(t.conversationId) ?? '' + } + + return turns +} + +// --------------------------------------------------------------------------- +// JetBrains parser: one ParsedProviderCall per assistant turn in the .db +// --------------------------------------------------------------------------- + +function createJetBrainsParser( + source: JetBrainsSessionSource, + seenKeys: Set<string> +): SessionParser { + return { + async *parse(): AsyncGenerator<ParsedProviderCall> { + const sessionId = source.sessionId + + // Nitrite .db (the store's authoritative session content). Read as latin1 + // so byte offsets are stable through the binary MVStore framing. + if (source.dbPath) { + let dbRaw: string | null = null + try { + dbRaw = await readSessionFile(source.dbPath, 'latin1') + } catch { + dbRaw = null + } + if (dbRaw) { + const storeModel = inferJetBrainsModel(dbRaw) + const turns = extractJetBrainsDbTurns(dbRaw) + // Per-conversation turn counter for stable, tab-scoped dedup keys. + const perConvIndex = new Map<string, number>() + for (const turn of turns) { + // One .db holds many chat tabs; group each turn under its own + // conversation so the user sees one session per tab, not per file. + const convId = turn.conversationId || sessionId + const idx = (perConvIndex.get(convId) ?? 0) + 1 + perConvIndex.set(convId, idx) + const dedupKey = `copilot:jb:${convId}:${idx}` + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + // Prefer the per-turn model, else the store default, else a generic + // Copilot bucket so a real reply is never mis-priced as free. + const model = turn.model || storeModel || 'copilot-anthropic-auto' + // Errored turns (failed generation) contribute no billable output. + const outputTokens = turn.errored ? 0 : estimateTokens(turn.replyText) + const costUSD = outputTokens > 0 ? calculateCost(model, 0, outputTokens, 0, 0, 0) : 0 + // Project resolution precedence: + // 1. projectName — the plugin's own recorded label (1.12+), + // joined across kind dirs by store id. Authoritative. + // 2. the git repo root of a file:// path the chat referenced + // (older plugins / when projectName is absent). + // 3. one honest bucket when neither signal exists. + // The conversation TITLE is a chat-thread name, NOT a project, and is + // kept out of `project` (it would otherwise pollute By-Project). + const project = + source.projectName || turn.conversationProject || 'copilot-jetbrains' + + yield { + provider: 'copilot', + sessionId: convId, + project, + model, + inputTokens: 0, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + costIsEstimated: true, + tools: [], + bashCommands: [], + timestamp: source.mtime, + speed: 'standard' as const, + deduplicationKey: dedupKey, + // Surface the chat-thread name here (it is the session's label, not + // a project) so it remains visible in session-level views. + userMessage: turn.conversationTitle, + } + } + } + } + + }, + } +} + // --------------------------------------------------------------------------- // OTel SQLite parser — reads agent-traces.db for FULL token data // --------------------------------------------------------------------------- @@ -1093,6 +1622,27 @@ interface ChatSessionSource extends SessionSource { sourceType: 'chatsession' } +interface JetBrainsSessionSource extends SessionSource { + sourceType: 'jetbrains' + // Fallback conversation id for turns whose own GUID can't be recovered (the + // on-disk store dir name). Normally each turn is grouped by its own tab GUID. + sessionId: string + // On-disk store directory name — the join key for the projectName lookup + // across sibling kind dirs (chat-sessions / chat-edit-sessions). + storeId: string + // Nitrite .db (copilot-*-nitrite.db) — the store's session content. + dbPath: string + // File mtime (ISO). The store has no reliable per-turn timestamp, so this + // places every turn on a day — without it, calls fall outside date ranges. + mtime: string + // Plugin-recorded project label (JetBrains Copilot 1.12+), resolved across + // all kind dirs by store id. The billable turns live in chat-agent-sessions, + // but the projectName field is usually written only into the sibling + // chat-sessions / chat-edit-sessions store, so discovery joins them by id. + // Undefined for older plugins that don't record it. + projectName?: string +} + function isOtelSource(source: SessionSource): source is OTelSessionSource { return (source as OTelSessionSource).sourceType === 'otel' } @@ -1101,6 +1651,10 @@ function isChatSessionSource(source: SessionSource): source is ChatSessionSource return (source as ChatSessionSource).sourceType === 'chatsession' } +function isJetBrainsSource(source: SessionSource): source is JetBrainsSessionSource { + return (source as JetBrainsSessionSource).sourceType === 'jetbrains' +} + // --------------------------------------------------------------------------- // Session discovery: JSONL (original) // --------------------------------------------------------------------------- @@ -1162,6 +1716,132 @@ async function discoverOtelSessions( return [{ path: dbPath, project: 'copilot-chat', provider: 'copilot', sourceType: 'otel' }] } +// --------------------------------------------------------------------------- +// Session discovery: JetBrains (IntelliJ IDEA, PyCharm, …) +// --------------------------------------------------------------------------- + +// The three JetBrains Copilot session kinds (agent / ask / edit mode). Each +// store directory holds a Nitrite .db with that kind's session content. +const JETBRAINS_SESSION_KINDS = ['chat-agent-sessions', 'chat-sessions', 'chat-edit-sessions'] + +// Candidate Nitrite .db filenames per kind, plus a generic fallback. +const JETBRAINS_DB_NAMES: Record<string, string> = { + 'chat-agent-sessions': 'copilot-agent-sessions-nitrite.db', + 'chat-sessions': 'copilot-chat-nitrite.db', + 'chat-edit-sessions': 'copilot-edit-sessions-nitrite.db', +} + +/** Locate the Nitrite .db in a store dir (known name, else any *-nitrite.db). */ +async function findNitriteDbPath(storeDir: string, kind: string): Promise<string | null> { + const known = JETBRAINS_DB_NAMES[kind] + if (known) { + const p = join(storeDir, known) + if ((await stat(p).catch(() => null))?.isFile()) return p + } + let files: string[] + try { + files = await readdir(storeDir) + } catch { + return null + } + const db = files.find((f) => f.endsWith('-nitrite.db')) + return db ? join(storeDir, db) : null +} + +/** + * Discover JetBrains Copilot sessions under the github-copilot config root. + * + * Layout: <root>/<ide>/<kind>/<storeId>/copilot-*-nitrite.db + * <ide> — per-IDE dir (iu, intellij, PyCharm2025.2, …) + * <kind> — one of JETBRAINS_SESSION_KINDS + * + * Emits one source per store directory that has a Nitrite .db. The store + * records no token counts, so the parser estimates output tokens from the + * assistant reply text (see createJetBrainsParser). + */ +async function discoverJetBrainsSessions( + root: string +): Promise<JetBrainsSessionSource[]> { + const sources: JetBrainsSessionSource[] = [] + + let ideDirs: string[] + try { + ideDirs = await readdir(root) + } catch { + return sources + } + + for (const ide of ideDirs) { + for (const kind of JETBRAINS_SESSION_KINDS) { + const kindDir = join(root, ide, kind) + let storeDirs: string[] + try { + storeDirs = await readdir(kindDir) + } catch { + continue // this IDE doesn't have this session kind + } + + for (const storeId of storeDirs) { + const storeDir = join(kindDir, storeId) + const dbPath = await findNitriteDbPath(storeDir, kind) + if (!dbPath) continue + + const dbStat = await stat(dbPath).catch(() => null) + const mtime = (dbStat?.mtime ?? new Date(0)).toISOString() + + sources.push({ + path: dbPath, + project: 'copilot-jetbrains', + provider: 'copilot', + sourceType: 'jetbrains', + sessionId: storeId, + storeId, + dbPath, + mtime, + }) + } + } + } + + // Join projectName across kinds by store id. The plugin records the label on + // the session doc, which usually lands in the chat-sessions/chat-edit-sessions + // store — NOT the chat-agent-sessions store where the billable turns live. + // Without this join, every current agent session falls to the generic bucket + // even though its repo name is sitting one store dir over. + await resolveJetBrainsProjectNames(sources) + + return sources +} + +/** + * Populate each source's `projectName` from whichever store dir (of the same + * store id) actually recorded it. Reads each source's .db once; a store whose + * own .db lacks the field inherits it from a sibling-kind store with the same + * id. Best-effort — read/parse failures leave projectName undefined. + */ +async function resolveJetBrainsProjectNames( + sources: JetBrainsSessionSource[] +): Promise<void> { + const byStore = new Map<string, string>() + for (const src of sources) { + // Already found this store's name via a sibling-kind source — skip the read. + if (!src.dbPath || byStore.has(src.storeId)) continue + let raw: string | null = null + try { + raw = await readSessionFile(src.dbPath, 'latin1') + } catch { + raw = null + } + if (!raw) continue + const name = extractJetBrainsProjectName(raw) + if (name) byStore.set(src.storeId, name) + } + for (const src of sources) { + const name = byStore.get(src.storeId) + if (name) src.projectName = name + } +} + // --------------------------------------------------------------------------- // Provider factory // --------------------------------------------------------------------------- @@ -1388,7 +2068,8 @@ async function discoverTranscriptSessions( export function createCopilotProvider( sessionStateDir?: string, workspaceStorageDir?: string, - globalStorageDir?: string + globalStorageDir?: string, + jetbrainsDir?: string ): Provider { // jsonlDir is resolved lazily inside discoverSessions so that env-var // overrides set after module load (e.g. in tests) are respected. @@ -1486,6 +2167,18 @@ export function createCopilotProvider( // Transcript discovery failed } + // 6. Discover JetBrains IDE sessions (IntelliJ, PyCharm, …). These live + // in a store none of the VS Code / CLI sources touch, so there is no + // overlap to dedupe against; the shared seenKeys set still guards it. + try { + const jetbrainsSources = await discoverJetBrainsSessions( + getJetBrainsCopilotRoot(jetbrainsDir) + ) + sources.push(...jetbrainsSources) + } catch { + // JetBrains discovery failed + } + return sources }, @@ -1503,6 +2196,9 @@ export function createCopilotProvider( if (isChatSessionSource(source)) { return createChatSessionParser(source, seenKeys) } + if (isJetBrainsSource(source)) { + return createJetBrainsParser(source, seenKeys) + } return createJsonlParser(source, seenKeys) }, } diff --git a/tests/providers/copilot.test.ts b/tests/providers/copilot.test.ts index 89cb4236..a999368b 100644 --- a/tests/providers/copilot.test.ts +++ b/tests/providers/copilot.test.ts @@ -1148,3 +1148,427 @@ describe('copilot provider - OTel cache token parsing', () => { } }) }) + +// --------------------------------------------------------------------------- +// JetBrains (IntelliJ / PyCharm / …) session parsing +// --------------------------------------------------------------------------- +// +// The JetBrains Copilot plugin persists sessions to a Nitrite (H2 MVStore) .db +// (~/.config/github-copilot/<ide>/<kind>/<storeId>/copilot-*-nitrite.db) of +// Java-serialized documents. Assistant replies are nested-escaped +// {"__first__":{"type":"Subgraph",…}} blobs; the model and projectName are +// separate serialized fields. These helpers reproduce that on-disk shape so +// tests exercise the real regex/scan extraction path. + +describe('copilot provider - JetBrains parsing', () => { + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'copilot-jetbrains-test-')) + vi.stubEnv('CODEBURN_COPILOT_DISABLE_OTEL', '1') + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + vi.unstubAllEnvs() + }) + + // A JetBrains source: session content lives in the Nitrite .db. + function jbDbSource(path: string, sessionId: string, mtime = '2026-07-03T12:00:00.000Z') { + return { + path, project: 'copilot-jetbrains', provider: 'copilot', sourceType: 'jetbrains', + sessionId, storeId: sessionId, dbPath: path, mtime, + } as unknown as { path: string; project: string; provider: string; sourceType?: string } + } + + // ---- Nitrite .db parsing ---- + + // Build an assistant response blob in the real nested-escaped shape: + // {"__first__":{"type":"Subgraph","value":"{\"<uuid>\":{\"type\":\"Value\", + // \"value\":\"{\\\"type\\\":\\\"Markdown\\\",\\\"data\\\":\\\"{\\\\\\\"text\\\\\\\":...}\"}"}} + function jbAssistantBlob(text: string, opts: { model?: string; errored?: boolean; files?: string[] } = {}) { + const innerMd = { type: 'Markdown', data: JSON.stringify({ text, annotations: [] }) } + const valueMap: Record<string, unknown> = { + 'a1b2c3d4-0000-0000-0000-000000000001': { type: 'Value', value: JSON.stringify(innerMd) }, + } + if (opts.model) valueMap['__model__'] = { type: 'Value', value: `{"model":"${opts.model}"}` } + // Files the turn referenced — project is derived from these file:// paths. + if (opts.files) { + valueMap['__refs__'] = { + type: 'Value', + value: JSON.stringify({ type: 'References', data: opts.files.map((f) => `file://${f}`).join(' ') }), + } + } + const outer: Record<string, unknown> = { + __first__: { type: 'Subgraph', value: JSON.stringify(valueMap) }, + } + if (opts.errored) { + // Real failed turns store the error under a type:"Error" record with a + // `message` field (NOT a Markdown `text`), so it is not billable output. + outer['__err__'] = { + type: 'Value', + value: JSON.stringify({ type: 'Error', message: 'Sorry, an error occurred while generating a response' }), + } + } + return JSON.stringify(outer) + } + + // A conversation title record in the real framing: `$<GUID>…name…value<TITLE>t\x00\x06source`. + function jbConversationRecord(guid: string, title: string) { + return `$${guid}t\x00\x04namesq\x00\x01?@\x00\x00w\x00\x00t\x00value t\x00${title}t\x00\x06sourcet\x00copilotx` + } + + // Assemble a minimal Nitrite-.db-shaped buffer: MVStore header + entity-class + // anchor + optional conversation records + assistant blobs. When a blob is + // preceded by a conversation record, turns attribute to that conversation. + function jbDbContent(blobs: string[], conversations: string[] = []) { + return ( + 'H:2,block:9,blockSize:1000,format:3\n' + + 'com.github.copilot.agent.session.persistence.nitrite.entity.NtAgentTurn\n' + + conversations.join('\n') + '\n' + + blobs.join('\nt\x00\x00model\n') + + '\n' + ) + } + + async function createJetBrainsDb(root: string, ide: string, kind: string, storeId: string, content: string) { + const dir = join(root, ide, kind, storeId) + await mkdir(dir, { recursive: true }) + const dbName = + kind === 'chat-agent-sessions' + ? 'copilot-agent-sessions-nitrite.db' + : kind === 'chat-edit-sessions' + ? 'copilot-edit-sessions-nitrite.db' + : 'copilot-chat-nitrite.db' + await writeFile(join(dir, dbName), content) + return join(dir, dbName) + } + + // The plugin-recorded project label, in the real Java-serialized framing: + // the field key `projectName` followed by TC_STRING `0x74 <u16 len> <value>`, + // then the sibling `user` field. This is what extractJetBrainsProjectName reads. + function jbProjectNameField(name: string) { + // TC_STRING length is the UTF-8 BYTE count (the .db is written UTF-8 and + // read back as latin1), not the JS UTF-16 code-unit count. + const len = Buffer.byteLength(name, 'utf8') + const hi = String.fromCharCode((len >> 8) & 0xff) + const lo = String.fromCharCode(len & 0xff) + return `t\x00\x0bprojectName\x74${hi}${lo}${name}t\x00\x04usert\x00\x08dev-user` + } + + it('parses assistant turns from a Nitrite .db and estimates cost', async () => { + const content = jbDbContent([ + jbAssistantBlob('Hello! How can I help you today?'), + jbAssistantBlob('Here is a longer architecture overview with plenty of detail.', { model: 'claude-opus-4.5' }), + ]) + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-1', content) + + const calls = await collectCalls(jbDbSource(dbPath, 'conv-1')) + expect(calls).toHaveLength(2) + expect(calls[0]!.outputTokens).toBeGreaterThan(0) + expect(calls[0]!.costIsEstimated).toBe(true) + expect(calls[0]!.inputTokens).toBe(0) + // Per-turn model recovered from inside the blob, normalised dots→dashes. + expect(calls[1]!.model).toBe('claude-opus-4-5') + expect(calls[1]!.costUSD).toBeGreaterThan(0) + // Dedup keys are conversation-scoped and stable. + expect(calls[0]!.deduplicationKey).toBe('copilot:jb:conv-1:1') + expect(calls[1]!.deduplicationKey).toBe('copilot:jb:conv-1:2') + }) + + it('recovers a reply containing quotes without garbling or duplicating it', async () => { + // Regression: the unescape loop must run extraction ONLY on the final, + // fully-unescaped form. Accumulating matches at every depth would union a + // half-unescaped (quote-truncated) capture with the full one, producing a + // garbled duplicate and inflating the token/cost estimate. + const reply = 'Use `printf "%s"` to print, then check "status" here.' + const content = jbDbContent([jbAssistantBlob(reply, { model: 'claude-opus-4.5' })]) + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-quote', content) + + const calls = await collectCalls(jbDbSource(dbPath, 'conv-quote')) + expect(calls).toHaveLength(1) + // Token estimate reflects the true reply length (CHARS_PER_TOKEN = 4), not + // an inflated garbled copy. + expect(calls[0]!.outputTokens).toBe(Math.ceil(reply.length / 4)) + }) + + it('counts a multibyte UTF-8 reply by codepoints, not latin1 bytes', async () => { + // The .db is read as latin1; the parser must re-decode to UTF-8 so a + // multibyte char counts as one codepoint for the token estimate. + const reply = 'café ☕ déjà vu — naïve façade' // several multibyte chars + const content = jbDbContent([jbAssistantBlob(reply, { model: 'claude-opus-4.5' })]) + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-utf8', content) + + const calls = await collectCalls(jbDbSource(dbPath, 'conv-utf8')) + expect(calls).toHaveLength(1) + expect(calls[0]!.outputTokens).toBe(Math.ceil(reply.length / 4)) + }) + + it('treats errored turns as $0 (failed generation, no billable output)', async () => { + const content = jbDbContent([ + jbAssistantBlob('', { errored: true }), + jbAssistantBlob('A real successful reply.', { model: 'claude-opus-4.5' }), + ]) + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-err', content) + + const calls = await collectCalls(jbDbSource(dbPath, 'conv-err')) + expect(calls).toHaveLength(2) + const errored = calls.find((c) => c.outputTokens === 0) + const good = calls.find((c) => c.outputTokens > 0) + expect(errored).toBeDefined() + expect(errored!.costUSD).toBe(0) + expect(good).toBeDefined() + expect(good!.costUSD).toBeGreaterThan(0) + }) + + it('de-duplicates repeated byte-copies of the same reply within a .db', async () => { + const content = jbDbContent([ + jbAssistantBlob('identical reply text stored twice'), + jbAssistantBlob('identical reply text stored twice'), + ]) + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-dup', content) + + const calls = await collectCalls(jbDbSource(dbPath, 'conv-dup')) + expect(calls).toHaveLength(1) + }) + + it('skips Steps/progress-only assistant blobs (no billable text)', async () => { + const stepsBlob = JSON.stringify({ + __first__: { + type: 'Subgraph', + value: JSON.stringify({ x: { type: 'Value', value: JSON.stringify({ type: 'Steps', data: '[]' }) } }), + }, + }) + const content = jbDbContent([stepsBlob, jbAssistantBlob('The only real answer.')]) + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-steps', content) + + const calls = await collectCalls(jbDbSource(dbPath, 'conv-steps')) + expect(calls).toHaveLength(1) + expect(calls[0]!.outputTokens).toBeGreaterThan(0) + }) + + it('per-turn model differences within one .db (opus vs gpt) are priced separately', async () => { + const content = jbDbContent([ + jbAssistantBlob('Opus answer with enough words to score tokens.', { model: 'claude-opus-4.5' }), + jbAssistantBlob('GPT answer with enough words to score tokens.', { model: 'gpt-5.3' }), + ]) + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-multi', content) + + const calls = await collectCalls(jbDbSource(dbPath, 'conv-multi')) + expect(calls).toHaveLength(2) + expect(calls.map((c) => c.model).sort()).toEqual(['claude-opus-4-5', 'gpt-5.3']) + }) + + it('splits one .db into sessions by conversation; project = repo, title = session label', async () => { + const guidA = '6acf5299-f9f7-404f-812d-dbe8300e1e5b' + const guidB = '485825c0-3331-46a7-acb2-c71875ad6640' + // Conversation A references a file in a real git repo; B touches no files. + const repoDir = join(tmpDir, 'container', 'web-api') + await mkdir(join(repoDir, '.git'), { recursive: true }) + await mkdir(join(repoDir, 'src'), { recursive: true }) + const fileA = join(repoDir, 'src', 'Main.java') + // Interleave each conversation record before its own turns (turns attribute + // to the nearest preceding conversation GUID). Title evolves default→final. + const content = + 'H:2,block:9,blockSize:1000,format:3\n' + + 'com.github.copilot.agent.session.persistence.nitrite.entity.NtAgentTurn\n' + + jbConversationRecord(guidA, 'New Agent Session') + '\n' + + jbConversationRecord(guidA, 'Understanding the API Architecture') + '\n' + + jbAssistantBlob('Answer about the web API.', { model: 'claude-opus-4.5', files: [fileA] }) + '\n' + + jbConversationRecord(guidB, 'Exploring the Controller Layer in Spring Boot') + '\n' + + jbAssistantBlob('Answer about the controller layer breakdown.', { model: 'gpt-5.3' }) + '\n' + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'multi-conv', content) + + const calls = await collectCalls(jbDbSource(dbPath, 'multi-conv')) + expect(calls).toHaveLength(2) + const bySession = new Map(calls.map((c) => [c.sessionId, c])) + // Sessions are split by conversation GUID. + expect(bySession.has(guidA)).toBe(true) + expect(bySession.has(guidB)).toBe(true) + // Project = the git repo root of the referenced file; else the generic + // bucket when the chat touched no files. + expect(bySession.get(guidA)!.project).toBe('web-api') + expect(bySession.get(guidB)!.project).toBe('copilot-jetbrains') + // The conversation TITLE is the session label (userMessage), NOT the project. + expect(bySession.get(guidA)!.userMessage).toBe('Understanding the API Architecture') + expect(bySession.get(guidB)!.userMessage).toBe('Exploring the Controller Layer in Spring Boot') + // Titles must never appear as project names (they are chat threads). + expect(calls.map((c) => c.project)).not.toContain('Understanding the API Architecture') + }) + + it('is idempotent across re-parses of the same .db (shared seenKeys)', async () => { + const content = jbDbContent([jbAssistantBlob('first reply'), jbAssistantBlob('second reply')]) + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-idem', content) + + const seen = new Set<string>() + const first = await collectCalls(jbDbSource(dbPath, 'conv-idem'), seen) + const second = await collectCalls(jbDbSource(dbPath, 'conv-idem'), seen) + expect(first).toHaveLength(2) + expect(second).toHaveLength(0) + }) + + it('discovers a store dir with a Nitrite .db', async () => { + const content = jbDbContent([jbAssistantBlob('hi there')]) + await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'db-only', content) + + const provider = createCopilotProvider('/nonexistent/legacy', '/nonexistent/ws', '/nonexistent/global', tmpDir) + const sessions = await provider.discoverSessions() + const jb = sessions.filter((s) => (s as { sourceType?: string }).sourceType === 'jetbrains') + expect(jb).toHaveLength(1) + expect((jb[0] as { dbPath?: string }).dbPath).toContain('copilot-agent-sessions-nitrite.db') + }) + + it('infers project as the git repo root of a referenced file (deep subdir → repo root)', async () => { + // Create a real git repo on disk so the .git walk-up can resolve it. + const repoDir = join(tmpDir, 'container', 'myapp') + await mkdir(join(repoDir, '.git'), { recursive: true }) + await mkdir(join(repoDir, 'src', 'a'), { recursive: true }) + const fileA = join(repoDir, 'src', 'a', 'One.ts') + const content = jbDbContent([ + jbAssistantBlob('Editing files in a real repo.', { model: 'gpt-4.1', files: [fileA] }), + ]) + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-gitwalk', content) + + const calls = await collectCalls(jbDbSource(dbPath, 'conv-gitwalk')) + expect(calls).toHaveLength(1) + // Project = basename of the nearest ancestor with .git (the repo root + // 'myapp'), NOT the deep subdir 'a'/'src' or the container dir. + expect(calls[0]!.project).toBe('myapp') + expect(calls[0]!.model).toBe('gpt-4.1') + }) + + it('falls back to copilot-jetbrains when no referenced file resolves to a git repo', async () => { + const content = jbDbContent([ + jbAssistantBlob('Editing a file outside any repo.', { + model: 'gpt-4.1', + files: ['/nonexistent/no-repo-here/src/One.ts'], + }), + ]) + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-norepo', content) + const calls = await collectCalls(jbDbSource(dbPath, 'conv-norepo')) + expect(calls).toHaveLength(1) + expect(calls[0]!.project).toBe('copilot-jetbrains') + }) + + it('resolves a git repo whose name contains a space', async () => { + const repoDir = join(tmpDir, 'My Project') + await mkdir(join(repoDir, '.git'), { recursive: true }) + await mkdir(join(repoDir, 'src'), { recursive: true }) + const file = join(repoDir, 'src', 'One.ts') + const content = jbDbContent([ + jbAssistantBlob('Reading a file in a spaced repo.', { model: 'gpt-4.1', files: [file] }), + ]) + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-space', content) + const calls = await collectCalls(jbDbSource(dbPath, 'conv-space')) + expect(calls).toHaveLength(1) + expect(calls[0]!.project).toBe('My Project') + }) + + it('discovers JetBrains sessions across IDE dirs and session kinds', async () => { + const content = jbDbContent([jbAssistantBlob('Hello from agent mode.', { model: 'claude-opus-4.5' })]) + await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'a1', content) + await createJetBrainsDb(tmpDir, 'intellij', 'chat-agent-sessions', 'b1', content) + + const provider = createCopilotProvider('/nonexistent/legacy', '/nonexistent/ws', '/nonexistent/global', tmpDir) + const sessions = await provider.discoverSessions() + const jb = sessions.filter((s) => (s as { sourceType?: string }).sourceType === 'jetbrains') + expect(jb.map((s) => (s as { sessionId?: string }).sessionId).sort()).toEqual(['a1', 'b1']) + }) + + it('does not crash on a corrupt/truncated .db', async () => { + const dbPath = await createJetBrainsDb( + tmpDir, + 'iu', + 'chat-agent-sessions', + 'conv-corrupt', + 'H:2,block:9\ncom.github.copilot.agent.session.persistence.nitrite.entity.NtAgentTurn\n{"__first__":{"type":"Subgraph"' // truncated, unbalanced + ) + const calls = await collectCalls(jbDbSource(dbPath, 'conv-corrupt')) + expect(Array.isArray(calls)).toBe(true) // no throw; may be empty + }) + + // ---- projectName field (JetBrains Copilot 1.12+) ---- + + it('uses the plugin-recorded projectName over the file-path git-walk', async () => { + // Same store carries both a projectName AND a file ref; projectName wins. + const repoDir = join(tmpDir, 'container', 'walkable-repo') + await mkdir(join(repoDir, '.git'), { recursive: true }) + const file = join(repoDir, 'Main.java') + const content = jbDbContent([ + jbProjectNameField('shared-utils'), + jbAssistantBlob('An answer referencing a file in a real git repo.', { + model: 'claude-opus-4.5', + files: [file], + }), + ]) + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-pn', content) + // discoverSessions populates source.projectName; feed the resolved source. + const provider = createCopilotProvider('/nonexistent/legacy', '/nonexistent/ws', '/nonexistent/global', tmpDir) + const sessions = await provider.discoverSessions() + const src = sessions.find((s) => (s as { storeId?: string }).storeId === 'conv-pn')! + expect((src as { projectName?: string }).projectName).toBe('shared-utils') + const calls = await collectCalls(src as never) + expect(calls.length).toBeGreaterThan(0) + // projectName beats the git-walk result (`walkable-repo`). + expect(calls.every((c) => c.project === 'shared-utils')).toBe(true) + }) + + it('joins projectName across kind dirs by store id (turns in agent, name in edit)', async () => { + // The billable turns live in chat-agent-sessions but carry NO projectName; + // the sibling chat-edit-sessions store (same id) records it. Discovery must + // join them so the agent session is labelled with the real repo. + const storeId = 'store-xyz-123' + const agentContent = jbDbContent([ + jbAssistantBlob('Architecture overview of the repo, no file refs at all.', { model: 'claude-opus-4.5' }), + ]) + await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', storeId, agentContent) + // Edit-kind store: has the projectName, but no billable turns. + const editContent = jbDbContent([], []) + jbProjectNameField('web-api') + await createJetBrainsDb(tmpDir, 'iu', 'chat-edit-sessions', storeId, editContent) + + const provider = createCopilotProvider('/nonexistent/legacy', '/nonexistent/ws', '/nonexistent/global', tmpDir) + const sessions = await provider.discoverSessions() + const jb = sessions.filter((s) => (s as { sourceType?: string }).sourceType === 'jetbrains') + // Every source for this store id inherits the sibling-recorded name. + for (const s of jb) { + expect((s as { projectName?: string }).projectName).toBe('web-api') + } + const agentSrc = jb.find((s) => ((s as { dbPath?: string }).dbPath ?? '').includes('chat-agent-sessions'))! + const calls = await collectCalls(agentSrc as never) + expect(calls.length).toBeGreaterThan(0) + expect(calls.every((c) => c.project === 'web-api')).toBe(true) + }) + + it('falls back to git-walk then bucket when no projectName is recorded', async () => { + // No projectName, no file refs → the honest generic bucket (older plugins). + const content = jbDbContent([jbAssistantBlob('A reply with no project signal at all.')]) + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-nopn', content) + const provider = createCopilotProvider('/nonexistent/legacy', '/nonexistent/ws', '/nonexistent/global', tmpDir) + const sessions = await provider.discoverSessions() + const src = sessions.find((s) => (s as { storeId?: string }).storeId === 'conv-nopn')! + expect((src as { projectName?: string }).projectName).toBeUndefined() + const calls = await collectCalls(src as never) + expect(calls.every((c) => c.project === 'copilot-jetbrains')).toBe(true) + }) + + it('extractJetBrainsProjectName reads the length-prefixed value, immune to embedded quotes', async () => { + // A value containing a quote/newline must not truncate: length-prefixed read. + const tricky = 'weird"name' + const raw = jbDbContent([jbAssistantBlob('x')]) + jbProjectNameField(tricky) + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-sessions', 'conv-tricky', raw) + const provider = createCopilotProvider('/nonexistent/legacy', '/nonexistent/ws', '/nonexistent/global', tmpDir) + const sessions = await provider.discoverSessions() + const src = sessions.find((s) => (s as { storeId?: string }).storeId === 'conv-tricky')! + expect((src as { projectName?: string }).projectName).toBe(tricky) + }) + + it('reads a non-ASCII (multibyte UTF-8) projectName', async () => { + // The value is length-delimited in UTF-8 bytes and re-decoded latin1→utf8, + // so a repo name with multibyte characters must round-trip intact. + const name = 'проект-café' + const raw = jbDbContent([jbAssistantBlob('x')]) + jbProjectNameField(name) + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-sessions', 'conv-utf8name', raw) + const provider = createCopilotProvider('/nonexistent/legacy', '/nonexistent/ws', '/nonexistent/global', tmpDir) + const sessions = await provider.discoverSessions() + const src = sessions.find((s) => (s as { storeId?: string }).storeId === 'conv-utf8name')! + expect((src as { projectName?: string }).projectName).toBe(name) + }) +}) From ccc9deb2bd2e2e60a6cd83b541c950ec4fa32778 Mon Sep 17 00:00:00 2001 From: Nihal Jain <nihaljain@apache.org> Date: Fri, 3 Jul 2026 16:13:30 +0530 Subject: [PATCH 2/5] docs(readme): note Copilot OTel + JetBrains sources in the support matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README "Data location" support matrix listed GitHub Copilot as only the legacy CLI and VS Code transcript sources. Update the row to reflect all sources the provider actually reads — the OpenTelemetry `agent-traces.db` (preferred when present) and the JetBrains IDE Nitrite `.db` — and how the project is resolved. Links to docs/providers/copilot.md for the full detail. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 81d8d8e0..3fa2f1d8 100644 --- a/README.md +++ b/README.md @@ -506,7 +506,7 @@ These are starting points, not verdicts. A 60% cache hit on a single experimenta | **OpenCode** | SQLite `~/.local/share/opencode/opencode*.db` (respects `XDG_DATA_HOME`) | Queries `session`, `message`, and `part` read-only and recalculates cost via LiteLLM (falling back to OpenCode's own cost field for unpriced models). Subtask sessions (`parent_id IS NOT NULL`) are excluded to avoid double counting; multiple channel databases are supported. | | **Gemini CLI** | `~/.gemini/tmp/<project>/chats/session-*.json` | One JSON file per session with real token counts (input, output, cached, thoughts) per message, so no estimation is needed. Input is reported inclusive of cached, so CodeBurn subtracts cached before pricing to avoid double charging. | | **Antigravity (CLI & IDE)** | Session files under `.gemini/` folders, plus the running language server | Pulls granular trajectory and pricing from the language server process. For the short-lived CLI, optionally install a status-line hook with `codeburn antigravity-hook install` so usage is captured between menubar refreshes. The IDE is detected via the `--app-data-dir antigravity-ide` flag on Windows. | -| **GitHub Copilot** | `~/.copilot/session-state/` (legacy CLI) and VS Code/VSCodium `workspaceStorage/*/GitHub.copilot-chat/transcripts/` | Editor transcripts carry no explicit token counts, so tokens are estimated from content length and the model is inferred from tool call ID prefixes. | +| **GitHub Copilot** | `~/.copilot/session-state/` (legacy CLI); VS Code/VSCodium `workspaceStorage/*` chat sessions, `GitHub.copilot-chat/transcripts/`, and the `agent-traces.db` OpenTelemetry store; JetBrains IDEs (IntelliJ, PyCharm, …) under `~/.config/github-copilot/<ide>/<kind>/<storeId>/copilot-*-nitrite.db` | The OTel SQLite store is preferred when present (it carries real input/output/cache token counts). Other sources carry no explicit counts, so tokens are estimated from content length and the model is inferred from tool call ID prefixes. JetBrains sessions read from a Nitrite (H2 MVStore) `.db`; project comes from the plugin's `projectName` field (else the `.git` root of a referenced file). See [docs/providers/copilot.md](docs/providers/copilot.md). | | **Kiro** | `.chat` JSON files | Token counts are estimated from content length. The model is not exposed, so sessions are labeled `kiro-auto` and costed at Sonnet rates. | | **Mistral Vibe** | `~/.vibe/logs/session/` (or `$VIBE_HOME/logs/session/`); each folder has `meta.json` + `messages.jsonl` | Reads cumulative prompt/completion totals and model pricing from `meta.json`, then the first user prompt and tool calls from `messages.jsonl`. Emits one record per session (source data is cumulative, not per turn); subagent sessions under `agents/` are counted separately. | | **OpenClaw** | `~/.openclaw/agents/*.jsonl` (legacy `.clawdbot`, `.moltbot`, `.moldbot`) | Token usage comes from assistant message `usage` blocks; the model from `modelId` or `message.model`. | From 2916cd988ed83839c6daf1b2be721fea4dffd553 Mon Sep 17 00:00:00 2001 From: Nihal Jain <nihaljain@apache.org> Date: Fri, 3 Jul 2026 16:59:05 +0530 Subject: [PATCH 3/5] fix(copilot): read JetBrains agent-mode replies from AgentRound records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JetBrains Copilot has two turn shapes in the Nitrite .db: - ask mode — the reply is a `Markdown` record's `text`; - agent / plan mode (e.g. PyCharm agent sessions, `/plan …`) — the reply is the `reply` field of an `AgentRound` record, and the `Markdown` record instead holds the USER's prompt. extractResponseText only read Markdown, so agent-mode turns yielded no reply text: they were discovered (session/turn counts showed up) but priced at $0 because output tokens came out zero. On this machine that silently under-counted a PyCharm session ($0 → $0.35) and several IntelliJ agent turns. Determine the mode by the PRESENCE of an `AgentRound` record and read only that record's `reply` (collecting every non-empty round in a multi-round blob). Crucially, an agent blob whose reply is empty — a failed turn or a pure tool-call round — does NOT fall back to the Markdown record, so a user prompt is never mistaken for the assistant's output; such turns bill $0 as before. Ask-mode blobs (no AgentRound) keep reading Markdown. Plan mode's sidecar records — Thinking, PendingChanges (proposed diff, under `content`), AskQuestion, Notification, SubTurn, and file-read `text` results — are never read as output. Verified across all local stores: the two reply shapes never coexist in one blob, so the split is unambiguous. Tests: agent-mode reply extraction (ignoring the prompt Markdown), pure tool-call rounds → $0, multi-round collection, and a failed agent turn → $0. docs/providers/copilot.md documents both turn shapes and the ignored sidecar records. --- docs/providers/copilot.md | 31 +++++++-- src/providers/copilot.ts | 109 ++++++++++++++++++++------------ tests/providers/copilot.test.ts | 86 +++++++++++++++++++++++++ 3 files changed, 182 insertions(+), 44 deletions(-) diff --git a/docs/providers/copilot.md b/docs/providers/copilot.md index 843b9cbe..7360c4e0 100644 --- a/docs/providers/copilot.md +++ b/docs/providers/copilot.md @@ -68,10 +68,29 @@ XDG rules: `$XDG_CONFIG_HOME/github-copilot` when set, else `H:2,block:9,…format:3`) of Java-serialized Nitrite documents (`NtAgentSession`, `NtAgentTurn`). It is read as `latin1` (byte-offset-stable, lossless) and scanned — no Java deserializer, no new deps, and it is **not** SQLite so `node:sqlite` is -not used. Each assistant reply is a `{"__first__":{"type":"Subgraph",…}}` blob; -the reply text is recovered by unescaping to a fixed point and then collecting -`Markdown` `"text"` fields once (`extractResponseText`). User prompts are the -simpler `{"<uuid>":{"type":"Value",…}}` value-maps. +not used. Each assistant reply is a `{"__first__":{"type":"Subgraph",…}}` blob. +`extractResponseText` recovers the reply by unescaping one level at a time and, +at the first depth where the record markers appear bare, reading the reply +**structurally** (the payload is parsed as a delimited JSON-string literal, so a +reply containing its own quotes is never truncated). + +**Two turn shapes, both handled** (a blob is one or the other — verified across +every observed store that they never coexist): + +- **Ask mode** — the reply is a `Markdown` record's `text`. +- **Agent / plan mode** (agent sessions, `/plan …`, e.g. in PyCharm) — the reply + is the `reply` field of an `AgentRound` record; here the `Markdown` records + hold the *user's* prompt instead. The mode is decided by the **presence** of an + `AgentRound` record, and only its `reply` is read — so an agent turn with an + empty reply (a failed turn or a pure tool-call round) is billed **$0** rather + than falling back to the prompt. A multi-round blob contributes every non-empty + round's reply. + +Sidecar records that plan/agent mode also writes — `Thinking` (chain-of-thought), +`PendingChanges` (proposed code diff, stored under `content` not `data`), +`AskQuestion`, `Notification`, `SubTurn`, and file-read `text` results — are +**not** billable assistant output and are deliberately skipped. User prompts are +the simpler `{"<uuid>":{"type":"Value",…}}` value-maps. (Store dirs may also contain a legacy `00000000000.xd` Xodus log from older plugin versions. On every installation observed it is either empty or shadowed @@ -84,7 +103,9 @@ surfaces, add a reader with a captured fixture.) call is marked `costIsEstimated: true`. - **Errored turns.** A failed generation ("Sorry, an error occurred …") is stored as an assistant blob with an error status and no reply text; it is detected and - billed **$0** (not conflated with an empty success). + billed **$0** (not conflated with an empty success). In agent mode a failed turn + has an empty `AgentRound` reply — the parser does not fall back to the prompt + `Markdown`, so the user's words are never billed as the assistant's output. - **Per-turn model.** The model varies per turn within one `.db`. It is recovered from inside the assistant blob when present, else a store-wide default, else a generic Copilot bucket. Dotted Claude names are normalised to canonical ids diff --git a/src/providers/copilot.ts b/src/providers/copilot.ts index 18245511..2f43a5bf 100644 --- a/src/providers/copilot.ts +++ b/src/providers/copilot.ts @@ -1129,42 +1129,50 @@ function matchJsonObject(raw: string, start: number): { chunk: string; end: numb /** * Recover the assistant reply text from a `__first__`/Subgraph response blob. * - * Recovers the `text` of each `Markdown` record. A Markdown record is - * `…"type":"Markdown"…"data":"<json-string>"…` where <json-string> is an - * escaped JSON document `{"text":"…","annotations":…}`. Rather than fully - * unescaping the whole blob (which strips the reply's own quotes and makes - * regex extraction ambiguous — the reply's `"` becomes indistinguishable from a - * JSON delimiter), we locate each `data` value, read it as a properly-delimited - * JSON-string literal (honouring escaping), unescape that ONE level, and - * `JSON.parse` it to read `.text` structurally. + * JetBrains Copilot has two turn shapes, both handled here: * - * Scoping to Markdown matters: failed turns store their error under a - * `type:"Error"` record with a `"message"` field, and `Steps`/status records - * carry their own strings — none of which are billable assistant output. - * Steps/error/progress-only blobs therefore yield ''. + * - **Ask mode:** the reply is a `Markdown` record whose `data` is an escaped + * JSON document `{"text":"…","annotations":…}`. + * - **Agent mode** (e.g. PyCharm agent sessions): the reply is the `reply` + * field of an `AgentRound` record `{"roundId":N,"reply":"…","toolCalls":[…]}`. + * In agent mode the `Markdown` records hold the USER's prompts, not the + * reply, so we must NOT read them — the assistant output is the AgentRound + * reply. + * + * Both are read STRUCTURALLY rather than by fully unescaping the blob (which + * would strip the reply's own quotes and make regex extraction ambiguous): we + * locate each `data`/`reply` value, read it as a properly-delimited JSON-string + * literal (honouring escaping), unescape one level, and `JSON.parse` to reach + * the text. We unescape the blob one level at a time and extract at the first + * depth that yields text, never accumulating across depths (which would union a + * quote-truncated half-unescaped capture with the full one and garble the + * reply, inflating the token/cost estimate). + * + * Steps/error/progress-only blobs (no Markdown text and no AgentRound reply) + * yield '' and are billed as $0 upstream. */ function extractResponseText(blob: string): string { - // The reply lives inside a Markdown record whose `data` value is itself an - // escaped JSON document `{"text":"…"}`. The blob is escaped several levels - // deep, so we unescape ONE level at a time and, at each depth, try to read the - // `data` payload STRUCTURALLY. Extraction succeeds only at the exact depth - // where `data` is a well-formed one-level-escaped JSON string; deeper, the - // reply's own quotes go bare and the structure is destroyed. We take the first - // depth that yields text — never accumulating across depths (which would union - // a quote-truncated half-unescaped capture with the full one and garble the - // reply, inflating the token/cost estimate). let s = blob for (let depth = 0; depth < 8; depth++) { - const texts = extractMarkdownTexts(s) - if (texts.length > 0) { - const decoded = texts.join('\n').trim() + // Decide the mode by the PRESENCE of an AgentRound record, not by whether it + // yielded a reply. In agent mode the Markdown record holds the USER prompt, + // so an agent blob whose reply is empty (a failed turn, or a pure tool-call + // round) must NOT fall back to Markdown — that would bill the user's prompt + // as the assistant's output. Ask-mode blobs have no AgentRound record and + // use Markdown. (Verified across every observed store: the two reply shapes + // never coexist in one blob, so this mode split is unambiguous.) + const isAgentMode = /"type":"AgentRound"/.test(s) + if (isAgentMode || /"type":"Markdown"/.test(s)) { + const decoded = isAgentMode ? extractAgentRoundReplies(s) : extractMarkdownTexts(s) // The .db is read as latin1 (byte-stable), so multibyte UTF-8 characters // are split into separate code units. Re-interpret as UTF-8 so the char // count (→ token estimate) reflects real content length, not byte count. - return Buffer.from(decoded, 'latin1').toString('utf8') + // decoded may be empty (failed/tool-only agent turn) → '' (billed $0). + return Buffer.from(decoded.join('\n').trim(), 'latin1').toString('utf8') } - // Unescape one level in a single left-to-right pass so `\\` and `\"` resolve - // together — a two-pass replace would turn `\\"` into `\"` not `\\` + `"`. + // Not yet at the depth where record markers appear bare — unescape one level + // in a single left-to-right pass so `\\` and `\"` resolve together (a + // two-pass replace would turn `\\"` into `\"` not `\\` + `"`). const next = s.replace(/\\([\\"])/g, '$1') if (next === s) break s = next @@ -1181,16 +1189,39 @@ function extractResponseText(blob: string): string { * billable output. Revisions repeat a reply, so identical texts are de-duped. */ function extractMarkdownTexts(s: string): string[] { + return extractRecordStrings(s, '"type":"Markdown"', '"data":"', 'text') +} + +/** + * Collect the non-empty `reply` of every `AgentRound` record (agent mode). A + * single blob can hold several rounds (a multi-turn agent session); each round's + * `reply` is the assistant's text for that step (empty on pure tool-call rounds). + * Deduped in order. + */ +function extractAgentRoundReplies(s: string): string[] { + return extractRecordStrings(s, '"type":"AgentRound"', '"data":"', 'reply') +} + +/** + * Shared structural reader: for every `<marker>` in `s`, find the following + * `<dataKey>` string literal (a one-level-escaped JSON document), parse it, and + * collect `doc[field]` when it is a non-empty string. Reading the value as a + * delimited literal — not a greedy regex — means the payload's own quotes never + * truncate it. Returns [] when `s` is not yet at the depth where the marker + * appears bare with a parseable payload. De-dupes in order (the store keeps + * byte-copies/revisions of each reply). + */ +function extractRecordStrings(s: string, marker: string, dataKey: string, field: string): string[] { const texts: string[] = [] const seen = new Set<string>() - const marker = /"type":"Markdown"/g + const re = new RegExp(marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g') let m: RegExpExecArray | null - while ((m = marker.exec(s))) { - const dataKey = s.indexOf('"data":"', m.index) - if (dataKey === -1 || dataKey - m.index > 200) continue - // The data value runs from after `"data":"` to the first UNescaped quote (an - // odd run of preceding backslashes escapes it). - const start = dataKey + '"data":"'.length + while ((m = re.exec(s))) { + const dk = s.indexOf(dataKey, m.index) + if (dk === -1 || dk - m.index > 200) continue + // The value runs from after `<dataKey>` to the first UNescaped quote (an odd + // run of preceding backslashes escapes it). + const start = dk + dataKey.length let i = start for (; i < s.length; i++) { if (s[i] !== '"') continue @@ -1201,15 +1232,15 @@ function extractMarkdownTexts(s: string): string[] { const literal = s.slice(start, i) try { // Wrapping in quotes + parsing unescapes exactly one level → the inner - // JSON document as a string; parsing THAT reaches { text, … }. - const doc = JSON.parse(JSON.parse('"' + literal + '"') as string) as { text?: unknown } - const text = typeof doc.text === 'string' ? doc.text : '' + // JSON document as a string; parsing THAT reaches { <field>, … }. + const doc = JSON.parse(JSON.parse('"' + literal + '"') as string) as Record<string, unknown> + const text = typeof doc[field] === 'string' ? (doc[field] as string) : '' if (text && !seen.has(text)) { seen.add(text) texts.push(text) } } catch { - // Not the right depth (or not a Markdown-text record) — skip. + // Not the right depth (or not a matching record) — skip. } } return texts @@ -1230,7 +1261,7 @@ function extractJetBrainsDbTurns(raw: string): JBDbTurn[] { const convById = new Map(conversations.map((c) => [c.id, c])) const turns: JBDbTurn[] = [] - const seenReplies = new Set<string>() // keyed by `${conversationId}\0${reply}` + const seenReplies = new Set<string>() // keyed by `${conversationId}::${reply}` const re = /\{"__first__":\{"type":"Subgraph"/g let m: RegExpExecArray | null while ((m = re.exec(raw))) { diff --git a/tests/providers/copilot.test.ts b/tests/providers/copilot.test.ts index a999368b..51649554 100644 --- a/tests/providers/copilot.test.ts +++ b/tests/providers/copilot.test.ts @@ -1211,6 +1211,34 @@ describe('copilot provider - JetBrains parsing', () => { return JSON.stringify(outer) } + // An AGENT-MODE assistant blob: the reply lives in an AgentRound record, and + // (as in real agent sessions) the Markdown record holds the USER's prompt, + // which must NOT be counted as the reply. `rounds` is a list of AgentRound + // replies (a single blob can carry several); a pure tool-call round has ''. + function jbAgentBlob(rounds: string[], opts: { model?: string; userPrompt?: string; errored?: boolean } = {}) { + const valueMap: Record<string, unknown> = {} + let n = 0 + // The user prompt as a Markdown record — a decoy the reply extractor must + // skip in agent mode (real stores put the prompt here, not the answer). + if (opts.userPrompt !== undefined) { + const md = { type: 'Markdown', data: JSON.stringify({ text: opts.userPrompt, annotations: [] }) } + valueMap[`u0000000-0000-0000-0000-00000000000${n++}`] = { type: 'Value', value: JSON.stringify(md) } + } + for (const reply of rounds) { + const ar = { type: 'AgentRound', data: JSON.stringify({ roundId: n, reply, toolCalls: [] }) } + valueMap[`a0000000-0000-0000-0000-00000000000${n++}`] = { type: 'Value', value: JSON.stringify(ar) } + } + if (opts.model) valueMap['__model__'] = { type: 'Value', value: `{"model":"${opts.model}"}` } + const outer: Record<string, unknown> = { __first__: { type: 'Subgraph', value: JSON.stringify(valueMap) } } + if (opts.errored) { + outer['__err__'] = { + type: 'Value', + value: JSON.stringify({ type: 'Error', message: 'Sorry, an error occurred while generating a response' }), + } + } + return JSON.stringify(outer) + } + // A conversation title record in the real framing: `$<GUID>…name…value<TITLE>t\x00\x06source`. function jbConversationRecord(guid: string, title: string) { return `$${guid}t\x00\x04namesq\x00\x01?@\x00\x00w\x00\x00t\x00value t\x00${title}t\x00\x06sourcet\x00copilotx` @@ -1302,6 +1330,64 @@ describe('copilot provider - JetBrains parsing', () => { expect(calls[0]!.outputTokens).toBe(Math.ceil(reply.length / 4)) }) + it('extracts agent-mode replies from AgentRound (not the user prompt Markdown)', async () => { + // Agent-mode sessions (e.g. PyCharm) store the reply in an AgentRound record; + // the Markdown record holds the USER prompt. The reply extractor must read + // the AgentRound reply and ignore the prompt — otherwise the turn bills $0 + // (reply never found) or bills the user's words as output. + const reply = "Here's a quick summary of this repo: it does X, Y, and Z." + const content = jbDbContent([ + jbAgentBlob([reply], { model: 'claude-opus-4.5', userPrompt: 'summarise this repo' }), + ]) + const dbPath = await createJetBrainsDb(tmpDir, 'py', 'chat-agent-sessions', 'conv-agent', content) + + const calls = await collectCalls(jbDbSource(dbPath, 'conv-agent')) + expect(calls).toHaveLength(1) + // Priced from the AgentRound reply, not the (shorter) user prompt. + expect(calls[0]!.outputTokens).toBe(Math.ceil(reply.length / 4)) + expect(calls[0]!.costUSD).toBeGreaterThan(0) + expect(calls[0]!.model).toBe('claude-opus-4-5') + }) + + it('skips pure tool-call agent rounds (empty reply → no billable output)', async () => { + // A round that only issued tool calls has reply:'' — it contributes nothing, + // exactly like a Steps-only ask-mode blob. + const content = jbDbContent([jbAgentBlob([''], { model: 'claude-opus-4.5' })]) + const dbPath = await createJetBrainsDb(tmpDir, 'py', 'chat-agent-sessions', 'conv-toolonly', content) + const calls = await collectCalls(jbDbSource(dbPath, 'conv-toolonly')) + expect(calls).toHaveLength(0) + }) + + it('a failed agent turn bills $0 and never counts the user prompt as the reply', async () => { + // Failed agent turn: empty AgentRound reply + an error marker + a user-prompt + // Markdown record. The parser must NOT fall back to the Markdown (that would + // bill the user's words); an agent blob is agent mode regardless of whether + // its reply is empty, so this is an errored turn → $0. + const content = jbDbContent([ + jbAgentBlob([''], { model: 'claude-opus-4.5', userPrompt: 'do the thing', errored: true }), + ]) + const dbPath = await createJetBrainsDb(tmpDir, 'py', 'chat-agent-sessions', 'conv-agenterr', content) + const calls = await collectCalls(jbDbSource(dbPath, 'conv-agenterr')) + expect(calls).toHaveLength(1) + expect(calls[0]!.outputTokens).toBe(0) + expect(calls[0]!.costUSD).toBe(0) + }) + + it('collects multiple AgentRound replies within one blob', async () => { + // A multi-round agent turn: the first round explores (tool call, empty + // reply), the second answers. Both non-empty replies are joined. + const content = jbDbContent([ + jbAgentBlob(['Let me explore the project.', '', 'Done — here is what it does.'], { + model: 'claude-opus-4.5', + }), + ]) + const dbPath = await createJetBrainsDb(tmpDir, 'py', 'chat-agent-sessions', 'conv-multiround', content) + const calls = await collectCalls(jbDbSource(dbPath, 'conv-multiround')) + expect(calls).toHaveLength(1) + const joined = 'Let me explore the project.\nDone — here is what it does.' + expect(calls[0]!.outputTokens).toBe(Math.ceil(joined.length / 4)) + }) + it('treats errored turns as $0 (failed generation, no billable output)', async () => { const content = jbDbContent([ jbAssistantBlob('', { errored: true }), From cd07707363e9e2eec6a4fea96811d417cceb256e Mon Sep 17 00:00:00 2001 From: Nihal Jain <nihaljain@apache.org> Date: Fri, 3 Jul 2026 18:04:24 +0530 Subject: [PATCH 4/5] =?UTF-8?q?fix(copilot):=20parse=20JetBrains=20agent?= =?UTF-8?q?=20sessions=20from=20old=20plugin=20format=20(=E2=89=A41.5.x)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JetBrains Copilot plugin ≤1.5.x (e.g. 1.5.59-243) stores all session turns inside ONE large binary-framed outer Nitrite document, rather than the per-turn {"__first__":{"type":"Subgraph",...}} blobs introduced in later plugins (≥1.12.x, e.g. 1.12.1-251). In the old format each assistant turn is a UUID-keyed Value entry whose value field contains a JSON-string-escaped AgentRound record: {"<uuid>":{"type":"Value","value":"{\"type\":\"AgentRound\", \"data\":\"{...reply...}\"}"}, ...} The extractResponseText depth-unescape loop already handles this one extra level of escaping; the only gap was that extractJetBrainsDbTurns never fed it the outer document — it only scanned for __first__/Subgraph blobs, which the old plugin never writes. Add a fallback that activates when the Subgraph scan produces zero turns but 'AgentRound' text is present in the raw file (old-format signal). It locates the binary-framed outer document (UUID-keyed Value entry, hex matched case-insensitively so an uppercase UUID does not fall through to $0), extracts it with matchJsonObject, and passes it to extractResponseText. Because the outer document holds every turn in one blob, this emits ONE session-level call per document (all rounds' replies joined): cost/tokens are correct, only the per-turn call-count granularity is coarser — an accepted tradeoff for legacy data. MVStore keeps two identical collection copies; seenReplies dedupes them. The fallback is guarded by turns.length === 0 so new-format sessions (whose Subgraph scan succeeds) are completely unaffected and never double-counted. Tests: old-format doc with multiple AgentRound rounds → 1 call whose token count equals the two non-empty replies joined (the empty tool-call round is excluded); an uppercase-UUID variant (fails without the case-insensitive match); and a guard that new-format Subgraph turns are not double-counted. docs/providers/copilot.md documents the old format and the one-call-per-session limitation. --- docs/providers/copilot.md | 12 ++++ src/providers/copilot.ts | 72 ++++++++++++++++++++++++ tests/providers/copilot.test.ts | 97 +++++++++++++++++++++++++++++++++ 3 files changed, 181 insertions(+) diff --git a/docs/providers/copilot.md b/docs/providers/copilot.md index 7360c4e0..478687e2 100644 --- a/docs/providers/copilot.md +++ b/docs/providers/copilot.md @@ -92,6 +92,18 @@ Sidecar records that plan/agent mode also writes — `Thinking` (chain-of-though **not** billable assistant output and are deliberately skipped. User prompts are the simpler `{"<uuid>":{"type":"Value",…}}` value-maps. +**Old plugin format (≤1.5.x, e.g. 1.5.59-243).** Older plugins do not write +per-turn `__first__`/Subgraph blobs at all — they store the whole session as ONE +binary-framed outer Nitrite document of UUID-keyed `Value` entries, with the +`AgentRound` records one escaping level deeper. When the Subgraph scan finds no +turns but the raw file contains `AgentRound` text, a fallback locates that outer +document (`extractJetBrainsDbTurns`), runs it through the same +`extractResponseText` depth-unescape, and emits **one session-level call** per +document (all rounds' replies joined). Cost and tokens are correct; only the +per-turn call-count granularity is coarser than the new format — an accepted +tradeoff for legacy data. The fallback is gated on the new-format scan yielding +nothing, so current sessions are never affected or double-counted. + (Store dirs may also contain a legacy `00000000000.xd` Xodus log from older plugin versions. On every installation observed it is either empty or shadowed by the `.db`, so CodeBurn reads only the `.db`. If a real `.xd`-only session ever diff --git a/src/providers/copilot.ts b/src/providers/copilot.ts index 2f43a5bf..67a0311a 100644 --- a/src/providers/copilot.ts +++ b/src/providers/copilot.ts @@ -1301,6 +1301,78 @@ function extractJetBrainsDbTurns(raw: string): JBDbTurn[] { turns.push({ replyText, model, errored: false, conversationId, conversationTitle, conversationProject }) } + // --------------------------------------------------------------------------- + // Fallback: old JetBrains Copilot plugin format (≤1.5.x, e.g. 1.5.59-243) + // --------------------------------------------------------------------------- + // In this format ALL session turns are stored inside ONE large outer Nitrite + // document — a binary-framed JSON object with UUID-keyed Value entries — rather + // than the per-turn {"__first__":{"type":"Subgraph",...}} blobs used by newer + // plugins (≥1.12.x). The AgentRound entries sit one escaping level deeper + // inside the outer document's string values, so `extractResponseText`'s + // depth-unescape loop handles extraction correctly once we feed it the right + // chunk. MVStore keeps two identical copies of the collection; `seenReplies` + // deduplicates them automatically. + // + // Detection heuristic: the __first__/Subgraph path produced no turns AND the + // raw file contains bare 'AgentRound' text (meaning old-format data is present). + if (turns.length === 0 && raw.includes('AgentRound')) { + // The outer Nitrite document is preceded by a single binary framing byte + // (0x81 in practice, but any non-printable/non-ASCII byte in MVStore). + // It starts with a UUID-keyed Value entry: {"<uuid>":{"type":"Value",...}}. + // Hex is matched case-insensitively — an uppercase UUID must not cause the + // whole session to fall through to $0 (the exact bug this path fixes). + const outerDocRe = /[\x00-\x1f\x7f-\xff]\{"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}":\{"type":"Value"/g + let dm: RegExpExecArray | null + while ((dm = outerDocRe.exec(raw))) { + // Skip the leading binary byte; matchJsonObject starts at the '{'. + const docStart = dm.index + 1 + const { chunk, end } = matchJsonObject(raw, docStart) + outerDocRe.lastIndex = end + + // Skip documents that contain no AgentRound data (e.g. empty sessions). + if (!chunk.includes('AgentRound')) continue + + // Attribute to the conversation whose GUID most recently precedes this doc. + let conversationId = '' + let conversationTitle = '' + let bestPos = -1 + for (const c of convById.values()) { + const p = raw.lastIndexOf(c.id, docStart) + if (p > bestPos) { + bestPos = p + conversationId = c.id + conversationTitle = c.title + } + } + + // extractResponseText handles the depth-1 unescape needed to surface the + // AgentRound records, then calls extractAgentRoundReplies for each turn. + // Because the outer document holds ALL turns in one blob we get back a + // single joined string; split it on the '\n' join to yield per-turn texts. + const allReplies = extractResponseText(chunk) + if (!allReplies) continue + + const conversationProject = inferJetBrainsProject(chunk) ?? '' + const storeModel = findJetBrainsModelToken(chunk) + + // extractResponseText joins multiple replies with '\n'. Since individual + // replies can themselves span multiple lines we cannot cleanly split here — + // instead we emit one ParsedProviderCall per outer document (one session). + const dedupeKey = `${conversationId}::${allReplies}` + if (seenReplies.has(dedupeKey)) continue + seenReplies.add(dedupeKey) + + turns.push({ + replyText: allReplies, + model: storeModel, + errored: false, + conversationId, + conversationTitle, + conversationProject, + }) + } + } + // A project derived from ANY turn of a conversation applies to all its turns // (the files are usually referenced in the first substantive turn only). const projByConv = new Map<string, string>() diff --git a/tests/providers/copilot.test.ts b/tests/providers/copilot.test.ts index 51649554..e0deb5d1 100644 --- a/tests/providers/copilot.test.ts +++ b/tests/providers/copilot.test.ts @@ -1657,4 +1657,101 @@ describe('copilot provider - JetBrains parsing', () => { const src = sessions.find((s) => (s as { storeId?: string }).storeId === 'conv-utf8name')! expect((src as { projectName?: string }).projectName).toBe(name) }) + + // --------------------------------------------------------------------------- + // Old plugin format (≤1.5.x, e.g. 1.5.59-243) + // --------------------------------------------------------------------------- + // In the old plugin all session turns live inside ONE large binary-framed + // outer Nitrite document. Each turn's response is stored as a UUID-keyed + // Value entry containing an AgentRound record (one escaping level deeper than + // the __first__/Subgraph format used by plugins ≥1.12.x). + + /** + * Build an outer Nitrite document in the old plugin format. + * The document is preceded by a single binary byte (0x81) and starts with a + * UUID-keyed Value entry. Each AgentRound is stored as a Value whose value + * field is a JSON string containing {\"type\":\"AgentRound\",\"data\":\"...\"} + * (one level of JSON-string escaping from the document root). + */ + function jbOldFormatDoc(rounds: Array<{ reply: string; model?: string }>, opts: { upperUuid?: boolean } = {}) { + const cased = (u: string) => (opts.upperUuid ? u.toUpperCase() : u) + const entries: Record<string, unknown> = {} + // Lead entry (mimics the References record always present in real DBs) + entries[cased('0f383f5c-f169-4fee-9115-c06d4dd8985f')] = { + type: 'Value', + value: JSON.stringify({ type: 'References', data: '[]' }), + } + rounds.forEach((r, i) => { + const uuid = cased(`ccadf30b-fa34-4387-9f14-0a5f63457d${String(i).padStart(2, '0')}`) + const agentRoundData = JSON.stringify({ roundId: i + 1, reply: r.reply, toolCalls: [] }) + const agentRoundValue = JSON.stringify({ type: 'AgentRound', data: agentRoundData }) + entries[uuid] = { type: 'Value', value: agentRoundValue } + if (r.model) { + const modelUuid = cased(`bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbb${String(i).padStart(4, '0')}`) + entries[modelUuid] = { type: 'Value', value: `{"model":"${r.model}"}` } + } + }) + // Binary framing byte (0x81) followed by the JSON document + return '\x81' + JSON.stringify(entries) + } + + it('parses agent turns from old plugin format (≤1.5.x, no __first__ blobs)', async () => { + // The old plugin stores all turns in one big outer Nitrite document with a + // binary framing byte. The fallback path must find and parse it. + const convGuid = '17a5d71b-27f7-4937-8803-7fc2cbb705cb' + const convRecord = jbConversationRecord(convGuid, 'Understanding HBase Architecture') + const oldFormatContent = + 'H:2,block:8,blockSize:1000,format:3\n' + + 'com.github.copilot.agent.session.persistence.nitrite.entity.NtAgentTurn\n' + + convRecord + '\n' + + jbOldFormatDoc([ + { reply: "I'll scan the repository to find the top-level project structure.", model: 'gpt-4.1' }, + { reply: "Now I'll open the README to explain architecture." }, + { reply: '' }, // empty reply (pure tool-call round) — must not produce a call + ]) + + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'old-fmt-1', oldFormatContent) + const calls = await collectCalls(jbDbSource(dbPath, 'old-fmt-1')) + + // The fallback emits one call per outer document (all replies joined). + expect(calls).toHaveLength(1) + expect(calls[0]!.costIsEstimated).toBe(true) + // The two NON-EMPTY rounds are captured and joined; the empty (tool-call) + // round contributes nothing. Assert the exact combined token count so the + // test fails if either reply is dropped or the empty round leaks in. + const joined = + "I'll scan the repository to find the top-level project structure.\n" + + "Now I'll open the README to explain architecture." + expect(calls[0]!.outputTokens).toBe(Math.ceil(joined.length / 4)) + // The session label is the conversation TITLE, not the reply text. + expect(calls[0]!.userMessage).toBe('Understanding HBase Architecture') + }) + + it('parses old plugin format when the outer-doc UUIDs are uppercase hex', async () => { + // The outer-doc detection must be case-insensitive: an uppercase UUID must + // not make the whole session fall through to $0. + const convRecord = jbConversationRecord('27b6e82c-38f8-4048-9914-8fd3dcc816dc', 'Conv Upper') + const content = + 'H:2,block:8,blockSize:1000,format:3\n' + + 'com.github.copilot.agent.session.persistence.nitrite.entity.NtAgentTurn\n' + + convRecord + '\n' + + jbOldFormatDoc([{ reply: 'An uppercase-UUID reply with enough words to score.' }], { upperUuid: true }) + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'old-fmt-upper', content) + const calls = await collectCalls(jbDbSource(dbPath, 'old-fmt-upper')) + expect(calls).toHaveLength(1) + expect(calls[0]!.outputTokens).toBeGreaterThan(0) + }) + + it('old plugin format: does not parse when __first__ blobs already yield turns (no double-count)', async () => { + // When the newer __first__/Subgraph path finds turns, the old-format fallback + // must not run (turns.length > 0 prevents it). + const content = jbDbContent([ + jbAgentBlob(['A reply from the new format.']), + ]) + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'new-fmt-guard', content) + const calls = await collectCalls(jbDbSource(dbPath, 'new-fmt-guard')) + // Only the one Subgraph-format turn — no old-format duplicates + expect(calls).toHaveLength(1) + expect(calls[0]!.outputTokens).toBeGreaterThan(0) + }) }) From d2edf8c9da425a7e01a635ba0ff875319d4437f5 Mon Sep 17 00:00:00 2001 From: AgentSeal <hello@agentseal.org> Date: Sun, 5 Jul 2026 15:36:49 +0200 Subject: [PATCH 5/5] fix(copilot): content-derived JetBrains dedup keys; isolate the new env var in tests Maintainer follow-up: - Derive JetBrains dedup keys from the reply content (sha256 prefix plus a per-hash occurrence counter) instead of the blob's scan position. Copilot is a durable provider: cached turns are never deleted and a re-parse appends any unseen key, while MVStore compaction can rewrite the store with blobs in a different byte order. With positional keys, a rewrite that moves a new blob ahead of an old one hands the new turn the old key (skipped as seen) and re-emits the old turn under a fresh index, double-billing it. Covered by a regression test that fails on the positional scheme. - Add CODEBURN_COPILOT_JETBRAINS_DIR to the env-isolation cleared list so a developer's real JetBrains store never bleeds into fixture tests. --- src/providers/copilot.ts | 20 ++++++--- tests/providers/copilot.test.ts | 74 +++++++++++++++++++++++++++++++-- tests/setup/env-isolation.ts | 1 + 3 files changed, 87 insertions(+), 8 deletions(-) diff --git a/src/providers/copilot.ts b/src/providers/copilot.ts index 67a0311a..e7561975 100644 --- a/src/providers/copilot.ts +++ b/src/providers/copilot.ts @@ -61,6 +61,7 @@ import { readdir, stat } from 'fs/promises' import { homedir, platform } from 'os' import { join, basename, dirname, posix, win32 } from 'path' import { existsSync } from 'fs' +import { createHash } from 'crypto' import { readSessionFile } from '../fs-utils.js' import { calculateCost } from '../models.js' import { extractBashCommands } from '../bash-utils.js' @@ -1412,15 +1413,24 @@ function createJetBrainsParser( if (dbRaw) { const storeModel = inferJetBrainsModel(dbRaw) const turns = extractJetBrainsDbTurns(dbRaw) - // Per-conversation turn counter for stable, tab-scoped dedup keys. - const perConvIndex = new Map<string, number>() + // Dedup keys derive from the reply CONTENT, not the scan position: + // copilot is a durable provider (cached turns are never deleted and a + // re-parse appends any key it hasn't seen), while MVStore compaction + // can rewrite the file with blobs in a different byte order. With + // positional keys, a rewrite that puts a new blob ahead of an old one + // hands the new turn the old turn's key (skipped as seen) and re-emits + // the old turn under a fresh index — double-billing it. The per-hash + // counter keeps genuinely repeated replies and errored turns (which + // share replyText '') distinct within a conversation. + const perContentIndex = new Map<string, number>() for (const turn of turns) { // One .db holds many chat tabs; group each turn under its own // conversation so the user sees one session per tab, not per file. const convId = turn.conversationId || sessionId - const idx = (perConvIndex.get(convId) ?? 0) + 1 - perConvIndex.set(convId, idx) - const dedupKey = `copilot:jb:${convId}:${idx}` + const contentHash = createHash('sha256').update(turn.replyText).digest('hex').slice(0, 12) + const nth = (perContentIndex.get(`${convId}:${contentHash}`) ?? 0) + 1 + perContentIndex.set(`${convId}:${contentHash}`, nth) + const dedupKey = `copilot:jb:${convId}:${contentHash}:${nth}` if (seenKeys.has(dedupKey)) continue seenKeys.add(dedupKey) diff --git a/tests/providers/copilot.test.ts b/tests/providers/copilot.test.ts index e0deb5d1..89586a57 100644 --- a/tests/providers/copilot.test.ts +++ b/tests/providers/copilot.test.ts @@ -1297,9 +1297,10 @@ describe('copilot provider - JetBrains parsing', () => { // Per-turn model recovered from inside the blob, normalised dots→dashes. expect(calls[1]!.model).toBe('claude-opus-4-5') expect(calls[1]!.costUSD).toBeGreaterThan(0) - // Dedup keys are conversation-scoped and stable. - expect(calls[0]!.deduplicationKey).toBe('copilot:jb:conv-1:1') - expect(calls[1]!.deduplicationKey).toBe('copilot:jb:conv-1:2') + // Dedup keys are conversation-scoped, content-derived, and distinct. + expect(calls[0]!.deduplicationKey).toMatch(/^copilot:jb:conv-1:[0-9a-f]{12}:1$/) + expect(calls[1]!.deduplicationKey).toMatch(/^copilot:jb:conv-1:[0-9a-f]{12}:1$/) + expect(calls[0]!.deduplicationKey).not.toBe(calls[1]!.deduplicationKey) }) it('recovers a reply containing quotes without garbling or duplicating it', async () => { @@ -1755,3 +1756,70 @@ describe('copilot provider - JetBrains parsing', () => { expect(calls[0]!.outputTokens).toBeGreaterThan(0) }) }) + +describe('copilot provider - JetBrains dedup key stability across store rewrites', () => { + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'copilot-jetbrains-dedup-')) + vi.stubEnv('CODEBURN_COPILOT_DISABLE_OTEL', '1') + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + vi.unstubAllEnvs() + }) + + function jbDedupSource(path: string, sessionId: string) { + return { + path, project: 'copilot-jetbrains', provider: 'copilot', sourceType: 'jetbrains', + sessionId, storeId: sessionId, dbPath: path, mtime: '2026-07-03T12:00:00.000Z', + } as unknown as { path: string; project: string; provider: string; sourceType?: string } + } + + function blobFor(text: string) { + const innerMd = { type: 'Markdown', data: JSON.stringify({ text, annotations: [] }) } + const valueMap = { 'a1b2c3d4-0000-0000-0000-000000000001': { type: 'Value', value: JSON.stringify(innerMd) } } + return JSON.stringify({ __first__: { type: 'Subgraph', value: JSON.stringify(valueMap) } }) + } + + function dbContent(blobs: string[]) { + return ( + 'H:2,block:9,blockSize:1000,format:3\n' + + 'com.github.copilot.agent.session.persistence.nitrite.entity.NtAgentTurn\n' + + '\n' + blobs.join('\nt\x00\x00model\n') + '\n' + ) + } + + it('a compaction that moves a new blob ahead of an old one must not re-bill the old turn', async () => { + // copilot is a durable provider: cached turns are never deleted, and a + // re-parse appends any dedup key it has not seen. MVStore compaction can + // rewrite the file with blobs in a different byte order. If dedup keys were + // positional (conversation + scan index), a rewrite that puts a NEW turn + // before an OLD one would hand the new turn the old turn's key (skipped as + // already-seen) and re-emit the old turn under a fresh index — billing it + // twice and never billing the new turn. Content-derived keys are immune. + const oldReply = 'The original answer, long enough to carry a token estimate.' + const newReply = 'A fresh answer written after the compaction happened.' + + const dir = join(tmpDir, 'iu', 'chat-agent-sessions', 'conv-rewrite') + await mkdir(dir, { recursive: true }) + const dbPath = join(dir, 'copilot-agent-sessions-nitrite.db') + + const seen = new Set<string>() + + // Scan 1: the store holds only the old turn. + await writeFile(dbPath, dbContent([blobFor(oldReply)])) + const first = await collectCalls(jbDedupSource(dbPath, 'conv-rewrite'), seen) + expect(first).toHaveLength(1) + expect(first[0]!.outputTokens).toBe(Math.ceil(oldReply.length / 4)) + + // Scan 2: compaction rewrote the file — the new turn now sits BEFORE the + // old one in byte order. + await writeFile(dbPath, dbContent([blobFor(newReply), blobFor(oldReply)])) + const second = await collectCalls(jbDedupSource(dbPath, 'conv-rewrite'), seen) + + // Exactly the new turn must be billed — once, at its own length. The old + // turn is already cached and must not re-enter under a different key. + expect(second).toHaveLength(1) + expect(second[0]!.outputTokens).toBe(Math.ceil(newReply.length / 4)) + }) +}) diff --git a/tests/setup/env-isolation.ts b/tests/setup/env-isolation.ts index 079165db..7d638a43 100644 --- a/tests/setup/env-isolation.ts +++ b/tests/setup/env-isolation.ts @@ -64,6 +64,7 @@ const CLEARED = [ 'ZS_DATA_DIR', // codeburn override dirs / paths 'CODEBURN_CACHE_DIR', + 'CODEBURN_COPILOT_JETBRAINS_DIR', 'CODEBURN_COPILOT_OTEL_DB', 'CODEBURN_COPILOT_SESSION_STATE_DIR', 'CODEBURN_COPILOT_WS_STORAGE_DIR',