codeburn/src/fs-utils.ts
Nihal Jain bae016c050 feat(copilot): track GitHub Copilot JetBrains IDE usage
## What & why

The JetBrains Copilot plugin (IntelliJ, PyCharm, RubyMine, …) stores its
chat/agent sessions under `~/.config/github-copilot/<ide>/<kind>/<storeId>/` —
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.
2026-07-03 16:11:27 +05:30

227 lines
7.1 KiB
TypeScript

import { readFile, stat } from 'fs/promises'
import { readFileSync, statSync, createReadStream } from 'fs'
// Hard cap well below V8's 512 MB string limit. Callers that need line-by-line
// processing should use readSessionLines(), which avoids materializing the
// whole file and can return large lines as Buffers.
export const MAX_SESSION_FILE_BYTES = 128 * 1024 * 1024
export const LARGE_STREAM_LINE_BYTES = 32 * 1024
// Line-by-line streaming has bounded memory (one line at a time) and is not
// constrained by V8's string limit, so it can safely handle multi-GB session
// files. Heavy Codex sessions routinely reach several GB (image-heavy compacted
// turns), so the cap is generous and exists only to guard against truly
// pathological inputs. When a file IS skipped, notice() surfaces it (always on,
// not verbose-gated) so a dropped session never silently understates usage.
export const MAX_STREAM_SESSION_FILE_BYTES = 4 * 1024 * 1024 * 1024
function verbose(): boolean {
return process.env.CODEBURN_VERBOSE === '1'
}
function warn(msg: string): void {
if (verbose()) process.stderr.write(`codeburn: ${msg}\n`)
}
// Always surfaced (not verbose-gated): dropping an entire session file silently
// understates reported usage with no signal, so oversize skips use this.
function notice(msg: string): void {
process.stderr.write(`codeburn: ${msg}\n`)
}
export async function readSessionFile(
filePath: string,
encoding: BufferEncoding = 'utf-8'
): Promise<string | null> {
let size: number
try {
size = (await stat(filePath)).size
} catch (err) {
warn(`stat failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`)
return null
}
if (size > MAX_SESSION_FILE_BYTES) {
warn(`skipped oversize file ${filePath} (${size} bytes > cap ${MAX_SESSION_FILE_BYTES})`)
return null
}
try {
return await readFile(filePath, encoding)
} catch (err) {
warn(`read failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`)
return null
}
}
export function readSessionFileSync(filePath: string): string | null {
let size: number
try {
size = statSync(filePath).size
} catch (err) {
warn(`stat failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`)
return null
}
if (size > MAX_SESSION_FILE_BYTES) {
warn(`skipped oversize file ${filePath} (${size} bytes > cap ${MAX_SESSION_FILE_BYTES})`)
return null
}
try {
return readFileSync(filePath, 'utf-8')
} catch (err) {
warn(`read failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`)
return null
}
}
export type SessionLine = string | Buffer
type ReadSessionLinesOptions = {
largeLineAsBuffer?: boolean
largeLineThresholdBytes?: number
startByteOffset?: number
byteOffsetTracker?: { lastCompleteLineOffset: number }
maxBytes?: number
}
export function readSessionLines(
filePath: string,
shouldSkipHead?: (head: string) => boolean,
): AsyncGenerator<string>
export function readSessionLines(
filePath: string,
shouldSkipHead?: (head: string) => boolean,
options?: ReadSessionLinesOptions & { largeLineAsBuffer: true },
): AsyncGenerator<SessionLine>
export async function* readSessionLines(
filePath: string,
shouldSkipHead?: (head: string) => boolean,
options: ReadSessionLinesOptions = {},
): AsyncGenerator<SessionLine> {
let size: number
try {
size = (await stat(filePath)).size
} catch (err) {
warn(`stat failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`)
return
}
const maxBytes = options.maxBytes ?? MAX_STREAM_SESSION_FILE_BYTES
if (size > maxBytes) {
notice(
`skipped oversize session ${filePath} (${size} bytes > cap ${maxBytes}); its usage is NOT counted`,
)
return
}
const stream = createReadStream(
filePath,
options.startByteOffset !== undefined ? { start: options.startByteOffset } : undefined,
)
const SKIP_HEAD = 2048
const largeLineThreshold = options.largeLineThresholdBytes ?? LARGE_STREAM_LINE_BYTES
const formatLine = (buf: Buffer, lineLen: number, head?: string): SessionLine => {
if (options.largeLineAsBuffer && lineLen > largeLineThreshold) return buf
return head !== undefined && lineLen <= SKIP_HEAD ? head : buf.toString('utf-8')
}
let parts: Buffer[] = []
let len = 0
let skipping = false
let headChecked = false
let chunkBase = options.startByteOffset ?? 0
const tracker = options.byteOffsetTracker
try {
for await (const raw of stream) {
const chunk = raw as Buffer
let pos = 0
while (pos < chunk.length) {
const nl = chunk.indexOf(0x0a, pos)
if (skipping) {
if (nl === -1) {
pos = chunk.length
} else {
if (tracker) tracker.lastCompleteLineOffset = chunkBase + nl + 1
skipping = false
pos = nl + 1
}
continue
}
if (nl !== -1) {
if (pos < nl) {
parts.push(chunk.subarray(pos, nl))
len += nl - pos
}
pos = nl + 1
if (tracker) tracker.lastCompleteLineOffset = chunkBase + pos
if (len === 0) {
parts = []
headChecked = false
continue
}
const buf = parts.length === 1 ? parts[0]! : Buffer.concat(parts, len)
const lineLen = len
parts = []
len = 0
headChecked = false
if (shouldSkipHead) {
const head = lineLen > SKIP_HEAD
? buf.subarray(0, SKIP_HEAD).toString('utf-8')
: buf.toString('utf-8')
if (shouldSkipHead(head)) continue
yield formatLine(buf, lineLen, head)
} else {
yield formatLine(buf, lineLen)
}
} else {
const slice = chunk.subarray(pos)
parts.push(slice)
len += slice.length
pos = chunk.length
// Mid-line skip: once we have enough bytes to check the head,
// enter scanning mode — just look for \n without accumulating.
if (shouldSkipHead && !headChecked && len >= SKIP_HEAD) {
headChecked = true
const headBuf = parts.length === 1
? parts[0]!.subarray(0, SKIP_HEAD)
: Buffer.concat(parts, len).subarray(0, SKIP_HEAD)
if (shouldSkipHead(headBuf.toString('utf-8'))) {
skipping = true
parts = []
len = 0
}
}
}
}
chunkBase += chunk.length
}
if (!skipping && len > 0) {
const buf = parts.length === 1 ? parts[0]! : Buffer.concat(parts, len)
const lineLen = len
if (shouldSkipHead) {
const head = lineLen > SKIP_HEAD
? buf.subarray(0, SKIP_HEAD).toString('utf-8')
: buf.toString('utf-8')
if (!shouldSkipHead(head)) {
yield formatLine(buf, lineLen, head)
}
} else {
yield formatLine(buf, lineLen)
}
}
} catch (err) {
warn(`stream read failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`)
} finally {
stream.destroy()
}
}