refactor(core): move shared runtime into workspace package
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
// Claude Code provider adapter in Core (see docs/adr/0001).
|
||||
//
|
||||
// Pure: discovers Claude transcript files and parses one into a record stream.
|
||||
// It never touches the Obelisk database. The per-line logic mirrors the original
|
||||
// indexJsonl exactly, but yields IndexRecords instead of writing rows; the shared
|
||||
// persist layer consumes them. Session aggregates here reflect only THIS chunk
|
||||
// (started_at/ended_at/message_count); persist merges them with any existing row.
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
const require = createRequire(import.meta.url);
|
||||
const fs = require('node:fs');
|
||||
|
||||
import {
|
||||
extractText, extractContentType, extractMessageIsMeta,
|
||||
filePath, trunc, truncJson, readLines, discoverJsonlFiles,
|
||||
} from '../parsing.mjs';
|
||||
|
||||
import type { Cursor, DiscoverContext, IndexRecord, IndexUnit, Provider } from './types.ts';
|
||||
|
||||
// Claude cursor encodes the file mtime and the number of lines already indexed:
|
||||
// "<mtimeMs>:<linesProcessed>". mtime lets discovery detect change; lines lets
|
||||
// parse resume without reprocessing.
|
||||
function cursorToSkip(cursor: Cursor): number {
|
||||
if (!cursor) return 0;
|
||||
const n = Number(cursor.split(':')[1]);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
export const name = 'claude';
|
||||
|
||||
export function discover(_ctx: DiscoverContext): IndexUnit[] {
|
||||
return discoverJsonlFiles().map((f: any) => ({
|
||||
key: f.path,
|
||||
sessionId: f.sessionId,
|
||||
project: f.project,
|
||||
isSubagent: f.isSubagent,
|
||||
agentId: f.agentId,
|
||||
meta: f.workflowRunId ? { workflowRunId: f.workflowRunId } : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
export function* parse(unit: IndexUnit, cursor: Cursor): Generator<IndexRecord, Cursor> {
|
||||
const skip = cursorToSkip(cursor);
|
||||
const mtime = fs.statSync(unit.key).mtimeMs;
|
||||
const isSubagent = unit.isSubagent === true;
|
||||
const records: IndexRecord[] = [];
|
||||
const sm = {
|
||||
started_at: null as string | null,
|
||||
ended_at: null as string | null,
|
||||
git_branch: null as string | null,
|
||||
version: null as string | null,
|
||||
title: null as string | null,
|
||||
n: 0,
|
||||
};
|
||||
|
||||
let lineNum = 0;
|
||||
readLines(unit.key, (line: string) => {
|
||||
lineNum++;
|
||||
if (lineNum <= skip) return;
|
||||
let obj: any;
|
||||
try { obj = JSON.parse(line); } catch { return; }
|
||||
const sid = unit.sessionId;
|
||||
const ts = obj.timestamp || null;
|
||||
|
||||
if (obj.type === 'ai-title' && obj.aiTitle) { sm.title = obj.aiTitle; return; }
|
||||
if (obj.type === 'system' && obj.subtype === 'away_summary' && obj.content) {
|
||||
records.push({ kind: 'summary', id: obj.uuid || `${sid}-away-${ts}`, session_id: sid, timestamp: ts, source: 'away_summary', content: obj.content });
|
||||
return;
|
||||
}
|
||||
if (obj.type === 'system' && obj.subtype === 'turn_duration' && obj.parentUuid && obj.durationMs) {
|
||||
records.push({ kind: 'message-turn-duration', uuid: obj.parentUuid, turn_duration_ms: obj.durationMs });
|
||||
return;
|
||||
}
|
||||
if (obj.type !== 'user' && obj.type !== 'assistant') return;
|
||||
|
||||
if (ts && (!sm.started_at || ts < sm.started_at)) sm.started_at = ts;
|
||||
if (ts && (!sm.ended_at || ts > sm.ended_at)) sm.ended_at = ts;
|
||||
if (obj.gitBranch) sm.git_branch = obj.gitBranch;
|
||||
if (obj.version) sm.version = obj.version;
|
||||
sm.n++;
|
||||
|
||||
const msg = obj.message || {};
|
||||
const text = extractText(msg.content);
|
||||
const contentType = extractContentType(msg.content);
|
||||
const isMeta = extractMessageIsMeta(obj, text);
|
||||
const usage = msg.usage || {};
|
||||
const aid = isSubagent ? (unit.agentId ?? null) : (obj.agentId || null);
|
||||
|
||||
if (obj.uuid) {
|
||||
records.push({
|
||||
kind: 'message', uuid: obj.uuid, session_id: sid, type: obj.type,
|
||||
parent_uuid: obj.parentUuid || null, timestamp: ts, role: msg.role || obj.type,
|
||||
text, content_type: contentType, is_meta: (isMeta ? 1 : 0), model: msg.model || null,
|
||||
is_sidechain: obj.isSidechain ? 1 : 0, agent_id: aid,
|
||||
input_tokens: usage.input_tokens || null, output_tokens: usage.output_tokens || null,
|
||||
cwd: obj.cwd || null, skill: obj.attributionSkill || null, source: 'claude',
|
||||
});
|
||||
}
|
||||
|
||||
if (obj.type === 'assistant' && Array.isArray(msg.content)) {
|
||||
for (const b of msg.content) {
|
||||
if (b.type === 'tool_use' && b.id)
|
||||
records.push({ kind: 'tool_call', id: b.id, message_uuid: obj.uuid, session_id: sid, name: b.name, input_json: truncJson(b.input || {}) as string, file_path: filePath(b.name, b.input) });
|
||||
}
|
||||
}
|
||||
|
||||
if (obj.type === 'user' && Array.isArray(msg.content)) {
|
||||
for (const b of msg.content) {
|
||||
if (b.type !== 'tool_result' || !b.tool_use_id) continue;
|
||||
const rt = typeof b.content === 'string' ? b.content
|
||||
: Array.isArray(b.content) ? b.content.map((c: any) => c.text || '').join('\n') : '';
|
||||
records.push({ kind: 'tool_result', tool_use_id: b.tool_use_id, message_uuid: obj.uuid, session_id: sid, content: trunc(rt), file_path: obj.toolUseResult?.filePath || null, is_error: b.is_error ? 1 : 0 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Subagent transcripts do not own a session row (matches indexJsonl).
|
||||
if (!isSubagent) {
|
||||
records.push({
|
||||
kind: 'session', id: unit.sessionId, title: sm.title, project: unit.project || null,
|
||||
started_at: sm.started_at, ended_at: sm.ended_at, git_branch: sm.git_branch,
|
||||
version: sm.version, message_count: sm.n, countMode: skip > 0 ? 'delta' : 'total',
|
||||
jsonl_path: unit.key, source: 'claude',
|
||||
});
|
||||
}
|
||||
|
||||
yield* records;
|
||||
return `${mtime}:${lineNum}`;
|
||||
}
|
||||
|
||||
export const claudeProvider: Provider = { name, discover, parse };
|
||||
@@ -0,0 +1,220 @@
|
||||
// Codex provider adapter in Core (see docs/adr/0001).
|
||||
//
|
||||
// Pure: discovers Codex rollout files and parses one into a record stream. It
|
||||
// never touches the Obelisk database. Unlike claude, codex is a FULL-REPARSE
|
||||
// adapter: it buffers every line and re-emits every record on each run, because
|
||||
// the event_msg ↔ response_item dedup needs whole-file (bidirectional) knowledge
|
||||
// (the matching pair sits ±1 line apart but in either order). Hence the session
|
||||
// record uses countMode 'total' (persist replaces the count, never accumulates).
|
||||
// The per-line logic mirrors the original indexCodexJsonl.
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
const require = createRequire(import.meta.url);
|
||||
const fs = require('node:fs');
|
||||
|
||||
import {
|
||||
trunc, truncJson, readLines,
|
||||
discoverCodexJsonlFiles, normalizeObservedCwd, projectSlugFromPath,
|
||||
codexRawId, codexDbId, codexCallId, codexLineUuid, codexParentThreadId,
|
||||
codexIsGuardianThread, codexAgentNickname, codexAgentRole, codexUsage,
|
||||
codexEventText, codexMessagePayloadText, codexVisibleMessageKey,
|
||||
codexToolInput, codexToolOutput,
|
||||
} from '../parsing.mjs';
|
||||
|
||||
import type { Cursor, DiscoverContext, IndexRecord, IndexUnit, MessageRecord, Provider } from './types.ts';
|
||||
|
||||
export const name = 'codex';
|
||||
|
||||
export function discover(_ctx: DiscoverContext): IndexUnit[] {
|
||||
return discoverCodexJsonlFiles().map((f: any) => ({ key: f.path, sessionId: '', meta: { source: 'codex' } }));
|
||||
}
|
||||
|
||||
export function* parse(unit: IndexUnit, _cursor: Cursor): Generator<IndexRecord, Cursor> {
|
||||
const mtime = fs.statSync(unit.key).mtimeMs;
|
||||
const records: { lineNum: number; obj: any }[] = [];
|
||||
let lineNum = 0;
|
||||
readLines(unit.key, (line: string) => {
|
||||
lineNum++;
|
||||
try { records.push({ lineNum, obj: JSON.parse(line) }); } catch { /* skip malformed */ }
|
||||
});
|
||||
const outCursor = `${mtime}:${lineNum}`;
|
||||
|
||||
const metaRecord = records.find(r => r.obj?.type === 'session_meta' && r.obj.payload?.id);
|
||||
if (!metaRecord) return outCursor;
|
||||
|
||||
const meta = metaRecord.obj.payload;
|
||||
const threadRawId = codexRawId(meta.id) as string;
|
||||
if (codexIsGuardianThread(meta, records)) {
|
||||
yield { kind: 'delete-session', sessionId: codexDbId(threadRawId) as string };
|
||||
return outCursor;
|
||||
}
|
||||
|
||||
const parentRawId = codexParentThreadId(meta);
|
||||
const sessionId = codexDbId(parentRawId || threadRawId) as string;
|
||||
const agentId = (parentRawId ? codexDbId(threadRawId) : null) as string | null;
|
||||
const isSidechain: 0 | 1 = agentId ? 1 : 0;
|
||||
const project = projectSlugFromPath(normalizeObservedCwd(meta.cwd));
|
||||
const lineUuid = (n: number): string => codexLineUuid(threadRawId, n) as string;
|
||||
|
||||
const out: IndexRecord[] = [];
|
||||
const msgByUuid = new Map<string, MessageRecord>();
|
||||
const sm = {
|
||||
started_at: (meta.timestamp || metaRecord.obj.timestamp || null) as string | null,
|
||||
ended_at: (meta.timestamp || metaRecord.obj.timestamp || null) as string | null,
|
||||
git_branch: (meta.git?.branch || null) as string | null,
|
||||
version: (meta.cli_version || null) as string | null,
|
||||
title: null as string | null,
|
||||
n: 0,
|
||||
lastMessageUuid: null as string | null,
|
||||
lastTextAssistantUuid: null as string | null,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
};
|
||||
|
||||
let currentCwd = normalizeObservedCwd(meta.cwd);
|
||||
let currentModel: string | null = null;
|
||||
const eventMessageKeys = new Set<string>();
|
||||
const callMessageUuids = new Map<string, string>();
|
||||
|
||||
const updateBounds = (ts: string | null) => {
|
||||
if (!ts) return;
|
||||
if (!sm.started_at || ts < sm.started_at) sm.started_at = ts;
|
||||
if (!sm.ended_at || ts > sm.ended_at) sm.ended_at = ts;
|
||||
};
|
||||
|
||||
const insertMessage = ({ uuid, type, role, text = null, contentType = 'text', timestamp, isMeta = 0 }: {
|
||||
uuid: string; type: string; role: string; text?: string | null; contentType?: string; timestamp: string | null; isMeta?: 0 | 1;
|
||||
}) => {
|
||||
const rec: MessageRecord = {
|
||||
kind: 'message', uuid, session_id: sessionId, type, parent_uuid: sm.lastMessageUuid,
|
||||
timestamp: timestamp || null, role, text: trunc(text), content_type: contentType,
|
||||
is_meta: isMeta, model: currentModel, is_sidechain: isSidechain, agent_id: agentId,
|
||||
input_tokens: null, output_tokens: null, cwd: currentCwd, skill: null, source: 'codex',
|
||||
};
|
||||
out.push(rec);
|
||||
msgByUuid.set(uuid, rec);
|
||||
sm.lastMessageUuid = uuid;
|
||||
if (!agentId) sm.n++;
|
||||
if (type === 'assistant' && contentType === 'text') sm.lastTextAssistantUuid = uuid;
|
||||
updateBounds(timestamp);
|
||||
return uuid;
|
||||
};
|
||||
|
||||
// First pass: collect visible event_msg keys so duplicate response_items drop.
|
||||
for (const { obj } of records) {
|
||||
if (obj?.type !== 'event_msg') continue;
|
||||
const payload = obj.payload || {};
|
||||
if (payload.type !== 'user_message' && payload.type !== 'agent_message') continue;
|
||||
const text = codexEventText(payload);
|
||||
if (text === null) continue;
|
||||
eventMessageKeys.add(codexVisibleMessageKey(payload.type === 'user_message' ? 'user' : 'assistant', text));
|
||||
}
|
||||
|
||||
for (const { lineNum: currentLine, obj } of records) {
|
||||
const ts = obj.timestamp || null;
|
||||
if (obj.type === 'session_meta') {
|
||||
if (obj.payload?.cwd) currentCwd = normalizeObservedCwd(obj.payload.cwd) || currentCwd;
|
||||
if (obj.payload?.git?.branch) sm.git_branch = obj.payload.git.branch;
|
||||
if (obj.payload?.cli_version) sm.version = obj.payload.cli_version;
|
||||
updateBounds(obj.payload?.timestamp || ts);
|
||||
continue;
|
||||
}
|
||||
if (obj.type === 'turn_context') {
|
||||
currentCwd = normalizeObservedCwd(obj.payload?.cwd) || currentCwd;
|
||||
currentModel = obj.payload?.model || currentModel;
|
||||
updateBounds(ts);
|
||||
continue;
|
||||
}
|
||||
if (obj.type === 'event_msg') {
|
||||
const payload = obj.payload || {};
|
||||
if (payload.type === 'user_message' || payload.type === 'agent_message' || payload.type === 'agent_reasoning') {
|
||||
const text = codexEventText(payload);
|
||||
if (text === null) continue;
|
||||
const isReasoning = payload.type === 'agent_reasoning';
|
||||
insertMessage({
|
||||
uuid: lineUuid(currentLine),
|
||||
type: payload.type === 'user_message' ? 'user' : 'assistant',
|
||||
role: payload.type === 'user_message' ? 'user' : 'assistant',
|
||||
text, contentType: isReasoning ? 'thinking' : 'text', timestamp: ts,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (payload.type === 'collab_agent_spawn_end' && payload.call_id && payload.new_thread_id) {
|
||||
const uuid = insertMessage({ uuid: lineUuid(currentLine), type: 'assistant', role: 'assistant', text: null, contentType: 'tool_use', timestamp: ts });
|
||||
const toolId = codexCallId(payload.call_id) as string;
|
||||
const description = payload.new_agent_nickname || payload.new_agent_role || 'Agent';
|
||||
const input = {
|
||||
description, subagent_type: payload.new_agent_role || 'Agent', prompt: payload.prompt || '',
|
||||
new_thread_id: payload.new_thread_id, model: payload.model || null, reasoning_effort: payload.reasoning_effort || null,
|
||||
};
|
||||
out.push({ kind: 'tool_call', id: toolId, message_uuid: uuid, session_id: sessionId, name: 'Agent', input_json: truncJson(input) as string, file_path: null });
|
||||
callMessageUuids.set(toolId, uuid);
|
||||
out.push({ kind: 'subagent', agent_id: codexDbId(payload.new_thread_id) as string, session_id: sessionId, parent_tool_use_id: toolId, agent_type: payload.new_agent_role || null, description });
|
||||
continue;
|
||||
}
|
||||
if (payload.type === 'task_complete') {
|
||||
if (sm.lastTextAssistantUuid && payload.duration_ms !== undefined) {
|
||||
out.push({ kind: 'message-turn-duration', uuid: sm.lastTextAssistantUuid, turn_duration_ms: payload.duration_ms || null });
|
||||
}
|
||||
updateBounds(ts);
|
||||
continue;
|
||||
}
|
||||
if (payload.type === 'token_count') {
|
||||
const usage = codexUsage(payload);
|
||||
if (usage.inputTokens !== null) sm.totalInputTokens = usage.inputTokens;
|
||||
if (usage.outputTokens !== null) sm.totalOutputTokens = usage.outputTokens;
|
||||
if (sm.lastTextAssistantUuid && (usage.inputTokens !== null || usage.outputTokens !== null)) {
|
||||
const rec = msgByUuid.get(sm.lastTextAssistantUuid);
|
||||
if (rec) { rec.input_tokens = usage.inputTokens; rec.output_tokens = usage.outputTokens; }
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (payload.type === 'thread_name_updated' && payload.thread_name) sm.title = payload.thread_name;
|
||||
continue;
|
||||
}
|
||||
if (obj.type !== 'response_item') continue;
|
||||
const payload = obj.payload || {};
|
||||
if (payload.type === 'message' && payload.role !== 'developer') {
|
||||
const text = codexMessagePayloadText(payload);
|
||||
const role = payload.role || 'assistant';
|
||||
if (text !== null && !eventMessageKeys.has(codexVisibleMessageKey(role, text))) {
|
||||
insertMessage({ uuid: lineUuid(currentLine), type: role === 'user' ? 'user' : 'assistant', role, text, contentType: 'text', timestamp: ts });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (['function_call', 'custom_tool_call', 'tool_search_call', 'web_search_call'].includes(payload.type) && payload.call_id) {
|
||||
const uuid = insertMessage({ uuid: lineUuid(currentLine), type: 'assistant', role: 'assistant', text: null, contentType: 'tool_use', timestamp: ts });
|
||||
const name = payload.name || payload.tool || payload.type.replace(/_call$/, '');
|
||||
const toolId = codexCallId(payload.call_id) as string;
|
||||
out.push({ kind: 'tool_call', id: toolId, message_uuid: uuid, session_id: sessionId, name, input_json: truncJson(codexToolInput(payload)) as string, file_path: null });
|
||||
callMessageUuids.set(toolId, uuid);
|
||||
continue;
|
||||
}
|
||||
if (['function_call_output', 'custom_tool_call_output', 'tool_search_output'].includes(payload.type) && payload.call_id) {
|
||||
const toolId = codexCallId(payload.call_id) as string;
|
||||
out.push({ kind: 'tool_result', tool_use_id: toolId, message_uuid: callMessageUuids.get(toolId) || '', session_id: sessionId, content: trunc(codexToolOutput(payload) || ''), file_path: null, is_error: payload.is_error ? 1 : 0 });
|
||||
}
|
||||
}
|
||||
|
||||
if (agentId) {
|
||||
const started = sm.started_at ? new Date(sm.started_at).getTime() : null;
|
||||
const ended = sm.ended_at ? new Date(sm.ended_at).getTime() : null;
|
||||
const tokenTotal = (sm.totalInputTokens || 0) + (sm.totalOutputTokens || 0);
|
||||
out.push({
|
||||
kind: 'subagent', agent_id: agentId, session_id: sessionId,
|
||||
agent_type: codexAgentRole(meta), description: codexAgentNickname(meta),
|
||||
duration_ms: started && ended ? ended - started : null, total_tokens: tokenTotal || null,
|
||||
});
|
||||
} else {
|
||||
out.push({
|
||||
kind: 'session', id: sessionId, title: sm.title, project,
|
||||
started_at: sm.started_at, ended_at: sm.ended_at, git_branch: sm.git_branch, version: sm.version,
|
||||
message_count: sm.n, countMode: 'total', jsonl_path: unit.key, source: 'codex',
|
||||
});
|
||||
}
|
||||
|
||||
yield* out;
|
||||
return outCursor;
|
||||
}
|
||||
|
||||
export const codexProvider: Provider = { name, discover, parse };
|
||||
@@ -0,0 +1,226 @@
|
||||
// Core provider contract (see docs/adr/0001).
|
||||
//
|
||||
// The indexing layer splits along two orthogonal axes:
|
||||
// - Provider axis: pure per-source adapters (claude, codex, later opencode,
|
||||
// pi, …) that discover their own work and parse it into records. A source is
|
||||
// NOT assumed to be a single JSONL file — an adapter may read a SQLite store,
|
||||
// a directory tree, etc. So discovery, change-detection, and resume cursoring
|
||||
// are all adapter-owned and format-specific.
|
||||
// - Persist axis: one shared, provider- and binding-agnostic orchestration
|
||||
// that consumes the records and writes them (index_state, FTS, upsert).
|
||||
//
|
||||
// This file defines only the shapes crossing that boundary. Record fields mirror
|
||||
// the columns in scripts/schema.sql; keep them in sync. Types only — no runtime
|
||||
// code — so consumers must import with `import type`.
|
||||
|
||||
// Opaque per-unit resume/watermark token. The orchestration stores it verbatim
|
||||
// (in index_state) and hands it back on the next run; ONLY the adapter that
|
||||
// produced it interprets it. A JSONL adapter might encode `"${mtime}:${lines}"`;
|
||||
// a SQLite-backed adapter might encode a rowid or timestamp high-water mark.
|
||||
export type Cursor = string | null;
|
||||
|
||||
// One unit of work an adapter has discovered. It is not necessarily a file: for
|
||||
// a file-based source `key` is the path; for a DB-backed source it might be
|
||||
// `"${dbPath}#${internalId}"`. `meta` carries adapter-private data (e.g. the
|
||||
// resolved file path or source handle) that the orchestration passes back to
|
||||
// parse() untouched.
|
||||
export interface IndexUnit {
|
||||
/** Stable identity used as the index_state cursor key. */
|
||||
key: string;
|
||||
/** Session id this unit indexes into. */
|
||||
sessionId: string;
|
||||
/** Project slug (dash-encoded path), when the source exposes one. */
|
||||
project?: string;
|
||||
/** Set for subagent transcripts, whose messages carry an agent id. */
|
||||
isSubagent?: boolean;
|
||||
agentId?: string;
|
||||
/** Adapter-private payload, opaque to the orchestration. */
|
||||
meta?: unknown;
|
||||
}
|
||||
|
||||
/** Context the orchestration provides to discovery. */
|
||||
export interface DiscoverContext {
|
||||
/** Look up the cursor persisted for a unit key on a previous run. */
|
||||
lastCursor(key: string): Cursor;
|
||||
/** When set (daemon changed-path mode), restrict discovery to these paths. */
|
||||
changedPaths?: string[];
|
||||
}
|
||||
|
||||
/** Discriminated union of everything an adapter's parse can emit. Each record
|
||||
* kind maps to one schema table (see scripts/schema.sql); `delete-session` is
|
||||
* the exception — a retraction op, not a table. Sources without a table
|
||||
* (history.jsonl, codex session_index.jsonl) are not records: adapters fold them
|
||||
* into the SessionRecord they already emit. */
|
||||
export type IndexRecord =
|
||||
| SessionRecord
|
||||
| MessageRecord
|
||||
| ToolCallRecord
|
||||
| ToolResultRecord
|
||||
| SummaryRecord
|
||||
| SubagentRecord
|
||||
| WorkflowRecord
|
||||
| WorkflowAgentRecord
|
||||
| MessageTurnDurationRecord
|
||||
| DeleteSessionRecord;
|
||||
|
||||
export interface MessageRecord {
|
||||
kind: 'message';
|
||||
uuid: string;
|
||||
session_id: string;
|
||||
type: string;
|
||||
parent_uuid: string | null;
|
||||
timestamp: string | null;
|
||||
role: string | null;
|
||||
text: string | null;
|
||||
content_type: string | null;
|
||||
is_meta: 0 | 1;
|
||||
model: string | null;
|
||||
is_sidechain: 0 | 1;
|
||||
agent_id: string | null;
|
||||
input_tokens: number | null;
|
||||
output_tokens: number | null;
|
||||
cwd: string | null;
|
||||
skill: string | null;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface ToolCallRecord {
|
||||
kind: 'tool_call';
|
||||
id: string;
|
||||
message_uuid: string;
|
||||
session_id: string;
|
||||
name: string;
|
||||
input_json: string;
|
||||
file_path: string | null;
|
||||
}
|
||||
|
||||
export interface ToolResultRecord {
|
||||
kind: 'tool_result';
|
||||
tool_use_id: string;
|
||||
message_uuid: string;
|
||||
session_id: string;
|
||||
content: string;
|
||||
file_path: string | null;
|
||||
is_error: 0 | 1;
|
||||
}
|
||||
|
||||
export interface SummaryRecord {
|
||||
kind: 'summary';
|
||||
id: string;
|
||||
session_id: string;
|
||||
timestamp: string | null;
|
||||
source: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
// One codex subagent. Like workflow_agent, a row can be contributed by more than
|
||||
// one point in the parse (the spawn event vs the agent's own thread), so non-key
|
||||
// fields are optional and persist merges them column-wise with COALESCE.
|
||||
export interface SubagentRecord {
|
||||
kind: 'subagent';
|
||||
agent_id: string;
|
||||
session_id: string;
|
||||
parent_tool_use_id?: string | null;
|
||||
agent_type?: string | null;
|
||||
description?: string | null;
|
||||
duration_ms?: number | null;
|
||||
total_tokens?: number | null;
|
||||
}
|
||||
|
||||
// A workflow run. `agent_count` is intentionally absent: it is a derived
|
||||
// aggregate (COUNT of workflow_agents for this run) that persist computes, since
|
||||
// the agents may be indexed on different runs than the workflow metadata.
|
||||
export interface WorkflowRecord {
|
||||
kind: 'workflow';
|
||||
run_id: string;
|
||||
session_id: string;
|
||||
task_id: string | null;
|
||||
script: string | null;
|
||||
result_json: string | null;
|
||||
timestamp: string | null;
|
||||
duration_ms: number | null;
|
||||
total_tokens: number | null;
|
||||
status: string | null;
|
||||
workflow_name: string | null;
|
||||
}
|
||||
|
||||
// One workflow agent. A single row is contributed by TWO independent units, in
|
||||
// any order: the subagent .meta.json unit fills agent_type/description; the
|
||||
// workflow run json unit fills phase/label/model/state/duration_ms/tokens/
|
||||
// tool_calls. So every optional field a unit does not know is omitted, and
|
||||
// persist merges column-wise (ON CONFLICT(agent_id) DO UPDATE SET
|
||||
// col=COALESCE(excluded.col, col)). All contributors MUST use the same unified
|
||||
// agent_id key so the merge lands on the same row.
|
||||
export interface WorkflowAgentRecord {
|
||||
kind: 'workflow_agent';
|
||||
agent_id: string;
|
||||
run_id: string;
|
||||
session_id: string;
|
||||
agent_type?: string | null;
|
||||
description?: string | null;
|
||||
phase?: string | null;
|
||||
label?: string | null;
|
||||
model?: string | null;
|
||||
state?: string | null;
|
||||
duration_ms?: number | null;
|
||||
tokens?: number | null;
|
||||
tool_calls?: number | null;
|
||||
}
|
||||
|
||||
// Update op (not a table): sets messages.turn_duration_ms for a message that was
|
||||
// (or will be) inserted by a separate line, possibly on a different run. Persist
|
||||
// applies it as a targeted UPDATE, so it never clobbers other message columns.
|
||||
export interface MessageTurnDurationRecord {
|
||||
kind: 'message-turn-duration';
|
||||
uuid: string;
|
||||
turn_duration_ms: number | null;
|
||||
}
|
||||
|
||||
// Retraction op (not a table). The adapter emits this when a previously-indexed
|
||||
// session must be removed — e.g. a Codex guardian/auto-review thread. Persist
|
||||
// executes the cascade delete across all tables for that session.
|
||||
export interface DeleteSessionRecord {
|
||||
kind: 'delete-session';
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
// Session-level aggregate. Emitted once, after the unit's records are produced,
|
||||
// because started_at/ended_at/message_count are computed across the stream.
|
||||
// title/ended_at may be enriched by the adapter from source-specific auxiliary
|
||||
// files (claude history.jsonl, codex session_index.jsonl); persist upserts with
|
||||
// fill-if-null (COALESCE) so those never clobber a value already present.
|
||||
// project_path is NOT set here — the orchestration's global pass derives it from
|
||||
// persisted message cwds (refreshSessionProjectPaths).
|
||||
//
|
||||
// countMode tells persist how to treat message_count, because providers differ:
|
||||
// a line-incremental adapter (claude) yields only new messages ('delta', persist
|
||||
// accumulates onto the existing row); a full-reparse adapter (codex) yields every
|
||||
// message each run ('total', persist replaces). A 'delta' parse from an empty
|
||||
// cursor is equivalent to 'total'.
|
||||
export interface SessionRecord {
|
||||
kind: 'session';
|
||||
id: string;
|
||||
title: string | null;
|
||||
project: string | null;
|
||||
started_at: string | null;
|
||||
ended_at: string | null;
|
||||
git_branch: string | null;
|
||||
version: string | null;
|
||||
message_count: number;
|
||||
countMode: 'total' | 'delta';
|
||||
jsonl_path: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
// A transcript source. Pure: it never touches the Obelisk database. It owns its
|
||||
// own discovery, change-detection, and resume cursoring, because those are
|
||||
// format-specific (file mtime, DB watermark, …). `parse` is a generator that
|
||||
// yields records for one unit and RETURNS the new cursor to persist.
|
||||
export interface Provider {
|
||||
/** Stable source tag stored on rows, e.g. 'claude' | 'codex'. */
|
||||
readonly name: string;
|
||||
/** Discover units needing (re)indexing, using stored cursors to detect change. */
|
||||
discover(ctx: DiscoverContext): IndexUnit[];
|
||||
/** Stream records for one unit resuming from `cursor`; return the new cursor. */
|
||||
parse(unit: IndexUnit, cursor: Cursor): Generator<IndexRecord, Cursor>;
|
||||
}
|
||||
Reference in New Issue
Block a user