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
82 changes: 51 additions & 31 deletions packages/sdk/src/workflows/collectors/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,7 @@ import os from 'node:os';
import path from 'node:path';
import { createRequire } from 'node:module';

import type {
CliSessionCollector,
CliSessionQuery,
CliSessionReport,
} from '../cli-session-collector.js';
import type { CliSessionCollector, CliSessionQuery, CliSessionReport } from '../cli-session-collector.js';

const require = createRequire(import.meta.url);
const CODEX_HOME = path.join(os.homedir(), '.codex');
Expand All @@ -23,14 +19,11 @@ type DatabaseInstance = {

type DatabaseConstructor = new (
filename: string,
options?: { readonly?: boolean; fileMustExist?: boolean },
options?: { readonly?: boolean; fileMustExist?: boolean }
) => DatabaseInstance;

interface DatabaseSyncModule {
DatabaseSync: new (
filename: string,
options?: { readOnly?: boolean; open?: boolean },
) => DatabaseInstance;
DatabaseSync: new (filename: string, options?: { readOnly?: boolean; open?: boolean }) => DatabaseInstance;
}

interface CodexCollectorOptions {
Expand Down Expand Up @@ -69,16 +62,32 @@ function loadBetterSqlite3(): DatabaseConstructor | null {
}
}

function isBunRuntime(): boolean {
// Bun exposes its version on process.versions.bun. The `node:sqlite` module
// is a Node.js 22+ builtin that Bun does not implement; attempting
// `await import('node:sqlite')` under Bun rejects AND emits cosmetic stderr
// noise ("error: Registry URL must be http:// or https://") that leaks
// through the try/catch into runner.log. Skip the fallback under Bun.
return (
typeof process !== 'undefined' &&
typeof (process.versions as { bun?: string } | undefined)?.bun === 'string'
);
}

async function openDatabase(dbPath: string): Promise<DatabaseInstance | null> {
const BetterSqlite = loadBetterSqlite3();
if (BetterSqlite) {
try {
return new BetterSqlite(dbPath, { readonly: true, fileMustExist: true });
} catch {
// Fall through to node:sqlite.
// Fall through to node:sqlite (on Node) or give up (on Bun).
}
}

if (isBunRuntime()) {
return null;
}

try {
const sqlite = (await import('node:sqlite')) as DatabaseSyncModule;
return new sqlite.DatabaseSync(dbPath, { readOnly: true, open: true });
Expand All @@ -88,11 +97,12 @@ async function openDatabase(dbPath: string): Promise<DatabaseInstance | null> {
}

function normalizeTimestamp(value: unknown): number | null {
const numeric = typeof value === 'number' && Number.isFinite(value)
? value
: typeof value === 'string' && value.trim()
? Number(value)
: null;
const numeric =
typeof value === 'number' && Number.isFinite(value)
? value
: typeof value === 'string' && value.trim()
? Number(value)
: null;
if (numeric === null || !Number.isFinite(numeric)) {
return null;
}
Expand All @@ -108,7 +118,10 @@ function parseJsonLine<T>(line: string): T | null {
}
}

function parseModelProvider(value: string | null | undefined): { provider: string | null; model: string | null } {
function parseModelProvider(value: string | null | undefined): {
provider: string | null;
model: string | null;
} {
if (!value) {
return { provider: null, model: null };
}
Expand Down Expand Up @@ -247,7 +260,8 @@ export class CodexCollector implements CliSessionCollector {
}

try {
return fs.readFileSync(this.historyPath, 'utf8')
return fs
.readFileSync(this.historyPath, 'utf8')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
Expand Down Expand Up @@ -287,20 +301,24 @@ export class CodexCollector implements CliSessionCollector {
}

try {
const threads = db.prepare(
`
const threads = db
.prepare(
`
SELECT *
FROM threads
WHERE cwd = ?
ORDER BY created_at DESC
LIMIT 100
`,
).all<ThreadRow>(query.cwd);

return threads.find((thread) => {
const createdAt = normalizeTimestamp(thread.created_at);
return createdAt !== null && createdAt >= query.startedAt && createdAt <= query.completedAt;
}) ?? null;
`
)
.all<ThreadRow>(query.cwd);

return (
threads.find((thread) => {
const createdAt = normalizeTimestamp(thread.created_at);
return createdAt !== null && createdAt >= query.startedAt && createdAt <= query.completedAt;
}) ?? null
);
} catch {
return null;
} finally {
Expand All @@ -319,15 +337,17 @@ export class CodexCollector implements CliSessionCollector {
}

try {
const rows = db.prepare(
`
const rows = db
.prepare(
`
SELECT ts, level, message, line
FROM logs
WHERE thread_id = ?
AND lower(level) = 'error'
ORDER BY ts ASC
`,
).all<LogRow>(threadId);
`
)
.all<LogRow>(threadId);

return rows
.map((row, index) => {
Expand Down
101 changes: 66 additions & 35 deletions packages/sdk/src/workflows/collectors/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,7 @@ import os from 'node:os';
import path from 'node:path';
import { createRequire } from 'node:module';

import type {
CliSessionCollector,
CliSessionQuery,
CliSessionReport,
} from '../cli-session-collector.js';
import type { CliSessionCollector, CliSessionQuery, CliSessionReport } from '../cli-session-collector.js';

const require = createRequire(import.meta.url);
const OPENCODE_DB_PATH = path.join(os.homedir(), '.local', 'share', 'opencode', 'opencode.db');
Expand All @@ -25,7 +21,7 @@ type DatabaseInstance = {

type DatabaseConstructor = new (
filename: string,
options?: { readonly?: boolean; fileMustExist?: boolean },
options?: { readonly?: boolean; fileMustExist?: boolean }
) => DatabaseInstance;

interface SessionRow {
Expand Down Expand Up @@ -70,33 +66,56 @@ interface OpenCodePartData {
name?: string;
}

function isBunRuntime(): boolean {
// Bun does not implement the Node 22+ `node:sqlite` builtin; requiring it
// under Bun both throws AND emits cosmetic stderr noise
// ("error: Registry URL must be http:// or https://") that leaks through
// the try/catch into runner.log.
return (
typeof process !== 'undefined' &&
typeof (process.versions as { bun?: string } | undefined)?.bun === 'string'
);
}

function loadDatabaseConstructor(): DatabaseConstructor | null {
try {
return require('better-sqlite3') as DatabaseConstructor;
} catch {
// fall through
}

if (isBunRuntime()) {
return null;
}

// Fall back to Node 22+ native node:sqlite (experimental)
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { DatabaseSync } = require('node:sqlite');
return function NativeSqliteWrapper(filename: string, options?: { readonly?: boolean; fileMustExist?: boolean }) {
return function NativeSqliteWrapper(
filename: string,
options?: { readonly?: boolean; fileMustExist?: boolean }
) {
const db = new DatabaseSync(filename, { open: true, readOnly: options?.readonly ?? false });
return {
prepare(sql: string) {
const stmt = db.prepare(sql);
return {
get<T>(params?: unknown): T | undefined {
return params != null ? stmt.get(params) as T | undefined : stmt.get() as T | undefined;
return params != null ? (stmt.get(params) as T | undefined) : (stmt.get() as T | undefined);
},
all<T>(params?: unknown): T[] {
return (params != null ? stmt.all(params) : stmt.all()) as T[];
},
};
},
pragma(source: string) { db.exec(`PRAGMA ${source}`); return undefined; },
close() { db.close(); },
pragma(source: string) {
db.exec(`PRAGMA ${source}`);
return undefined;
},
close() {
db.close();
},
};
} as unknown as DatabaseConstructor;
} catch {
Expand Down Expand Up @@ -169,42 +188,48 @@ export class OpenCodeCollector implements CliSessionCollector {
db = new Database(OPENCODE_DB_PATH, { readonly: true, fileMustExist: true });
db.pragma('query_only = ON');

const session = db.prepare(
`
const session = db
.prepare(
`
SELECT id, directory, time_created
FROM session
WHERE directory = @cwd
AND time_created BETWEEN @startedAt AND @completedAt
ORDER BY time_created DESC
LIMIT 1
`,
).get<SessionRow>({
cwd: query.cwd,
startedAt: query.startedAt - MATCH_WINDOW_GRACE_MS,
completedAt: query.completedAt,
});
`
)
.get<SessionRow>({
cwd: query.cwd,
startedAt: query.startedAt - MATCH_WINDOW_GRACE_MS,
completedAt: query.completedAt,
});

if (!session) {
return null;
}

const messages = db.prepare(
`
const messages = db
.prepare(
`
SELECT id, session_id, time_created, data
FROM message
WHERE session_id = ?
ORDER BY time_created ASC
`,
).all<MessageRow>(session.id);

const parts = db.prepare(
`
)
.all<MessageRow>(session.id);

const parts = db
.prepare(
`
SELECT id, message_id, session_id, time_created, data
FROM part
WHERE session_id = ?
ORDER BY time_created ASC
`,
).all<PartRow>(session.id);
`
)
.all<PartRow>(session.id);

const parsedMessages = messages.map((message) => ({
...message,
Expand All @@ -227,11 +252,11 @@ export class OpenCodeCollector implements CliSessionCollector {
totals.cacheRead += toNumber(tokens?.cache?.read);
return totals;
},
{ input: 0, output: 0, cacheRead: 0 },
{ input: 0, output: 0, cacheRead: 0 }
);

const hasCostData = parsedMessages.some(
(message) => typeof message.parsed?.cost === 'number' && Number.isFinite(message.parsed.cost),
(message) => typeof message.parsed?.cost === 'number' && Number.isFinite(message.parsed.cost)
);
const totalCost = parsedMessages.reduce((sum, message) => sum + toNumber(message.parsed?.cost), 0);

Expand Down Expand Up @@ -266,13 +291,16 @@ export class OpenCodeCollector implements CliSessionCollector {
}
}

const summary = [...parsedParts]
.reverse()
.find((part) => part.parsed?.type === 'text' && part.parsed.text?.trim())?.parsed?.text?.trim() ?? null;
const summary =
[...parsedParts]
.reverse()
.find((part) => part.parsed?.type === 'text' && part.parsed.text?.trim())
?.parsed?.text?.trim() ?? null;

const turns = parsedMessages.filter(
(message) => message.parsed?.role === 'assistant' || message.parsed?.role === 'user',
).length || parsedMessages.length;
const turns =
parsedMessages.filter(
(message) => message.parsed?.role === 'assistant' || message.parsed?.role === 'user'
).length || parsedMessages.length;

return {
cli: 'opencode',
Expand All @@ -292,7 +320,10 @@ export class OpenCodeCollector implements CliSessionCollector {
summary,
raw: {
session,
messages: parsedMessages.map(({ parsed, ...message }) => ({ ...message, data: parsed ?? message.data })),
messages: parsedMessages.map(({ parsed, ...message }) => ({
...message,
data: parsed ?? message.data,
})),
parts: parsedParts.map(({ parsed, ...part }) => ({ ...part, data: parsed ?? part.data })),
},
};
Expand Down
Loading