Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ The CLI automatically detects and parses usage data from:
| **Droid** | `~/.factory/sessions/*.settings.json` | Session files |
| **Gemini CLI** | `~/.gemini/tmp/*/chats/session-*.json` | Session files |
| **Kilo Code** | VS Code `globalStorage/kilocode.kilo-code/` | VS Code extension |
| **OpenCode** | `~/.local/share/opencode/storage/message/` | Message storage |
| **OpenCode** | `~/.local/share/opencode/opencode.db` | SQLite (fallback JSON) |
| **Roo Code** | VS Code `globalStorage/rooveterinaryinc.roo-cline/` | VS Code extension |

Don't see your tool? [Open an issue](https://github.com/agusmdev/burntop/issues) and we'll add it!
Expand Down
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"dev": "bun run --watch src/index.ts",
"build": "bun build src/index.ts --outfile dist/index.js --target bun --format esm && chmod +x dist/index.js",
"typecheck": "tsc --noEmit",
"test": "bun test",
"prepublishOnly": "bun run build",
"link": "bun run build && bun link",
"unlink": "bun unlink",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ export async function statsCommand(options: StatsOptions): Promise<void> {
console.log(' - Droid (~/.factory/sessions/*.settings.json)');
console.log(' - Gemini CLI (~/.gemini/tmp/*/chats/session-*.json)');
console.log(' - Kilo Code (VS Code globalStorage/kilocode.kilo-code/)');
console.log(' - OpenCode (~/.local/share/opencode/storage/message/)');
console.log(' - OpenCode (~/.local/share/opencode/opencode.db, fallback: storage/message/)');
console.log(' - Roo Code (VS Code globalStorage/rooveterinaryinc.roo-cline/)\n');
console.log('Get started by using one of these AI tools, then run `burntop stats` again.');
return;
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,7 +517,7 @@ export async function syncCommand(options: SyncOptions): Promise<void> {
console.log(' - Droid (~/.factory/sessions/*.settings.json)');
console.log(' - Gemini CLI (~/.gemini/tmp/*/chats/session-*.json)');
console.log(' - Kilo Code (VS Code globalStorage/kilocode.kilo-code/)');
console.log(' - OpenCode (~/.local/share/opencode/storage/message/)');
console.log(' - OpenCode (~/.local/share/opencode/opencode.db, fallback: storage/message/)');
console.log(' - Roo Code (VS Code globalStorage/rooveterinaryinc.roo-cline/)');
return;
}
Expand Down
9 changes: 6 additions & 3 deletions packages/cli/src/config/machine-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,15 @@
*/

import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
import { hostname, networkInterfaces } from 'node:os';
import { join } from 'node:path';

const CONFIG_DIR = join(process.env['HOME'] || process.env['USERPROFILE'] || '', '.config', 'burntop');
const CONFIG_DIR = join(
process.env['HOME'] || process.env['USERPROFILE'] || '',
'.config',
'burntop'
);
const MACHINE_ID_FILE = join(CONFIG_DIR, 'machine-id');

/**
Expand Down Expand Up @@ -125,7 +129,6 @@ export function getMachineIdPath(): string {
export function resetMachineId(): void {
try {
if (existsSync(MACHINE_ID_FILE)) {
const { unlinkSync } = require('node:fs');
unlinkSync(MACHINE_ID_FILE);
}
} catch {
Expand Down
15 changes: 7 additions & 8 deletions packages/cli/src/config/sync-checkpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,16 @@
* Checkpoint data is stored in ~/.config/burntop/sync-checkpoint.json
*/

import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';

import { getMachineId } from './machine-id';

const CONFIG_DIR = join(process.env['HOME'] || process.env['USERPROFILE'] || '', '.config', 'burntop');
const CONFIG_DIR = join(
process.env['HOME'] || process.env['USERPROFILE'] || '',
'.config',
'burntop'
);
const CHECKPOINT_FILE = join(CONFIG_DIR, 'sync-checkpoint.json');

/**
Expand Down Expand Up @@ -108,11 +112,7 @@ export function loadCheckpoint(): SyncCheckpoint | null {
const checkpoint = JSON.parse(content) as SyncCheckpoint;

// Validate checkpoint structure
if (
!checkpoint ||
checkpoint.version !== '1.0' ||
typeof checkpoint.sources !== 'object'
) {
if (!checkpoint || checkpoint.version !== '1.0' || typeof checkpoint.sources !== 'object') {
return null;
}

Expand Down Expand Up @@ -189,7 +189,6 @@ export function createEmptyCheckpoint(): SyncCheckpoint {
export function clearCheckpoint(): void {
try {
if (existsSync(CHECKPOINT_FILE)) {
const { unlinkSync } = require('node:fs');
unlinkSync(CHECKPOINT_FILE);
}
} catch {
Expand Down
217 changes: 217 additions & 0 deletions packages/cli/src/parsers/opencode.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { Database } from 'bun:sqlite';
import { afterEach, describe, expect, it } from 'bun:test';

import { OpenCodeParser } from './opencode';

function setupTempDataHome(): { cleanup: () => void; opencodeRoot: string } {
const originalXdg = process.env['XDG_DATA_HOME'];
const tempDir = mkdtempSync(join(tmpdir(), 'opencode-parser-'));
const opencodeRoot = join(tempDir, 'opencode');
mkdirSync(opencodeRoot, { recursive: true });
process.env['XDG_DATA_HOME'] = tempDir;

return {
opencodeRoot,
cleanup: () => {
if (originalXdg) {
process.env['XDG_DATA_HOME'] = originalXdg;
} else {
delete process.env['XDG_DATA_HOME'];
}
if (existsSync(tempDir)) {
rmSync(tempDir, { recursive: true, force: true });
}
},
};
}

function createOpenCodeDb(dbPath: string): Database {
const db = new Database(dbPath);
db.run(
'CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, data TEXT NOT NULL)'
);
return db;
}

describe('OpenCodeParser integration', () => {
const cleanups: Array<() => void> = [];

afterEach(() => {
while (cleanups.length > 0) {
const cleanup = cleanups.pop();
cleanup?.();
}
});

it('parses assistant messages from SQLite storage', async () => {
const { cleanup, opencodeRoot } = setupTempDataHome();
cleanups.push(cleanup);

const dbPath = join(opencodeRoot, 'opencode.db');
const db = createOpenCodeDb(dbPath);

const createdAt = Date.UTC(2026, 0, 15, 10, 30, 0);
db.query(
'INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)'
).run(
'msg_sqlite_1',
'session_sqlite',
createdAt,
createdAt,
JSON.stringify({
role: 'assistant',
modelID: 'gpt-5.3-codex',
time: { created: createdAt },
tokens: {
input: 150,
output: 40,
reasoning: 10,
cache: { read: 75, write: 5 },
},
})
);
db.query(
'INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)'
).run(
'msg_sqlite_user',
'session_sqlite',
createdAt + 1,
createdAt + 1,
JSON.stringify({
role: 'user',
time: { created: createdAt + 1 },
tokens: { input: 1, output: 1, cache: { read: 0, write: 0 } },
})
);
db.close();

const parser = new OpenCodeParser();
const result = await parser.parse();

expect(result.records).toHaveLength(1);
expect(result.records[0]).toEqual({
id: 'msg_sqlite_1',
sessionId: 'session_sqlite',
source: 'opencode',
model: 'gpt-5.3-codex',
timestamp: new Date(createdAt).toISOString(),
inputTokens: 150,
outputTokens: 50,
cacheCreationTokens: 5,
cacheReadTokens: 75,
});
expect(result.stats.messageCount).toBe(1);
expect(result.stats.sessionCount).toBe(1);
});

it('keeps legacy JSON message parsing', async () => {
const { cleanup, opencodeRoot } = setupTempDataHome();
cleanups.push(cleanup);

const messageDir = join(opencodeRoot, 'storage', 'message', 'session-json');
mkdirSync(messageDir, { recursive: true });

const createdAt = Date.UTC(2026, 0, 20, 14, 0, 0);
writeFileSync(
join(messageDir, 'msg_json_1.json'),
JSON.stringify({
id: 'msg_json_1',
sessionID: 'session_json',
role: 'assistant',
modelID: 'gpt-4.1',
time: { created: createdAt },
tokens: {
input: 90,
output: 30,
reasoning: 5,
cache: { read: 0, write: 2 },
},
})
);

const parser = new OpenCodeParser();
const result = await parser.parse();

expect(result.records).toHaveLength(1);
expect(result.records[0]?.id).toBe('msg_json_1');
expect(result.stats.totalInputTokens).toBe(90);
expect(result.stats.totalOutputTokens).toBe(35);
expect(result.stats.totalCacheCreationTokens).toBe(2);
});

it('parses SQLite and JSON together and avoids duplicate message IDs', async () => {
const { cleanup, opencodeRoot } = setupTempDataHome();
cleanups.push(cleanup);

const dbPath = join(opencodeRoot, 'opencode.db');
const db = createOpenCodeDb(dbPath);

const sqliteTimestamp = Date.UTC(2026, 1, 1, 9, 0, 0);
db.query(
'INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)'
).run(
'msg_shared_id',
'session_shared',
sqliteTimestamp,
sqliteTimestamp,
JSON.stringify({
role: 'assistant',
modelID: 'gpt-5.3-codex',
time: { created: sqliteTimestamp },
tokens: {
input: 200,
output: 100,
cache: { read: 50, write: 0 },
},
})
);
db.close();

const jsonDir = join(opencodeRoot, 'storage', 'message', 'session-shared');
mkdirSync(jsonDir, { recursive: true });
writeFileSync(
join(jsonDir, 'msg_shared_id.json'),
JSON.stringify({
id: 'msg_shared_id',
sessionID: 'session_shared',
role: 'assistant',
modelID: 'gpt-5.3-codex',
time: { created: sqliteTimestamp },
tokens: {
input: 999,
output: 999,
cache: { read: 999, write: 999 },
},
})
);
writeFileSync(
join(jsonDir, 'msg_json_unique.json'),
JSON.stringify({
id: 'msg_json_unique',
sessionID: 'session_json_unique',
role: 'assistant',
modelID: 'gpt-4.1',
time: { created: sqliteTimestamp + 1000 },
tokens: {
input: 10,
output: 5,
cache: { read: 0, write: 0 },
},
})
);

const parser = new OpenCodeParser();
const result = await parser.parse();

expect(result.records).toHaveLength(2);
expect(result.stats.messageCount).toBe(2);
expect(result.stats.totalInputTokens).toBe(210);
expect(result.stats.totalOutputTokens).toBe(105);
expect(result.stats.totalCacheReadTokens).toBe(50);
expect(result.stats.totalCacheCreationTokens).toBe(0);
});
});
Loading
Loading