mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-01 04:15:53 +00:00
## 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.
227 lines
7.1 KiB
TypeScript
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()
|
|
}
|
|
}
|