refactor: add canonical transcript assembly seam
This commit is contained in:
+15
-5
@@ -40,13 +40,23 @@ node:fs/path/os — deliberately node:sqlite-free so the compiled providers can
|
||||
consumed by the app (whose Electron runtime has no `node:sqlite`).
|
||||
_Avoid_: parse core, parser, ingest
|
||||
|
||||
**Record**:
|
||||
One normalized row destined for the index (session, message, tool call, tool
|
||||
result, summary, subagent, workflow, …), emitted by a provider adapter before any
|
||||
persistence happens.
|
||||
**Transcript record**:
|
||||
One provider-normalized fact (session, message, tool call, tool result, summary,
|
||||
subagent, workflow, …) in the canonical transcript language. Provider adapters
|
||||
resolve source-specific deduplication, visibility, and identity before emitting
|
||||
it. The persist layer serializes transcript records; the session detail
|
||||
assembler can consume the same stream directly.
|
||||
_Avoid_: database row, raw event
|
||||
|
||||
**Session detail assembler**:
|
||||
The provider-independent module that projects canonical transcript records into
|
||||
the timeline shape used by the app. It may consume records directly from a
|
||||
provider adapter's fresh full parse or after a SQLite round-trip. Provider deltas
|
||||
require prior state and are handled by the snapshot/patch seam instead. It never
|
||||
branches on provider and never infers provider semantics from message text.
|
||||
|
||||
**Persist layer**:
|
||||
The single shared, provider- and binding-agnostic writer that consumes records
|
||||
The single shared, provider- and binding-agnostic writer that consumes transcript records
|
||||
from any adapter and writes them into an injected SQLite handle inside a
|
||||
transaction. The binding is injected — `node:sqlite` (CLI) or
|
||||
`better-sqlite3` (app) — so there is one persist implementation, not one per
|
||||
|
||||
@@ -200,9 +200,11 @@ Full-text search via FTS5 covers all layers.
|
||||
packages/core/ # @obelisk/core npm workspace (TypeScript + ESM)
|
||||
├── src/
|
||||
│ ├── providers/
|
||||
│ │ ├── types.ts # Provider + IndexRecord contract
|
||||
│ │ ├── types.ts # Provider + TranscriptRecord contract
|
||||
│ │ ├── claude.ts # Claude Code adapter (line-incremental)
|
||||
│ │ └── codex.ts # Codex adapter (full-reparse)
|
||||
│ │ ├── codex.ts # Codex adapter (full-reparse)
|
||||
│ │ └── kimi.ts # Kimi Code adapter (session projection)
|
||||
│ ├── session-detail.ts # Provider-independent transcript projection
|
||||
│ ├── persist.ts # Binding-agnostic record writer (upsert/merge)
|
||||
│ ├── tx.ts # Write transaction + connection config
|
||||
│ ├── write-coordinator.ts # Bounded retry policy
|
||||
|
||||
+10
-30
@@ -10,6 +10,7 @@ import { createIndexerService } from './indexer-service.ts';
|
||||
import { createWorkerBuildIndex } from './indexer-worker-client.ts';
|
||||
import { buildRecapExportQuery } from './recap-capture-query.ts';
|
||||
import { acquireWriterLease, writerLockPathFor } from '../../../packages/core/src/writer-lease.ts';
|
||||
import { migrateCoreSchemaColumns } from '../../../packages/core/src/schema-migrations.ts';
|
||||
import { createBuiltinProviderRegistry } from '../../../packages/core/src/providers/builtins.ts';
|
||||
import {
|
||||
buildSourceCatalog,
|
||||
@@ -32,7 +33,7 @@ import type {
|
||||
SessionWorkflowRow,
|
||||
} from '../shared/session-detail-types.ts';
|
||||
import { createSessionPatch } from '../shared/session-patch.mjs';
|
||||
import { assembleSessionMessages } from '../shared/session-detail-assembly.mjs';
|
||||
import { assembleSessionDetail } from '../shared/session-detail-assembly.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -160,35 +161,12 @@ function resolveSchemaPath() {
|
||||
return candidates.find(p => fs.existsSync(p));
|
||||
}
|
||||
|
||||
function ensureColumn(db, table, column, definition) {
|
||||
const columns = db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name);
|
||||
if (!columns.includes(column)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
||||
}
|
||||
|
||||
function tableExists(db, table) {
|
||||
return Boolean(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(table));
|
||||
}
|
||||
|
||||
function migrateExistingColumns(db) {
|
||||
if (tableExists(db, 'sessions')) ensureColumn(db, 'sessions', 'source', "TEXT DEFAULT 'claude'");
|
||||
if (tableExists(db, 'messages')) {
|
||||
ensureColumn(db, 'messages', 'content_type', 'TEXT');
|
||||
ensureColumn(db, 'messages', 'is_meta', 'INTEGER DEFAULT 0');
|
||||
ensureColumn(db, 'messages', 'source', "TEXT DEFAULT 'claude'");
|
||||
}
|
||||
if (tableExists(db, 'memories')) {
|
||||
ensureColumn(db, 'memories', 'anchors', 'TEXT');
|
||||
ensureColumn(db, 'memories', 'deleted_at', 'TEXT');
|
||||
ensureColumn(db, 'memories', 'deleted_reason', 'TEXT');
|
||||
}
|
||||
}
|
||||
|
||||
function migrateDb(db) {
|
||||
if (typeof db.exec !== 'function' || typeof db.prepare !== 'function') return;
|
||||
migrateExistingColumns(db);
|
||||
migrateCoreSchemaColumns(db);
|
||||
const schemaPath = resolveSchemaPath();
|
||||
if (schemaPath) db.exec(fs.readFileSync(schemaPath, 'utf8'));
|
||||
migrateExistingColumns(db);
|
||||
migrateCoreSchemaColumns(db);
|
||||
}
|
||||
|
||||
function closeDb() {
|
||||
@@ -417,7 +395,7 @@ function querySessionMessages(sessionId: string): SessionMessageRow[] {
|
||||
return db.prepare(`
|
||||
SELECT m.uuid, m.session_id, m.type, m.parent_uuid, m.timestamp, m.role, m.text, m.model,
|
||||
m.is_sidechain, m.agent_id, m.input_tokens, m.output_tokens, m.cwd, m.skill, m.turn_duration_ms,
|
||||
m.content_type, m.is_meta, m.source
|
||||
m.content_type, m.is_meta, m.visibility, m.source
|
||||
FROM messages m WHERE m.session_id = ? AND m.agent_id IS NULL ORDER BY m.timestamp, m.uuid
|
||||
`).all(sessionId) as SessionMessageRow[];
|
||||
}
|
||||
@@ -464,9 +442,11 @@ function querySessionSnapshot(sessionId: string): SessionDetailAssemblyInput {
|
||||
|
||||
function querySessionDisplaySnapshot(sessionId: string): SessionPatchSnapshot {
|
||||
const snapshot = querySessionSnapshot(sessionId);
|
||||
const detail = assembleSessionDetail(snapshot);
|
||||
return {
|
||||
messages: assembleSessionMessages(snapshot),
|
||||
workflows: snapshot.workflows,
|
||||
messages: detail.messages,
|
||||
workflows: detail.workflows,
|
||||
summaries: detail.summaries,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -544,7 +524,7 @@ ipcMain.handle('db:getSubagentMessages', (_, agentId) => {
|
||||
return db.prepare(`
|
||||
SELECT m.uuid, m.session_id, m.type, m.parent_uuid, m.timestamp, m.role, m.text, m.model,
|
||||
m.is_sidechain, m.agent_id, m.input_tokens, m.output_tokens, m.cwd, m.skill, m.turn_duration_ms,
|
||||
m.content_type, m.is_meta, m.source
|
||||
m.content_type, m.is_meta, m.visibility, m.source
|
||||
FROM messages m WHERE m.agent_id = ? ORDER BY m.timestamp, m.uuid
|
||||
`).all(agentId);
|
||||
});
|
||||
|
||||
+2
-99
@@ -11,23 +11,18 @@ import {
|
||||
writeProviderIndexMarkers,
|
||||
} from '../../../packages/core/src/provider-indexing.ts';
|
||||
import { runWriteTransaction, configureConnection, betterSqliteTransactionAdapter } from '../../../packages/core/src/tx.ts';
|
||||
import { migrateCoreSchemaColumns } from '../../../packages/core/src/schema-migrations.ts';
|
||||
import { acquireWriterLease, writerLockPathFor } from '../../../packages/core/src/writer-lease.ts';
|
||||
import { runRetryableWriteTransaction, isBeginBusyFailure, hasUnusableTransaction } from '../../../packages/core/src/write-coordinator.ts';
|
||||
import {
|
||||
inferProjectPath,
|
||||
isDir,
|
||||
readLines,
|
||||
codexDbId,
|
||||
} from '../../../packages/core/src/parsing.ts';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const DEFAULT_CLAUDE_DIR = path.join(os.homedir(), '.claude');
|
||||
const DEFAULT_CODEX_DIR = path.join(os.homedir(), '.codex');
|
||||
const DEFAULT_OBELISK_DIR = path.join(os.homedir(), '.obelisk');
|
||||
const DEFAULT_DB_PATH = path.join(DEFAULT_OBELISK_DIR, 'obelisk.sqlite');
|
||||
const DEFAULT_PROJECTS_DIR = path.join(DEFAULT_CLAUDE_DIR, 'projects');
|
||||
const DEFAULT_HISTORY_PATH = path.join(DEFAULT_CLAUDE_DIR, 'history.jsonl');
|
||||
|
||||
function resolveSchemaPath() {
|
||||
const candidates = [
|
||||
@@ -42,7 +37,7 @@ function resolveSchemaPath() {
|
||||
|
||||
function installSchema(db, schemaPath = resolveSchemaPath()) {
|
||||
db.exec(fs.readFileSync(schemaPath, 'utf8'));
|
||||
migrateDb(db);
|
||||
migrateCoreSchemaColumns(db);
|
||||
}
|
||||
|
||||
function openIndexDb({ dbPath = DEFAULT_DB_PATH, schemaPath = resolveSchemaPath(), DatabaseImpl = Database }: { dbPath?: string; schemaPath?: string; DatabaseImpl?: new (dbPath: string) => any } = {}) {
|
||||
@@ -53,21 +48,6 @@ function openIndexDb({ dbPath = DEFAULT_DB_PATH, schemaPath = resolveSchemaPath(
|
||||
return db;
|
||||
}
|
||||
|
||||
function ensureColumn(db, table, column, definition) {
|
||||
const columns = db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name);
|
||||
if (!columns.includes(column)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
||||
}
|
||||
|
||||
function migrateDb(db) {
|
||||
ensureColumn(db, 'sessions', 'source', "TEXT DEFAULT 'claude'");
|
||||
ensureColumn(db, 'messages', 'content_type', 'TEXT');
|
||||
ensureColumn(db, 'messages', 'is_meta', 'INTEGER DEFAULT 0');
|
||||
ensureColumn(db, 'messages', 'source', "TEXT DEFAULT 'claude'");
|
||||
ensureColumn(db, 'memories', 'anchors', 'TEXT');
|
||||
ensureColumn(db, 'memories', 'deleted_at', 'TEXT');
|
||||
ensureColumn(db, 'memories', 'deleted_reason', 'TEXT');
|
||||
}
|
||||
|
||||
function copyMemoriesFromDb(db, sourceDbPath) {
|
||||
if (!sourceDbPath || !fs.existsSync(sourceDbPath)) return false;
|
||||
db.prepare('ATTACH DATABASE ? AS previous_obelisk').run(sourceDbPath);
|
||||
@@ -126,23 +106,6 @@ function sessionIdFromChangedPath(projectsDir, changedPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function indexCodexSessionIndex(db, { codexDir = DEFAULT_CODEX_DIR } = {}) {
|
||||
const indexPath = path.join(codexDir, 'session_index.jsonl');
|
||||
if (!fs.existsSync(indexPath)) return;
|
||||
readLines(indexPath, (line) => {
|
||||
let item;
|
||||
try {
|
||||
item = JSON.parse(line);
|
||||
} catch (error) {
|
||||
console.warn(`Warning: malformed Codex session index line: ${(error as Error).message}`);
|
||||
return;
|
||||
}
|
||||
if (!item.id || !item.thread_name) return;
|
||||
db.prepare('UPDATE sessions SET title=COALESCE(title, ?), ended_at=COALESCE(ended_at, ?) WHERE id=? AND source=?')
|
||||
.run(item.thread_name, item.updated_at || null, codexDbId(item.id), 'codex');
|
||||
});
|
||||
}
|
||||
|
||||
function refreshSessionProjectPaths(db) {
|
||||
const sessions = db.prepare('SELECT id, project FROM sessions').all();
|
||||
const cwdStmt = db.prepare(`
|
||||
@@ -158,61 +121,6 @@ function refreshSessionProjectPaths(db) {
|
||||
}
|
||||
}
|
||||
|
||||
function indexWorkflows(db, { projectsDir = DEFAULT_PROJECTS_DIR } = {}) {
|
||||
if (!fs.existsSync(projectsDir)) return;
|
||||
let projects;
|
||||
try { projects = fs.readdirSync(projectsDir); } catch { return; }
|
||||
for (const proj of projects) {
|
||||
const pp = path.join(projectsDir, proj);
|
||||
if (!isDir(pp)) continue;
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(pp); } catch { continue; }
|
||||
for (const sd of entries) {
|
||||
const wd = path.join(pp, sd, 'workflows');
|
||||
if (!isDir(wd)) continue;
|
||||
let wfFiles;
|
||||
try { wfFiles = fs.readdirSync(wd); } catch { continue; }
|
||||
for (const f of wfFiles) {
|
||||
if (!f.endsWith('.json')) continue;
|
||||
let wf;
|
||||
try {
|
||||
wf = JSON.parse(fs.readFileSync(path.join(wd, f), 'utf8'));
|
||||
} catch (error) {
|
||||
console.warn(`Warning: failed to read workflow ${f}: ${(error as Error).message}`);
|
||||
continue;
|
||||
}
|
||||
if (!wf.runId) continue;
|
||||
const ac = db.prepare('SELECT COUNT(*) as c FROM workflow_agents WHERE run_id=?').get(wf.runId);
|
||||
db.prepare('INSERT OR REPLACE INTO workflows (run_id,session_id,task_id,script,result_json,timestamp,agent_count,duration_ms,total_tokens,status,workflow_name) VALUES(?,?,?,?,?,?,?,?,?,?,?)').run(
|
||||
wf.runId, sd, wf.taskId||null, wf.script||null,
|
||||
wf.result ? JSON.stringify(wf.result) : null, wf.timestamp||null, ac?.c||0,
|
||||
wf.durationMs||null, wf.totalTokens||null, wf.status||null, wf.workflowName||null);
|
||||
const progress = wf.workflowProgress || [];
|
||||
for (const item of progress) {
|
||||
if (item.type !== 'workflow_agent' || !item.agentId) continue;
|
||||
db.prepare('UPDATE workflow_agents SET phase=?, label=?, model=?, state=?, duration_ms=?, tokens=?, tool_calls=? WHERE agent_id=?').run(
|
||||
item.phaseTitle||null, item.label||null, item.model||null, item.state||null,
|
||||
item.durationMs||null, item.tokens||null, item.toolCalls||null, 'agent-' + item.agentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function indexHistory(db, { historyPath = DEFAULT_HISTORY_PATH } = {}) {
|
||||
if (!fs.existsSync(historyPath)) return;
|
||||
readLines(historyPath, (line) => {
|
||||
let item;
|
||||
try {
|
||||
item = JSON.parse(line);
|
||||
} catch (error) {
|
||||
console.warn(`Warning: malformed history line: ${(error as Error).message}`);
|
||||
return;
|
||||
}
|
||||
if (item.sessionId && item.title) db.prepare('UPDATE sessions SET title=? WHERE id=? AND title IS NULL').run(item.title, item.sessionId);
|
||||
});
|
||||
}
|
||||
|
||||
function rebuildFts(db) {
|
||||
db.exec("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')");
|
||||
db.exec("INSERT INTO memories_fts(memories_fts) VALUES('rebuild')");
|
||||
@@ -286,7 +194,6 @@ interface BuildIndexOptions {
|
||||
claudeDir?: string;
|
||||
codexDir?: string;
|
||||
projectsDir?: string;
|
||||
historyPath?: string;
|
||||
dbPath?: string;
|
||||
schemaPath?: string;
|
||||
DatabaseImpl?: new (dbPath: string) => any;
|
||||
@@ -339,7 +246,6 @@ function buildIndex({
|
||||
claudeDir = DEFAULT_CLAUDE_DIR,
|
||||
codexDir = path.join(path.dirname(claudeDir), '.codex'),
|
||||
projectsDir = path.join(claudeDir, 'projects'),
|
||||
historyPath = path.join(claudeDir, 'history.jsonl'),
|
||||
dbPath = DEFAULT_DB_PATH,
|
||||
schemaPath = resolveSchemaPath(),
|
||||
DatabaseImpl = Database,
|
||||
@@ -489,10 +395,7 @@ function buildIndex({
|
||||
// index would otherwise be left inconsistent).
|
||||
try {
|
||||
runRetryableWriteTransaction(txDb, () => {
|
||||
indexWorkflows(db, { projectsDir });
|
||||
refreshSessionProjectPaths(db);
|
||||
indexHistory(db, { historyPath });
|
||||
indexCodexSessionIndex(db, { codexDir });
|
||||
if (messageFtsTriggersDropped) installSchema(db, schemaPath);
|
||||
ftsRebuilt = ensureFtsReady(db, { force });
|
||||
writeIndexMarker(db, '__last_build__');
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
applySessionPatch,
|
||||
createSessionPatchCursor,
|
||||
} from '../../shared/session-patch.mjs';
|
||||
import { assembleSessionMessages } from '../../shared/session-detail-assembly.mjs';
|
||||
import { assembleSessionDetail } from '../../shared/session-detail-assembly.mjs';
|
||||
|
||||
const sessionMessageSnapshots = new Map();
|
||||
const MAX_SESSION_MESSAGE_SNAPSHOTS = 3;
|
||||
@@ -25,6 +25,7 @@ function sessionMetadata(session) {
|
||||
const metadata = { ...session };
|
||||
delete metadata.messages;
|
||||
delete metadata.workflow;
|
||||
delete metadata.summaries;
|
||||
return markRaw(metadata);
|
||||
}
|
||||
|
||||
@@ -96,9 +97,11 @@ export async function loadSessionDetail(sessionId) {
|
||||
window.obelisk.getSessionWorkflows(sessionId),
|
||||
window.obelisk.getSessionSummaries(sessionId),
|
||||
]);
|
||||
const detail = assembleSessionDetail({ messages, toolCalls, toolResults, subagents, workflows, summaries });
|
||||
const snapshot = {
|
||||
messages: assembleSessionMessages({ messages, toolCalls, toolResults, subagents, workflows }),
|
||||
workflows,
|
||||
messages: detail.messages,
|
||||
workflows: detail.workflows,
|
||||
summaries: detail.summaries,
|
||||
};
|
||||
const metadata = sessionMetadata(state.sessions.find(candidate => candidate.id === sessionId));
|
||||
rememberSessionMessageSnapshot(sessionId, {
|
||||
@@ -154,13 +157,14 @@ export function getCachedSessionDetail(sessionId) {
|
||||
});
|
||||
}
|
||||
|
||||
function commitSessionDetail(sessionId, { messages, workflows = [] }, { updateStore, metadata = null }) {
|
||||
function commitSessionDetail(sessionId, { messages, workflows = [], summaries = [] }, { updateStore, metadata = null }) {
|
||||
const session = state.sessions.find(candidate => candidate.id === sessionId);
|
||||
const assembled = {
|
||||
...(session || {}),
|
||||
...(metadata || {}),
|
||||
id: sessionId,
|
||||
messages: markRaw(messages),
|
||||
summaries: markRaw(summaries),
|
||||
};
|
||||
if (workflows.length > 0) assembled.workflow = workflows[0];
|
||||
|
||||
@@ -181,13 +185,13 @@ export async function loadSubagentDetail(agentId) {
|
||||
window.obelisk.getSubagentToolCalls(agentId),
|
||||
window.obelisk.getSubagentToolResults(agentId),
|
||||
]);
|
||||
return assembleSessionMessages({
|
||||
return assembleSessionDetail({
|
||||
messages,
|
||||
toolCalls,
|
||||
toolResults,
|
||||
subagents: [],
|
||||
workflows: [],
|
||||
});
|
||||
}).messages;
|
||||
}
|
||||
|
||||
const TEXT_LIMIT = 10000;
|
||||
|
||||
@@ -1,173 +1,9 @@
|
||||
// @ts-check
|
||||
import { assembleSessionDetail } from '../../../packages/core/src/session-detail.ts';
|
||||
|
||||
/** @typedef {import('./session-detail-types.ts').AssembledMessage} AssembledMessage */
|
||||
/** @typedef {import('./session-detail-types.ts').AssembledToolCall} AssembledToolCall */
|
||||
/** @typedef {import('./session-detail-types.ts').SessionDetailAssemblyInput} SessionDetailAssemblyInput */
|
||||
/** @typedef {import('./session-detail-types.ts').SessionSubagentRow} SessionSubagentRow */
|
||||
/** @typedef {import('./session-detail-types.ts').SessionToolResultRow} SessionToolResultRow */
|
||||
export { assembleSessionDetail };
|
||||
|
||||
/**
|
||||
* @param {SessionDetailAssemblyInput} input
|
||||
* @returns {AssembledMessage[]}
|
||||
*/
|
||||
export function assembleSessionMessages({ messages, toolCalls, toolResults, subagents, workflows }) {
|
||||
const resultsByCallId = /** @type {Map<string, SessionToolResultRow>} */ (new Map());
|
||||
for (const result of toolResults || []) resultsByCallId.set(result.tool_use_id, result);
|
||||
|
||||
const subagentsByCallId = /** @type {Map<string, SessionSubagentRow>} */ (new Map());
|
||||
for (const subagent of subagents || []) {
|
||||
if (subagent.parent_tool_use_id) subagentsByCallId.set(subagent.parent_tool_use_id, subagent);
|
||||
}
|
||||
|
||||
const callsByMessageUuid = /** @type {Map<string, AssembledToolCall[]>} */ (new Map());
|
||||
for (const toolCall of toolCalls || []) {
|
||||
const call = /** @type {AssembledToolCall} */ ({
|
||||
id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
input_json: toolCall.input_json,
|
||||
result: resultsByCallId.get(toolCall.id) || null,
|
||||
});
|
||||
const subagent = subagentsByCallId.get(toolCall.id);
|
||||
if (subagent) {
|
||||
call.subagent = {
|
||||
agent_id: subagent.agent_id,
|
||||
agent_type: subagent.agent_type,
|
||||
description: subagent.description,
|
||||
};
|
||||
}
|
||||
const messageUuid = toolCall.message_uuid;
|
||||
const calls = callsByMessageUuid.get(messageUuid) || [];
|
||||
calls.push(call);
|
||||
callsByMessageUuid.set(messageUuid, calls);
|
||||
}
|
||||
|
||||
for (const workflow of workflows || []) {
|
||||
for (const calls of callsByMessageUuid.values()) {
|
||||
for (const call of calls) {
|
||||
if (call.name !== 'Workflow' || call.workflow) continue;
|
||||
const resultText = call.result?.content || '';
|
||||
if (!resultText.includes(workflow.run_id) && !resultText.includes(workflow.workflow_name || '___none___')) continue;
|
||||
call.workflow = {
|
||||
run_id: workflow.run_id,
|
||||
workflow_name: workflow.workflow_name,
|
||||
status: workflow.status,
|
||||
duration_ms: workflow.duration_ms,
|
||||
total_tokens: workflow.total_tokens,
|
||||
agent_count: workflow.agent_count,
|
||||
agents: (workflow.agents || []).map(agent => ({
|
||||
agent_id: agent.agent_id,
|
||||
phase: agent.phase,
|
||||
label: agent.label,
|
||||
state: agent.state,
|
||||
tokens: agent.tokens,
|
||||
duration_ms: agent.duration_ms,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const metaPattern = /^\s*<(task-notification|command-name|local-command|system-reminder)/;
|
||||
const rawAssembled = (messages || []).map(message => {
|
||||
const assembled = /** @type {AssembledMessage} */ ({
|
||||
uuid: message.uuid,
|
||||
type: message.type || message.role,
|
||||
timestamp: message.timestamp,
|
||||
text: message.text,
|
||||
content_type: message.content_type || null,
|
||||
is_meta: message.is_meta || (message.text && metaPattern.test(message.text) ? 1 : 0),
|
||||
});
|
||||
const calls = callsByMessageUuid.get(message.uuid);
|
||||
if (calls?.length) assembled.tool_calls = calls;
|
||||
return assembled;
|
||||
});
|
||||
|
||||
const assembledMessages = /** @type {AssembledMessage[]} */ ([]);
|
||||
for (let index = 0; index < rawAssembled.length; index++) {
|
||||
const message = rawAssembled[index];
|
||||
if (message.content_type === 'tool_result') continue;
|
||||
|
||||
if (message.type === 'assistant' && message.content_type === 'thinking') {
|
||||
const thinkingParts = [message.text || ''];
|
||||
let nextIndex = index + 1;
|
||||
while (
|
||||
nextIndex < rawAssembled.length
|
||||
&& rawAssembled[nextIndex].type === 'assistant'
|
||||
&& rawAssembled[nextIndex].content_type === 'thinking'
|
||||
) {
|
||||
thinkingParts.push(rawAssembled[nextIndex].text || '');
|
||||
nextIndex++;
|
||||
}
|
||||
if (
|
||||
nextIndex < rawAssembled.length
|
||||
&& rawAssembled[nextIndex].type === 'assistant'
|
||||
&& rawAssembled[nextIndex].content_type !== 'thinking'
|
||||
) {
|
||||
rawAssembled[nextIndex]._thinking = thinkingParts.join('\n\n');
|
||||
index = nextIndex - 1;
|
||||
continue;
|
||||
}
|
||||
assembledMessages.push({ ...message, text: thinkingParts.join('\n\n'), content_type: 'thinking' });
|
||||
index = nextIndex - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.type === 'assistant' && message.content_type === 'tool_use') {
|
||||
const merged = /** @type {AssembledMessage} */ ({
|
||||
...message,
|
||||
tool_calls: [...(message.tool_calls || [])],
|
||||
});
|
||||
const mergedCalls = merged.tool_calls || [];
|
||||
if (message._thinking) merged._thinking = message._thinking;
|
||||
const skillOnly = mergedCalls.length === 1 && mergedCalls[0].name === 'Skill';
|
||||
let nextIndex = index + 1;
|
||||
while (nextIndex < rawAssembled.length) {
|
||||
const next = rawAssembled[nextIndex];
|
||||
if (next.content_type === 'tool_result') {
|
||||
nextIndex++;
|
||||
continue;
|
||||
}
|
||||
if (next.is_meta && next.text && next.text.includes('Base directory for this skill')) {
|
||||
merged._skillMd = next.text;
|
||||
nextIndex++;
|
||||
continue;
|
||||
}
|
||||
if (!skillOnly && next.type === 'assistant' && next.content_type === 'tool_use') {
|
||||
if (next.tool_calls) mergedCalls.push(...next.tool_calls);
|
||||
if (next.text && !merged.text) merged.text = next.text;
|
||||
nextIndex++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
assembledMessages.push(merged);
|
||||
index = nextIndex - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const output = /** @type {AssembledMessage} */ ({ ...message });
|
||||
if (message._thinking) output._thinking = message._thinking;
|
||||
if (message.type === 'assistant' && message.content_type !== 'tool_use' && message.content_type !== 'thinking') {
|
||||
if (!output.tool_calls) output.tool_calls = [];
|
||||
let nextIndex = index + 1;
|
||||
while (nextIndex < rawAssembled.length) {
|
||||
const next = rawAssembled[nextIndex];
|
||||
if (next.content_type === 'tool_result') {
|
||||
nextIndex++;
|
||||
continue;
|
||||
}
|
||||
if (next.type === 'assistant' && next.content_type === 'tool_use') {
|
||||
if (next.tool_calls) output.tool_calls.push(...next.tool_calls);
|
||||
nextIndex++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (!output.tool_calls.length) delete output.tool_calls;
|
||||
index = nextIndex - 1;
|
||||
}
|
||||
assembledMessages.push(output);
|
||||
}
|
||||
|
||||
return assembledMessages;
|
||||
// Compatibility for local/generated app tooling; production callers use the
|
||||
// single Core assembleSessionDetail seam.
|
||||
export function assembleSessionMessages(input) {
|
||||
return assembleSessionDetail(input).messages;
|
||||
}
|
||||
|
||||
@@ -1,105 +1,13 @@
|
||||
export interface SessionMessageRow {
|
||||
[key: string]: unknown;
|
||||
uuid: string;
|
||||
type?: string | null;
|
||||
role?: string | null;
|
||||
timestamp?: string | null;
|
||||
text?: string | null;
|
||||
content_type?: string | null;
|
||||
is_meta?: number | boolean | null;
|
||||
}
|
||||
|
||||
export interface SessionToolResultRow {
|
||||
[key: string]: unknown;
|
||||
tool_use_id: string;
|
||||
content?: string | null;
|
||||
}
|
||||
|
||||
export interface SessionToolCallRow {
|
||||
[key: string]: unknown;
|
||||
id: string;
|
||||
message_uuid: string;
|
||||
name: string;
|
||||
input_json?: string | null;
|
||||
}
|
||||
|
||||
export interface SessionSubagentRow {
|
||||
[key: string]: unknown;
|
||||
agent_id: string;
|
||||
parent_tool_use_id?: string | null;
|
||||
agent_type?: string | null;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface SessionWorkflowAgentRow {
|
||||
[key: string]: unknown;
|
||||
agent_id: string;
|
||||
phase?: string | null;
|
||||
label?: string | null;
|
||||
state?: string | null;
|
||||
tokens?: number | null;
|
||||
duration_ms?: number | null;
|
||||
}
|
||||
|
||||
export interface SessionWorkflowRow {
|
||||
[key: string]: unknown;
|
||||
run_id: string;
|
||||
workflow_name?: string | null;
|
||||
status?: string | null;
|
||||
duration_ms?: number | null;
|
||||
total_tokens?: number | null;
|
||||
agent_count?: number | null;
|
||||
agents?: SessionWorkflowAgentRow[] | null;
|
||||
}
|
||||
|
||||
export interface SessionSummaryRow {
|
||||
[key: string]: unknown;
|
||||
id: string | number;
|
||||
}
|
||||
|
||||
export interface SessionDetailAssemblyInput {
|
||||
messages?: SessionMessageRow[];
|
||||
toolCalls?: SessionToolCallRow[];
|
||||
toolResults?: SessionToolResultRow[];
|
||||
subagents?: SessionSubagentRow[];
|
||||
workflows?: SessionWorkflowRow[];
|
||||
summaries?: SessionSummaryRow[];
|
||||
}
|
||||
|
||||
export interface AssembledToolCall {
|
||||
[key: string]: unknown;
|
||||
id: string;
|
||||
name: string;
|
||||
input_json?: string | null;
|
||||
result: SessionToolResultRow | null;
|
||||
subagent?: {
|
||||
agent_id: string;
|
||||
agent_type?: string | null;
|
||||
description?: string | null;
|
||||
};
|
||||
workflow?: {
|
||||
run_id: string;
|
||||
workflow_name?: string | null;
|
||||
status?: string | null;
|
||||
duration_ms?: number | null;
|
||||
total_tokens?: number | null;
|
||||
agent_count?: number | null;
|
||||
agents: Array<{
|
||||
agent_id: string;
|
||||
phase?: string | null;
|
||||
label?: string | null;
|
||||
state?: string | null;
|
||||
tokens?: number | null;
|
||||
duration_ms?: number | null;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AssembledMessage extends SessionMessageRow {
|
||||
type?: string | null;
|
||||
content_type?: string | null;
|
||||
is_meta?: number | boolean | null;
|
||||
tool_calls?: AssembledToolCall[];
|
||||
_thinking?: string;
|
||||
_skillMd?: string;
|
||||
}
|
||||
export type {
|
||||
AssembledMessage,
|
||||
AssembledToolCall,
|
||||
SessionDetailRows as SessionDetailAssemblyInput,
|
||||
SessionDetailSessionRow,
|
||||
SessionMessageRow,
|
||||
SessionSubagentRow,
|
||||
SessionSummaryRow,
|
||||
SessionToolCallRow,
|
||||
SessionToolResultRow,
|
||||
SessionWorkflowAgentRow,
|
||||
SessionWorkflowRow,
|
||||
} from '../../../packages/core/src/session-detail.ts';
|
||||
|
||||
@@ -3,7 +3,7 @@ import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { createSessionPatch } from '../src/shared/session-patch.mjs';
|
||||
import { assembleSessionMessages } from '../src/shared/session-detail-assembly.mjs';
|
||||
import { assembleSessionDetail } from '../src/shared/session-detail-assembly.mjs';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const appRoot = join(here, '..');
|
||||
@@ -106,13 +106,13 @@ function registerHandlers() {
|
||||
ipcMain.handle('db:getSessionPatch', (_event, sessionId, cursor) => {
|
||||
const fixture = fixtures[sessionId];
|
||||
const patch = createSessionPatch({
|
||||
messages: assembleSessionMessages({
|
||||
messages: assembleSessionDetail({
|
||||
messages: fixture.messages,
|
||||
toolCalls: fixture.toolCalls,
|
||||
toolResults: fixture.toolResults,
|
||||
subagents: [],
|
||||
workflows: [],
|
||||
}),
|
||||
}).messages,
|
||||
workflows: [],
|
||||
}, cursor);
|
||||
return { ...patch, session: summary(sessionId) };
|
||||
|
||||
@@ -5,7 +5,7 @@ import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { createSessionPatch } from '../src/shared/session-patch.mjs';
|
||||
import { assembleSessionMessages } from '../src/shared/session-detail-assembly.mjs';
|
||||
import { assembleSessionDetail } from '../src/shared/session-detail-assembly.mjs';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const appRoot = join(here, '..');
|
||||
@@ -530,7 +530,7 @@ function registerHandlers() {
|
||||
nextPatchDelayMs = 0;
|
||||
if (delayMs > 0) await delay(delayMs);
|
||||
const patch = createSessionPatch({
|
||||
messages: assembleSessionMessages({ messages, toolCalls, toolResults, subagents: [], workflows: [] }),
|
||||
messages: assembleSessionDetail({ messages, toolCalls, toolResults, subagents: [], workflows: [] }).messages,
|
||||
workflows: [],
|
||||
}, cursor);
|
||||
ipcReads.patchMessageRows.push(patch.changes.messages.length);
|
||||
|
||||
@@ -33,7 +33,9 @@ binding-agnostic and does not need a per-binding implementation.
|
||||
`IndexUnit` when correctness requires whole-unit semantics — for example,
|
||||
Codex duplicate reconciliation or Kimi `context.undo` / `context.clear`
|
||||
replay. Each adapter maps its own resume/change semantics onto the existing
|
||||
`mtime` and `lines_processed` cursor pair in `index_state`.
|
||||
`mtime` and `lines_processed` cursor pair in `index_state`. The emitted
|
||||
`TranscriptRecord` stream is also the input to provider-independent session
|
||||
detail assembly; see ADR-0007.
|
||||
- **Persist axis — one shared orchestration.** A single provider-agnostic,
|
||||
binding-agnostic layer consumes records from any adapter and writes them:
|
||||
incremental `index_state` bookkeeping, FTS maintenance, and the canonical
|
||||
@@ -56,9 +58,9 @@ main process migrates to ESM (ADR-0003) to import the shared core. The real work
|
||||
is disentangling the currently interleaved parse-and-write inside `indexJsonl` /
|
||||
`indexCodexJsonl` into (pure adapter parse) + (shared persist).
|
||||
|
||||
The SQLite schema and normalized `IndexRecord` union are the stable center of
|
||||
the design. Provider-only concepts are either projected lossily into that
|
||||
language or ignored; they do not add provider columns or tables. The registry,
|
||||
The normalized `TranscriptRecord` union is the stable center of the design, and
|
||||
SQLite is one serialization adapter for it. Provider-only concepts are either
|
||||
projected lossily into that language or ignored. The registry,
|
||||
not provider switches, drives both indexers, watcher roots, persisted source
|
||||
roots, source catalog/UI labels and colors, and raw-record routing. Adding Pi
|
||||
therefore changes the Pi adapter, its registration, and its conformance tests;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Canonical transcript records are the session-detail seam
|
||||
|
||||
**Context.** Provider adapters originally emitted database-shaped records, while
|
||||
the desktop app reconstructed presentation semantics after querying SQLite.
|
||||
Although that reconstruction had no explicit provider switch, it still inferred
|
||||
metadata from raw message text. As more providers are added, those heuristics
|
||||
would make provider semantics leak into a shared presentation module and allow
|
||||
the direct parse path to drift from the persisted path.
|
||||
|
||||
**Decision.** Every provider adapter emits a canonical `TranscriptRecord`
|
||||
stream. The adapter owns all source-specific interpretation: duplicate raw
|
||||
events, stable identities, tool relationships, message classification, and
|
||||
visibility. `visibility` is separate from `is_meta`: hidden transport context is
|
||||
omitted from session detail, while visible system evidence can remain a metadata
|
||||
card. Presentation-sensitive concepts are explicit canonical fields: tool calls
|
||||
carry a presentation class, Skill instructions carry a content type, and
|
||||
workflows carry their parent tool-call identity.
|
||||
|
||||
The Core `assembleSessionDetail(input)` module is the only session-detail seam.
|
||||
It accepts either a provider's complete transcript stream from a fresh parse
|
||||
(`cursor = null`) or table-shaped rows after a persistence round-trip. A delta
|
||||
parse cannot produce a complete detail snapshot without prior state, so the
|
||||
assembler rejects a `SessionRecord` whose `countMode` is `delta`; incremental UI
|
||||
updates use the existing snapshot/patch seam. Its internal row adapter restores
|
||||
the canonical record language before assembly. The implementation may sort,
|
||||
group thinking, and attach tool results, subagents, and workflows, but it never
|
||||
checks the provider and never parses message text to recover provider semantics.
|
||||
Tool names are likewise display data, not assembly control flow.
|
||||
|
||||
The persist layer only serializes transcript records and cursor state. SQLite is
|
||||
not the source of transcript semantics, and a persistence round-trip must not
|
||||
change the assembled result.
|
||||
|
||||
**Consequences.** A new provider is complete only when its canonical transcript
|
||||
can pass directly through `assembleSessionDetail`. Provider conformance tests
|
||||
cover that seam, while persistence tests verify that canonical classification
|
||||
survives a database round-trip. Codex-owned normalization now classifies hidden
|
||||
context envelopes and structurally removes image wrappers before duplicate
|
||||
reconciliation. Adding another provider does not add branches to the app's
|
||||
session-detail code.
|
||||
@@ -11,6 +11,7 @@
|
||||
"./parsing": "./dist/parsing.js",
|
||||
"./persist": "./dist/persist.js",
|
||||
"./provider-indexing": "./dist/provider-indexing.js",
|
||||
"./session-detail": "./dist/session-detail.js",
|
||||
"./providers/claude": "./dist/providers/claude.js",
|
||||
"./providers/codex": "./dist/providers/codex.js",
|
||||
"./providers/kimi": "./dist/providers/kimi.js",
|
||||
@@ -18,6 +19,7 @@
|
||||
"./providers/builtins": "./dist/providers/builtins.js",
|
||||
"./providers/types": "./dist/providers/types.js",
|
||||
"./query": "./dist/query.js",
|
||||
"./schema-migrations": "./dist/schema-migrations.js",
|
||||
"./sqlite-types": "./dist/sqlite-types.js",
|
||||
"./tx": "./dist/tx.js",
|
||||
"./write-coordinator": "./dist/write-coordinator.js",
|
||||
|
||||
+3
-29
@@ -2,6 +2,7 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import { CLAUDE_DIR, CODEX_DIR, TEXT_LIMIT, trunc, truncJson, extractText, extractContentType, extractMessageIsMeta, filePath, isDir, readLines } from './parsing.ts';
|
||||
import { configureConnection } from './tx.ts';
|
||||
import { migrateCoreSchemaColumns } from './schema-migrations.ts';
|
||||
import type { NodeSqliteDb, SqliteDb } from './sqlite-types.ts';
|
||||
const require = createRequire(import.meta.url);
|
||||
const fs = require('node:fs');
|
||||
@@ -26,9 +27,9 @@ function openDb(): NodeSqliteDb {
|
||||
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
||||
const db = new DatabaseSync(DB_PATH);
|
||||
configureConnection(db, { busyTimeoutMs: 250 });
|
||||
migrateExistingColumns(db);
|
||||
migrateCoreSchemaColumns(db);
|
||||
db.exec(SCHEMA);
|
||||
migrateDb(db);
|
||||
migrateCoreSchemaColumns(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
@@ -44,33 +45,6 @@ function openWriterLeaseDb(lockPath: string): NodeSqliteDb {
|
||||
return new DatabaseSync(lockPath);
|
||||
}
|
||||
|
||||
function ensureColumn(db: SqliteDb, table: string, column: string, definition: string): void {
|
||||
const columns = db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name);
|
||||
if (!columns.includes(column)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
||||
}
|
||||
|
||||
function tableExists(db: SqliteDb, table: string): boolean {
|
||||
return Boolean(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(table));
|
||||
}
|
||||
|
||||
function migrateExistingColumns(db: SqliteDb): void {
|
||||
if (tableExists(db, 'sessions')) ensureColumn(db, 'sessions', 'source', "TEXT DEFAULT 'claude'");
|
||||
if (tableExists(db, 'messages')) {
|
||||
ensureColumn(db, 'messages', 'content_type', 'TEXT');
|
||||
ensureColumn(db, 'messages', 'is_meta', 'INTEGER DEFAULT 0');
|
||||
ensureColumn(db, 'messages', 'source', "TEXT DEFAULT 'claude'");
|
||||
}
|
||||
if (tableExists(db, 'memories')) {
|
||||
ensureColumn(db, 'memories', 'anchors', 'TEXT');
|
||||
ensureColumn(db, 'memories', 'deleted_at', 'TEXT');
|
||||
ensureColumn(db, 'memories', 'deleted_reason', 'TEXT');
|
||||
}
|
||||
}
|
||||
|
||||
function migrateDb(db: SqliteDb): void {
|
||||
migrateExistingColumns(db);
|
||||
}
|
||||
|
||||
function rebuildMemoryFts(db: SqliteDb): void {
|
||||
db.exec("INSERT INTO memories_fts(memories_fts) VALUES('rebuild')");
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
// Passive-pull indexing orchestration for the Core package.
|
||||
import { DB_PATH, openDb, openReadDb, openWriterLeaseDb, rebuildMemoryFts } from './db.ts';
|
||||
import {
|
||||
CLAUDE_DIR, CODEX_DIR, PROJECTS_DIR, fs, path, isDir, readLines,
|
||||
inferProjectPath, codexDbId,
|
||||
} from './parsing.ts';
|
||||
import { fs, inferProjectPath } from './parsing.ts';
|
||||
import {
|
||||
createProviderIndexPlan,
|
||||
indexProviderPlan,
|
||||
@@ -15,10 +12,6 @@ import { runRetryableWriteTransaction, isBeginBusyFailure, hasUnusableTransactio
|
||||
import { createBuiltinProviderRegistry } from './providers/builtins.ts';
|
||||
import type { NodeSqliteDb, SqliteRow } from './sqlite-types.ts';
|
||||
|
||||
const HISTORY_PATH = path.join(CLAUDE_DIR, 'history.jsonl');
|
||||
|
||||
type JsonRecord = Record<string, any>;
|
||||
|
||||
interface SkippedFile {
|
||||
path: string;
|
||||
error: string;
|
||||
@@ -35,23 +28,6 @@ function errorMessage(error: unknown): string {
|
||||
}
|
||||
|
||||
|
||||
function indexCodexSessionIndex(db: NodeSqliteDb): void {
|
||||
const indexPath = path.join(CODEX_DIR, 'session_index.jsonl');
|
||||
if (!fs.existsSync(indexPath)) return;
|
||||
readLines(indexPath, (line) => {
|
||||
let item: JsonRecord;
|
||||
try {
|
||||
item = JSON.parse(line);
|
||||
} catch (e) {
|
||||
process.stderr.write(`Warning: malformed Codex session index line: ${errorMessage(e)}\n`);
|
||||
return;
|
||||
}
|
||||
if (!item.id || !item.thread_name) return;
|
||||
db.prepare('UPDATE sessions SET title=COALESCE(title, ?), ended_at=COALESCE(ended_at, ?) WHERE id=? AND source=?')
|
||||
.run(item.thread_name, item.updated_at || null, codexDbId(item.id), 'codex');
|
||||
});
|
||||
}
|
||||
|
||||
function refreshSessionProjectPaths(db: NodeSqliteDb): void {
|
||||
const sessions = db.prepare('SELECT id, project FROM sessions').all();
|
||||
const cwdStmt = db.prepare(`
|
||||
@@ -68,61 +44,6 @@ function refreshSessionProjectPaths(db: NodeSqliteDb): void {
|
||||
}
|
||||
}
|
||||
|
||||
function indexWorkflows(db: NodeSqliteDb): void {
|
||||
if (!fs.existsSync(PROJECTS_DIR)) return;
|
||||
let projects;
|
||||
try { projects = fs.readdirSync(PROJECTS_DIR); } catch { return; }
|
||||
for (const proj of projects) {
|
||||
const pp = path.join(PROJECTS_DIR, proj);
|
||||
if (!isDir(pp)) continue;
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(pp); } catch { continue; }
|
||||
for (const sd of entries) {
|
||||
const wd = path.join(pp, sd, 'workflows');
|
||||
if (!isDir(wd)) continue;
|
||||
let wfFiles;
|
||||
try { wfFiles = fs.readdirSync(wd); } catch { continue; }
|
||||
for (const f of wfFiles) {
|
||||
if (!f.endsWith('.json')) continue;
|
||||
let wf: JsonRecord;
|
||||
try {
|
||||
wf = JSON.parse(fs.readFileSync(path.join(wd, f), 'utf8'));
|
||||
} catch (e) {
|
||||
process.stderr.write(`Warning: failed to read workflow ${f}: ${errorMessage(e)}\n`);
|
||||
continue;
|
||||
}
|
||||
if (!wf.runId) continue;
|
||||
const ac = db.prepare('SELECT COUNT(*) as c FROM workflow_agents WHERE run_id=?').get(wf.runId);
|
||||
db.prepare('INSERT OR REPLACE INTO workflows (run_id,session_id,task_id,script,result_json,timestamp,agent_count,duration_ms,total_tokens,status,workflow_name) VALUES(?,?,?,?,?,?,?,?,?,?,?)').run(
|
||||
wf.runId, sd, wf.taskId||null, wf.script||null,
|
||||
wf.result ? JSON.stringify(wf.result) : null, wf.timestamp||null, ac?.c||0,
|
||||
wf.durationMs||null, wf.totalTokens||null, wf.status||null, wf.workflowName||null);
|
||||
const progress = wf.workflowProgress || [];
|
||||
for (const item of progress) {
|
||||
if (item.type !== 'workflow_agent' || !item.agentId) continue;
|
||||
db.prepare('UPDATE workflow_agents SET phase=?, label=?, model=?, state=?, duration_ms=?, tokens=?, tool_calls=? WHERE agent_id=?').run(
|
||||
item.phaseTitle||null, item.label||null, item.model||null, item.state||null,
|
||||
item.durationMs||null, item.tokens||null, item.toolCalls||null, 'agent-' + item.agentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function indexHistory(db: NodeSqliteDb): void {
|
||||
if (!fs.existsSync(HISTORY_PATH)) return;
|
||||
readLines(HISTORY_PATH, (line) => {
|
||||
let item: JsonRecord;
|
||||
try {
|
||||
item = JSON.parse(line);
|
||||
} catch (e) {
|
||||
process.stderr.write(`Warning: malformed history line: ${errorMessage(e)}\n`);
|
||||
return;
|
||||
}
|
||||
if (item.sessionId && item.title) db.prepare('UPDATE sessions SET title=? WHERE id=? AND title IS NULL').run(item.title, item.sessionId);
|
||||
});
|
||||
}
|
||||
|
||||
const BUILD_DEBOUNCE_MS = 30000;
|
||||
const APP_HEARTBEAT_FRESH_MS = 60000;
|
||||
|
||||
@@ -221,10 +142,7 @@ function buildIndex({ force = false }: { force?: boolean } = {}) {
|
||||
// the build (a half-finalized index would be inconsistent).
|
||||
try {
|
||||
runRetryableWriteTransaction(txDb, () => {
|
||||
indexWorkflows(db);
|
||||
refreshSessionProjectPaths(db);
|
||||
indexHistory(db);
|
||||
indexCodexSessionIndex(db);
|
||||
db.exec("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')");
|
||||
rebuildMemoryFts(db);
|
||||
db.prepare("INSERT OR REPLACE INTO index_state (jsonl_path, mtime, lines_processed) VALUES ('__last_build__', ?, 0)").run(Date.now());
|
||||
|
||||
@@ -85,6 +85,7 @@ function extractContentType(content: JsonValue): string {
|
||||
}
|
||||
|
||||
const COMMAND_ENVELOPE_RE = /^\s*(<command-name>[^<]+<\/command-name>|<(?:task-notification|system-reminder)\b|<local-command(?:\b|-))/;
|
||||
const SKILL_INSTRUCTIONS_RE = /^\s*Base directory for this skill(?:\s*:|\s*\r?\n)/;
|
||||
|
||||
function extractMessageIsMeta(record: JsonRecord, text: string | null = extractText(record?.message?.content)): 0 | 1 {
|
||||
const msg = record?.message || {};
|
||||
@@ -92,6 +93,10 @@ function extractMessageIsMeta(record: JsonRecord, text: string | null = extractT
|
||||
return typeof text === 'string' && COMMAND_ENVELOPE_RE.test(text) ? 1 : 0;
|
||||
}
|
||||
|
||||
function isSkillInstructions(text: unknown): boolean {
|
||||
return typeof text === 'string' && SKILL_INSTRUCTIONS_RE.test(text);
|
||||
}
|
||||
|
||||
function filePath(name: string, input: JsonRecord | null | undefined): string | null {
|
||||
if (!input) return null;
|
||||
return ['Read', 'Edit', 'Write', 'NotebookEdit'].includes(name) ? (input.file_path || null) : null;
|
||||
@@ -226,9 +231,9 @@ function codexLineUuid(threadId: unknown, lineNum: number): string {
|
||||
return `codex:${codexRawId(threadId)}:${String(lineNum).padStart(6, '0')}`;
|
||||
}
|
||||
|
||||
function codexCallId(callId: unknown): string | null {
|
||||
if (!callId) return null;
|
||||
return `codex:${String(callId).replace(/^codex:/, '')}`;
|
||||
function codexCallId(threadId: unknown, callId: unknown): string | null {
|
||||
if (!threadId || !callId) return null;
|
||||
return `codex:${codexRawId(threadId)}:${String(callId).replace(/^codex:/, '')}`;
|
||||
}
|
||||
|
||||
function codexParentThreadId(meta: JsonRecord): string | null {
|
||||
@@ -313,7 +318,22 @@ function codexEventText(payload: JsonRecord): string | null {
|
||||
function codexMessagePayloadText(payload: JsonRecord): string | null {
|
||||
if (!Array.isArray(payload?.content)) return null;
|
||||
const parts: string[] = [];
|
||||
for (const block of payload.content) {
|
||||
for (let index = 0; index < payload.content.length; index++) {
|
||||
const block = payload.content[index];
|
||||
const image = payload.content[index + 1];
|
||||
const close = payload.content[index + 2];
|
||||
if (
|
||||
block?.type === 'input_text'
|
||||
&& typeof block.text === 'string'
|
||||
&& block.text.trim() === '<image>'
|
||||
&& image?.type === 'input_image'
|
||||
&& close?.type === 'input_text'
|
||||
&& typeof close.text === 'string'
|
||||
&& close.text.trim() === '</image>'
|
||||
) {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (typeof block?.text === 'string') parts.push(block.text);
|
||||
}
|
||||
return parts.length ? parts.join('\n') : null;
|
||||
@@ -340,7 +360,7 @@ function codexToolOutput(payload: JsonRecord): string | null {
|
||||
|
||||
export {
|
||||
fs, path, os, CLAUDE_DIR, CODEX_DIR, PROJECTS_DIR, CODEX_SESSIONS_DIR, TEXT_LIMIT,
|
||||
trunc, truncJson, extractText, extractContentType, extractMessageIsMeta, filePath, isDir, readLines,
|
||||
trunc, truncJson, extractText, extractContentType, extractMessageIsMeta, isSkillInstructions, filePath, isDir, readLines,
|
||||
legacyProjectPathFromSlug, normalizeObservedCwd, projectSlugFromPath, inferProjectPath,
|
||||
discoverJsonlFiles, discoverCodexJsonlFiles,
|
||||
codexDbId, codexRawId, codexLineUuid, codexCallId, codexParentThreadId, codexIsGuardianThread,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Shared Core persist layer (see docs/adr/0001).
|
||||
//
|
||||
// Provider-agnostic and binding-agnostic: it consumes the IndexRecord stream
|
||||
// Provider-agnostic and binding-agnostic: it consumes the TranscriptRecord stream
|
||||
// from any adapter's parse() and writes rows into the injected database handle
|
||||
// (node:sqlite for the CLI, better-sqlite3 for the app — they share the
|
||||
// prepare/run/get API). It is the ONLY layer that touches the database and the
|
||||
@@ -12,7 +12,7 @@
|
||||
// turn-duration is a targeted UPDATE; delete-session cascades. The generator's
|
||||
// return value is the new cursor, persisted verbatim into index_state.
|
||||
|
||||
import type { Cursor, IndexRecord, IndexUnit } from './providers/types.ts';
|
||||
import type { Cursor, TranscriptRecord, IndexUnit } from './providers/types.ts';
|
||||
import type { SqliteDb } from './sqlite-types.ts';
|
||||
|
||||
const minStr = (a: string | null, b: string | null) => (a == null ? b : b == null ? a : a < b ? a : b);
|
||||
@@ -21,16 +21,17 @@ const maxStr = (a: string | null, b: string | null) => (a == null ? b : b == nul
|
||||
function statements(db: SqliteDb) {
|
||||
return {
|
||||
msg: db.prepare(`
|
||||
INSERT INTO messages (uuid,session_id,type,parent_uuid,timestamp,role,text,content_type,is_meta,model,is_sidechain,agent_id,input_tokens,output_tokens,cwd,skill,source)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
INSERT INTO messages (uuid,session_id,type,parent_uuid,timestamp,role,text,content_type,is_meta,visibility,model,is_sidechain,agent_id,input_tokens,output_tokens,cwd,skill,source)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(uuid) DO UPDATE SET
|
||||
session_id=excluded.session_id, type=excluded.type, parent_uuid=excluded.parent_uuid,
|
||||
timestamp=excluded.timestamp, role=excluded.role, text=excluded.text,
|
||||
content_type=excluded.content_type, is_meta=excluded.is_meta, model=excluded.model,
|
||||
content_type=excluded.content_type, is_meta=excluded.is_meta,
|
||||
visibility=excluded.visibility, model=excluded.model,
|
||||
is_sidechain=excluded.is_sidechain, agent_id=excluded.agent_id,
|
||||
input_tokens=excluded.input_tokens, output_tokens=excluded.output_tokens,
|
||||
cwd=excluded.cwd, skill=excluded.skill, source=excluded.source`),
|
||||
tc: db.prepare('INSERT OR REPLACE INTO tool_calls (id,message_uuid,session_id,name,input_json,file_path) VALUES (?,?,?,?,?,?)'),
|
||||
tc: db.prepare('INSERT OR REPLACE INTO tool_calls (id,message_uuid,session_id,name,presentation,input_json,file_path) VALUES (?,?,?,?,?,?,?)'),
|
||||
tr: db.prepare('INSERT OR REPLACE INTO tool_results (tool_use_id,message_uuid,session_id,content,file_path,is_error) VALUES (?,?,?,?,?,?)'),
|
||||
sum: db.prepare('INSERT OR REPLACE INTO summaries (id,session_id,timestamp,source,content) VALUES (?,?,?,?,?)'),
|
||||
ses: db.prepare('INSERT OR REPLACE INTO sessions (id,title,project,project_path,started_at,ended_at,git_branch,version,message_count,jsonl_path,source) VALUES (?,?,?,?,?,?,?,?,?,?,?)'),
|
||||
@@ -44,6 +45,25 @@ function statements(db: SqliteDb) {
|
||||
description=COALESCE(excluded.description, subagents.description),
|
||||
duration_ms=COALESCE(excluded.duration_ms, subagents.duration_ms),
|
||||
total_tokens=COALESCE(excluded.total_tokens, subagents.total_tokens)`),
|
||||
wf: db.prepare(`
|
||||
INSERT OR REPLACE INTO workflows
|
||||
(run_id,session_id,parent_tool_use_id,task_id,script,result_json,timestamp,agent_count,duration_ms,total_tokens,status,workflow_name)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`),
|
||||
wa: db.prepare(`
|
||||
INSERT INTO workflow_agents
|
||||
(agent_id,run_id,session_id,agent_type,description,phase,label,model,state,duration_ms,tokens,tool_calls)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(agent_id) DO UPDATE SET
|
||||
run_id=excluded.run_id, session_id=excluded.session_id,
|
||||
agent_type=COALESCE(excluded.agent_type, workflow_agents.agent_type),
|
||||
description=COALESCE(excluded.description, workflow_agents.description),
|
||||
phase=COALESCE(excluded.phase, workflow_agents.phase),
|
||||
label=COALESCE(excluded.label, workflow_agents.label),
|
||||
model=COALESCE(excluded.model, workflow_agents.model),
|
||||
state=COALESCE(excluded.state, workflow_agents.state),
|
||||
duration_ms=COALESCE(excluded.duration_ms, workflow_agents.duration_ms),
|
||||
tokens=COALESCE(excluded.tokens, workflow_agents.tokens),
|
||||
tool_calls=COALESCE(excluded.tool_calls, workflow_agents.tool_calls)`),
|
||||
turn: db.prepare('UPDATE messages SET turn_duration_ms=? WHERE uuid=?'),
|
||||
idx: db.prepare('INSERT OR REPLACE INTO index_state (jsonl_path,mtime,lines_processed) VALUES (?,?,?)'),
|
||||
getSession: db.prepare('SELECT * FROM sessions WHERE id=?'),
|
||||
@@ -56,22 +76,24 @@ function deleteSession(db: SqliteDb, sessionId: string) {
|
||||
db.prepare('DELETE FROM tool_calls WHERE session_id=? OR message_uuid IN (SELECT uuid FROM messages WHERE session_id=? OR agent_id=?)').run(sessionId, sessionId, sessionId);
|
||||
db.prepare('DELETE FROM messages WHERE session_id=? OR agent_id=?').run(sessionId, sessionId);
|
||||
db.prepare('DELETE FROM subagents WHERE agent_id=? OR session_id=?').run(sessionId, sessionId);
|
||||
db.prepare('DELETE FROM workflow_agents WHERE session_id=?').run(sessionId);
|
||||
db.prepare('DELETE FROM workflows WHERE session_id=?').run(sessionId);
|
||||
db.prepare('DELETE FROM summaries WHERE session_id=?').run(sessionId);
|
||||
db.prepare('DELETE FROM sessions WHERE id=?').run(sessionId);
|
||||
}
|
||||
|
||||
// Consume one unit's record stream into the database and return the new cursor
|
||||
// (also written to index_state). `db` is any SQLite handle sharing prepare/run.
|
||||
export function persist(db: SqliteDb, unit: IndexUnit, gen: Generator<IndexRecord, Cursor>): Cursor {
|
||||
export function persist(db: SqliteDb, unit: IndexUnit, gen: Generator<TranscriptRecord, Cursor>): Cursor {
|
||||
const st = statements(db);
|
||||
|
||||
const write = (r: IndexRecord) => {
|
||||
const write = (r: TranscriptRecord) => {
|
||||
switch (r.kind) {
|
||||
case 'message':
|
||||
st.msg.run(r.uuid, r.session_id, r.type, r.parent_uuid, r.timestamp, r.role, r.text, r.content_type, r.is_meta, r.model, r.is_sidechain, r.agent_id, r.input_tokens, r.output_tokens, r.cwd, r.skill, r.source);
|
||||
st.msg.run(r.uuid, r.session_id, r.type, r.parent_uuid, r.timestamp, r.role, r.text, r.content_type, r.is_meta, r.visibility, r.model, r.is_sidechain, r.agent_id, r.input_tokens, r.output_tokens, r.cwd, r.skill, r.source);
|
||||
break;
|
||||
case 'tool_call':
|
||||
st.tc.run(r.id, r.message_uuid, r.session_id, r.name, r.input_json, r.file_path);
|
||||
st.tc.run(r.id, r.message_uuid, r.session_id, r.name, r.presentation, r.input_json, r.file_path);
|
||||
break;
|
||||
case 'tool_result':
|
||||
st.tr.run(r.tool_use_id, r.message_uuid, r.session_id, r.content, r.file_path, r.is_error);
|
||||
@@ -82,6 +104,12 @@ export function persist(db: SqliteDb, unit: IndexUnit, gen: Generator<IndexRecor
|
||||
case 'subagent':
|
||||
st.sub.run(r.agent_id, r.session_id, r.parent_tool_use_id ?? null, r.agent_type ?? null, r.description ?? null, r.duration_ms ?? null, r.total_tokens ?? null);
|
||||
break;
|
||||
case 'workflow':
|
||||
st.wf.run(r.run_id, r.session_id, r.parent_tool_use_id ?? null, r.task_id, r.script, r.result_json, r.timestamp, r.agent_count, r.duration_ms, r.total_tokens, r.status, r.workflow_name);
|
||||
break;
|
||||
case 'workflow_agent':
|
||||
st.wa.run(r.agent_id, r.run_id, r.session_id, r.agent_type ?? null, r.description ?? null, r.phase ?? null, r.label ?? null, r.model ?? null, r.state ?? null, r.duration_ms ?? null, r.tokens ?? null, r.tool_calls ?? null);
|
||||
break;
|
||||
case 'message-turn-duration':
|
||||
st.turn.run(r.turn_duration_ms, r.uuid);
|
||||
break;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//
|
||||
// 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
|
||||
// indexJsonl exactly, but yields canonical TranscriptRecords 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.
|
||||
|
||||
@@ -13,14 +13,14 @@ const require = createRequire(import.meta.url);
|
||||
const fs = require('node:fs');
|
||||
|
||||
import {
|
||||
extractText, extractContentType, extractMessageIsMeta,
|
||||
filePath, trunc, truncJson, readLines, discoverJsonlFiles,
|
||||
extractText, extractContentType, extractMessageIsMeta, isSkillInstructions,
|
||||
filePath, trunc, truncJson, readLines, discoverJsonlFiles, isDir,
|
||||
} from '../parsing.ts';
|
||||
|
||||
import type {
|
||||
Cursor,
|
||||
DiscoverContext,
|
||||
IndexRecord,
|
||||
TranscriptRecord,
|
||||
IndexUnit,
|
||||
ProviderAdapter,
|
||||
RawLookup,
|
||||
@@ -37,7 +37,12 @@ function cursorToSkip(cursor: Cursor): number {
|
||||
}
|
||||
|
||||
export const name = 'claude';
|
||||
export const CLAUDE_INPUT_TOKEN_SEMANTICS_MARKER = '__claude_input_tokens_include_cache_v1__';
|
||||
export const CLAUDE_CANONICAL_TRANSCRIPT_MARKER = '__claude_canonical_transcript_v2__';
|
||||
|
||||
interface ClaudeWorkflowUnitMeta {
|
||||
readonly kind: 'workflow';
|
||||
readonly mainTranscriptPath: string;
|
||||
}
|
||||
|
||||
function totalInputTokens(usage: Record<string, unknown>): number | null {
|
||||
const fields = [
|
||||
@@ -58,9 +63,25 @@ function totalInputTokens(usage: Record<string, unknown>): number | null {
|
||||
|
||||
function discoverAt(rootDir: string, ctx: DiscoverContext): IndexUnit[] {
|
||||
const projectsDir = join(rootDir, 'projects');
|
||||
const historyPath = normalize(join(rootDir, 'history.jsonl'));
|
||||
const historyTitles = new Map<string, string>();
|
||||
if (fs.existsSync(historyPath)) {
|
||||
readLines(historyPath, (line: string) => {
|
||||
try {
|
||||
const item = JSON.parse(line);
|
||||
if (item?.sessionId && item?.title) historyTitles.set(item.sessionId, item.title);
|
||||
} catch { /* malformed history entry */ }
|
||||
});
|
||||
}
|
||||
const changedTranscriptPaths = new Set<string>();
|
||||
const changedWorkflowPaths = new Set<string>();
|
||||
const forcedPaths = new Set<string>();
|
||||
let historyChanged = false;
|
||||
for (const changedPath of ctx.changedPaths ?? []) {
|
||||
const rootRelative = isAbsolute(changedPath)
|
||||
? normalize(changedPath)
|
||||
: normalize(join(rootDir, changedPath));
|
||||
if (rootRelative === historyPath) historyChanged = true;
|
||||
const absolute = isAbsolute(changedPath)
|
||||
? normalize(changedPath)
|
||||
: normalize(join(projectsDir, changedPath));
|
||||
@@ -72,13 +93,16 @@ function discoverAt(rootDir: string, ctx: DiscoverContext): IndexUnit[] {
|
||||
forcedPaths.add(transcript);
|
||||
} else if (absolute.toLowerCase().endsWith('.jsonl')) {
|
||||
changedTranscriptPaths.add(absolute);
|
||||
} else if (absolute.toLowerCase().endsWith('.json')) {
|
||||
changedWorkflowPaths.add(absolute);
|
||||
}
|
||||
}
|
||||
return discoverJsonlFiles(projectsDir).filter((file) => {
|
||||
const transcriptUnits = discoverJsonlFiles(projectsDir).filter((file) => {
|
||||
const normalizedPath = normalize(file.path);
|
||||
if (ctx.changedPaths !== undefined && !changedTranscriptPaths.has(normalizedPath)) return false;
|
||||
if (ctx.changedPaths !== undefined && !historyChanged && !changedTranscriptPaths.has(normalizedPath)) return false;
|
||||
const cursor = ctx.lastCursor(file.path);
|
||||
return forcedPaths.has(normalizedPath)
|
||||
return historyChanged
|
||||
|| forcedPaths.has(normalizedPath)
|
||||
|| cursor === null
|
||||
|| Number(cursor.split(':')[0]) < fs.statSync(file.path).mtimeMs;
|
||||
}).map((f: any) => ({
|
||||
@@ -87,25 +111,155 @@ function discoverAt(rootDir: string, ctx: DiscoverContext): IndexUnit[] {
|
||||
project: f.project,
|
||||
isSubagent: f.isSubagent,
|
||||
agentId: f.agentId,
|
||||
meta: f.workflowRunId ? { workflowRunId: f.workflowRunId } : undefined,
|
||||
meta: {
|
||||
...(f.workflowRunId ? { workflowRunId: f.workflowRunId } : {}),
|
||||
...(historyTitles.has(f.sessionId) ? { historyTitle: historyTitles.get(f.sessionId) } : {}),
|
||||
},
|
||||
}));
|
||||
|
||||
const workflowUnits: IndexUnit[] = [];
|
||||
if (!fs.existsSync(projectsDir)) return transcriptUnits;
|
||||
let projects: string[];
|
||||
try { projects = fs.readdirSync(projectsDir); } catch { return transcriptUnits; }
|
||||
for (const project of projects) {
|
||||
const projectPath = join(projectsDir, project);
|
||||
if (!isDir(projectPath)) continue;
|
||||
let sessionIds: string[];
|
||||
try { sessionIds = fs.readdirSync(projectPath); } catch { continue; }
|
||||
for (const sessionId of sessionIds) {
|
||||
const workflowDir = join(projectPath, sessionId, 'workflows');
|
||||
if (!isDir(workflowDir)) continue;
|
||||
const mainTranscriptPath = join(projectPath, `${sessionId}.jsonl`);
|
||||
let files: string[];
|
||||
try { files = fs.readdirSync(workflowDir); } catch { continue; }
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.json')) continue;
|
||||
const workflowPath = join(workflowDir, file);
|
||||
const normalizedPath = normalize(workflowPath);
|
||||
const relationshipChanged = changedTranscriptPaths.has(normalize(mainTranscriptPath));
|
||||
if (
|
||||
ctx.changedPaths !== undefined
|
||||
&& !changedWorkflowPaths.has(normalizedPath)
|
||||
&& !relationshipChanged
|
||||
) continue;
|
||||
const mtime = fs.statSync(workflowPath).mtimeMs;
|
||||
const cursor = ctx.lastCursor(workflowPath);
|
||||
if (!relationshipChanged && cursor !== null && Number(cursor.split(':')[0]) >= mtime) continue;
|
||||
workflowUnits.push({
|
||||
key: workflowPath,
|
||||
sessionId,
|
||||
project,
|
||||
meta: { kind: 'workflow', mainTranscriptPath } satisfies ClaudeWorkflowUnitMeta,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...transcriptUnits, ...workflowUnits];
|
||||
}
|
||||
|
||||
export function discover(ctx: DiscoverContext): IndexUnit[] {
|
||||
return discoverAt(join(homedir(), '.claude'), ctx);
|
||||
}
|
||||
|
||||
export function* parse(unit: IndexUnit, cursor: Cursor): Generator<IndexRecord, Cursor> {
|
||||
function toolResultText(content: unknown): string {
|
||||
if (typeof content === 'string') return content;
|
||||
if (!Array.isArray(content)) return '';
|
||||
return content.map((part) => typeof part?.text === 'string' ? part.text : '').join('\n');
|
||||
}
|
||||
|
||||
function workflowParentToolUseId(
|
||||
transcriptPath: string,
|
||||
runId: string,
|
||||
workflowName: string | null,
|
||||
): string | null {
|
||||
if (!fs.existsSync(transcriptPath)) return null;
|
||||
const workflowToolIds = new Set<string>();
|
||||
let parentToolUseId: string | null = null;
|
||||
readLines(transcriptPath, (line: string) => {
|
||||
let record: any;
|
||||
try { record = JSON.parse(line); } catch { return; }
|
||||
const content = record?.message?.content;
|
||||
if (!Array.isArray(content)) return;
|
||||
if (record.type === 'assistant') {
|
||||
for (const block of content) {
|
||||
if (block?.type === 'tool_use' && block?.name === 'Workflow' && typeof block.id === 'string') {
|
||||
workflowToolIds.add(block.id);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (record.type !== 'user') return;
|
||||
for (const block of content) {
|
||||
if (block?.type !== 'tool_result' || !workflowToolIds.has(block.tool_use_id)) continue;
|
||||
const text = toolResultText(block.content);
|
||||
if (!text.includes(runId) && !(workflowName && text.includes(workflowName))) continue;
|
||||
parentToolUseId = block.tool_use_id;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return parentToolUseId;
|
||||
}
|
||||
|
||||
function* parseWorkflow(unit: IndexUnit): Generator<TranscriptRecord, Cursor> {
|
||||
const mtime = fs.statSync(unit.key).mtimeMs;
|
||||
const outCursor = `${mtime}:1`;
|
||||
let workflow: any;
|
||||
try { workflow = JSON.parse(fs.readFileSync(unit.key, 'utf8')); } catch { return outCursor; }
|
||||
if (!workflow?.runId) return outCursor;
|
||||
const meta = unit.meta as ClaudeWorkflowUnitMeta;
|
||||
const progress = Array.isArray(workflow.workflowProgress) ? workflow.workflowProgress : [];
|
||||
const agents = progress.filter((item: any) => item?.type === 'workflow_agent' && item.agentId);
|
||||
yield {
|
||||
kind: 'workflow',
|
||||
run_id: workflow.runId,
|
||||
session_id: unit.sessionId,
|
||||
parent_tool_use_id: workflowParentToolUseId(
|
||||
meta.mainTranscriptPath,
|
||||
workflow.runId,
|
||||
workflow.workflowName || null,
|
||||
),
|
||||
task_id: workflow.taskId || null,
|
||||
script: workflow.script || null,
|
||||
result_json: workflow.result ? JSON.stringify(workflow.result) : null,
|
||||
timestamp: workflow.timestamp || null,
|
||||
agent_count: agents.length,
|
||||
duration_ms: workflow.durationMs || null,
|
||||
total_tokens: workflow.totalTokens || null,
|
||||
status: workflow.status || null,
|
||||
workflow_name: workflow.workflowName || null,
|
||||
};
|
||||
for (const item of agents) {
|
||||
yield {
|
||||
kind: 'workflow_agent',
|
||||
agent_id: `agent-${item.agentId}`,
|
||||
run_id: workflow.runId,
|
||||
session_id: unit.sessionId,
|
||||
phase: item.phaseTitle || null,
|
||||
label: item.label || null,
|
||||
model: item.model || null,
|
||||
state: item.state || null,
|
||||
duration_ms: item.durationMs || null,
|
||||
tokens: item.tokens || null,
|
||||
tool_calls: item.toolCalls || null,
|
||||
};
|
||||
}
|
||||
return outCursor;
|
||||
}
|
||||
|
||||
export function* parse(unit: IndexUnit, cursor: Cursor): Generator<TranscriptRecord, Cursor> {
|
||||
if ((unit.meta as ClaudeWorkflowUnitMeta | undefined)?.kind === 'workflow') {
|
||||
return yield* parseWorkflow(unit);
|
||||
}
|
||||
const skip = cursorToSkip(cursor);
|
||||
const mtime = fs.statSync(unit.key).mtimeMs;
|
||||
const isSubagent = unit.isSubagent === true;
|
||||
const records: IndexRecord[] = [];
|
||||
const records: TranscriptRecord[] = [];
|
||||
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,
|
||||
title: ((unit.meta as { historyTitle?: string } | undefined)?.historyTitle ?? null) as string | null,
|
||||
n: 0,
|
||||
};
|
||||
const subagentStats = {
|
||||
@@ -149,15 +303,17 @@ export function* parse(unit: IndexUnit, cursor: Cursor): Generator<IndexRecord,
|
||||
sm.n++;
|
||||
|
||||
const text = extractText(msg.content);
|
||||
const contentType = extractContentType(msg.content);
|
||||
const rawContentType = extractContentType(msg.content);
|
||||
const isMeta = extractMessageIsMeta(obj, text);
|
||||
const contentType = isMeta && isSkillInstructions(text) ? 'skill_instructions' : rawContentType;
|
||||
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,
|
||||
text, content_type: contentType, is_meta: (isMeta ? 1 : 0), visibility: 'visible',
|
||||
model: msg.model || null,
|
||||
is_sidechain: obj.isSidechain ? 1 : 0, agent_id: aid,
|
||||
input_tokens: totalInputTokens(usage), output_tokens: usage.output_tokens || null,
|
||||
cwd: obj.cwd || null, skill: obj.attributionSkill || null, source: 'claude',
|
||||
@@ -167,7 +323,7 @@ export function* parse(unit: IndexUnit, cursor: Cursor): Generator<IndexRecord,
|
||||
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) });
|
||||
records.push({ kind: 'tool_call', id: b.id, message_uuid: obj.uuid, session_id: sid, name: b.name, presentation: b.name === 'Skill' ? 'skill' : 'default', input_json: truncJson(b.input || {}) as string, file_path: filePath(b.name, b.input) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,8 +426,11 @@ export function createClaudeProvider({ rootDir = join(homedir(), '.claude') }: {
|
||||
return {
|
||||
name,
|
||||
descriptor: { id: name, name: 'Claude Code', vendor: 'Anthropic', defaultRoot: rootDir, color: '#d97757' },
|
||||
indexVersionMarker: CLAUDE_INPUT_TOKEN_SEMANTICS_MARKER,
|
||||
watchRoots: (configuredRoot) => [join(configuredRoot, 'projects')],
|
||||
indexVersionMarker: CLAUDE_CANONICAL_TRANSCRIPT_MARKER,
|
||||
watchRoots: (configuredRoot) => [
|
||||
join(configuredRoot, 'projects'),
|
||||
join(configuredRoot, 'history.jsonl'),
|
||||
],
|
||||
discover: (ctx) => discoverAt(rootDir, ctx),
|
||||
parse,
|
||||
raw: rawClaude,
|
||||
|
||||
@@ -21,13 +21,14 @@ import {
|
||||
codexIsGuardianThread, codexAgentNickname, codexAgentRole, codexUsage,
|
||||
codexEventText, codexMessagePayloadText, codexVisibleMessageKey,
|
||||
codexToolInput, codexToolOutput,
|
||||
extractMessageIsMeta, isSkillInstructions,
|
||||
readCodexGuardianThreadInfo,
|
||||
} from '../parsing.ts';
|
||||
|
||||
import type {
|
||||
Cursor,
|
||||
DiscoverContext,
|
||||
IndexRecord,
|
||||
TranscriptRecord,
|
||||
IndexUnit,
|
||||
MessageRecord,
|
||||
ProviderAdapter,
|
||||
@@ -36,11 +37,40 @@ import type {
|
||||
} from './types.ts';
|
||||
|
||||
export const name = 'codex';
|
||||
const CODEX_CANONICAL_TRANSCRIPT_MARKER = '__codex_canonical_transcript_v2__';
|
||||
|
||||
const HIDDEN_CONTEXT_ENVELOPE_RE = /^\s*<(environment_context|codex_internal_context)\b[^>]*>[\s\S]*<\/\1>\s*$/;
|
||||
|
||||
function messageVisibility(role: string, text: string | null): 'visible' | 'hidden' {
|
||||
return role === 'user' && typeof text === 'string' && HIDDEN_CONTEXT_ENVELOPE_RE.test(text)
|
||||
? 'hidden'
|
||||
: 'visible';
|
||||
}
|
||||
|
||||
function discoverAt(rootDir: string, ctx: DiscoverContext): IndexUnit[] {
|
||||
const sessionsDir = join(rootDir, 'sessions');
|
||||
const sessionIndexPath = normalize(join(rootDir, 'session_index.jsonl'));
|
||||
const sessionIndex = new Map<string, { title: string; updatedAt: string | null }>();
|
||||
if (fs.existsSync(sessionIndexPath)) {
|
||||
readLines(sessionIndexPath, (line: string) => {
|
||||
try {
|
||||
const item = JSON.parse(line);
|
||||
if (item?.id && item?.thread_name) {
|
||||
sessionIndex.set(codexRawId(item.id) as string, {
|
||||
title: item.thread_name,
|
||||
updatedAt: item.updated_at || null,
|
||||
});
|
||||
}
|
||||
} catch { /* malformed session-index entry */ }
|
||||
});
|
||||
}
|
||||
const changedFiles = new Set<string>();
|
||||
let sessionIndexChanged = false;
|
||||
for (const changedPath of ctx.changedPaths ?? []) {
|
||||
const rootRelative = isAbsolute(changedPath)
|
||||
? normalize(changedPath)
|
||||
: normalize(join(rootDir, changedPath));
|
||||
if (rootRelative === sessionIndexPath) sessionIndexChanged = true;
|
||||
const absolute = isAbsolute(changedPath)
|
||||
? normalize(changedPath)
|
||||
: normalize(join(sessionsDir, changedPath));
|
||||
@@ -49,10 +79,10 @@ function discoverAt(rootDir: string, ctx: DiscoverContext): IndexUnit[] {
|
||||
if (absolute.toLowerCase().endsWith('.jsonl')) changedFiles.add(absolute);
|
||||
}
|
||||
return discoverCodexJsonlFiles(sessionsDir).flatMap((file) => {
|
||||
if (ctx.changedPaths !== undefined && !changedFiles.has(normalize(file.path))) return [];
|
||||
if (ctx.changedPaths !== undefined && !sessionIndexChanged && !changedFiles.has(normalize(file.path))) return [];
|
||||
const cursor = ctx.lastCursor(file.path);
|
||||
const guardian = readCodexGuardianThreadInfo(file.path);
|
||||
if (cursor !== null && Number(cursor.split(':')[0]) >= fs.statSync(file.path).mtimeMs && guardian === null) {
|
||||
if (!sessionIndexChanged && cursor !== null && Number(cursor.split(':')[0]) >= fs.statSync(file.path).mtimeMs && guardian === null) {
|
||||
return [];
|
||||
}
|
||||
let meta: any = null;
|
||||
@@ -67,10 +97,16 @@ function discoverAt(rootDir: string, ctx: DiscoverContext): IndexUnit[] {
|
||||
});
|
||||
const rawId = meta ? codexRawId(meta.id) : null;
|
||||
const parentId = meta ? codexParentThreadId(meta) : null;
|
||||
const indexed = rawId ? sessionIndex.get(rawId) : undefined;
|
||||
return [{
|
||||
key: file.path,
|
||||
sessionId: guardian === null ? codexDbId(parentId || rawId) ?? '' : '',
|
||||
meta: { source: 'codex', guardian: guardian !== null },
|
||||
meta: {
|
||||
source: 'codex',
|
||||
guardian: guardian !== null,
|
||||
indexedTitle: indexed?.title,
|
||||
indexedUpdatedAt: indexed?.updatedAt,
|
||||
},
|
||||
}];
|
||||
});
|
||||
}
|
||||
@@ -79,7 +115,7 @@ export function discover(ctx: DiscoverContext): IndexUnit[] {
|
||||
return discoverAt(join(homedir(), '.codex'), ctx);
|
||||
}
|
||||
|
||||
export function* parse(unit: IndexUnit, _cursor: Cursor): Generator<IndexRecord, Cursor> {
|
||||
export function* parse(unit: IndexUnit, _cursor: Cursor): Generator<TranscriptRecord, Cursor> {
|
||||
const mtime = fs.statSync(unit.key).mtimeMs;
|
||||
const records: { lineNum: number; obj: any }[] = [];
|
||||
let lineNum = 0;
|
||||
@@ -106,14 +142,19 @@ export function* parse(unit: IndexUnit, _cursor: Cursor): Generator<IndexRecord,
|
||||
const project = projectSlugFromPath(normalizeObservedCwd(meta.cwd));
|
||||
const lineUuid = (n: number): string => codexLineUuid(threadRawId, n) as string;
|
||||
|
||||
const out: IndexRecord[] = [];
|
||||
const out: TranscriptRecord[] = [];
|
||||
const msgByUuid = new Map<string, MessageRecord>();
|
||||
const indexedMeta = unit.meta as { indexedTitle?: string; indexedUpdatedAt?: string | null } | undefined;
|
||||
const initialTimestamp = (meta.timestamp || metaRecord.obj.timestamp || null) as string | null;
|
||||
const indexedUpdatedAt = indexedMeta?.indexedUpdatedAt ?? null;
|
||||
const sm = {
|
||||
started_at: (meta.timestamp || metaRecord.obj.timestamp || null) as string | null,
|
||||
ended_at: (meta.timestamp || metaRecord.obj.timestamp || null) as string | null,
|
||||
started_at: initialTimestamp,
|
||||
ended_at: indexedUpdatedAt && (!initialTimestamp || indexedUpdatedAt > initialTimestamp)
|
||||
? indexedUpdatedAt
|
||||
: initialTimestamp,
|
||||
git_branch: (meta.git?.branch || null) as string | null,
|
||||
version: (meta.cli_version || null) as string | null,
|
||||
title: null as string | null,
|
||||
title: indexedMeta?.indexedTitle ?? null,
|
||||
n: 0,
|
||||
lastMessageUuid: null as string | null,
|
||||
lastTextAssistantUuid: null as string | null,
|
||||
@@ -135,10 +176,14 @@ export function* parse(unit: IndexUnit, _cursor: Cursor): Generator<IndexRecord,
|
||||
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 visibility = messageVisibility(role, text);
|
||||
const skillInstructions = role === 'user' && isSkillInstructions(text);
|
||||
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,
|
||||
timestamp: timestamp || null, role, text: trunc(text),
|
||||
content_type: skillInstructions ? 'skill_instructions' : contentType,
|
||||
is_meta: visibility === 'hidden' || skillInstructions ? 1 : (isMeta || extractMessageIsMeta({}, text)), visibility,
|
||||
model: currentModel, is_sidechain: isSidechain, agent_id: agentId,
|
||||
input_tokens: null, output_tokens: null, cwd: currentCwd, skill: null, source: 'codex',
|
||||
};
|
||||
out.push(rec);
|
||||
@@ -191,13 +236,13 @@ export function* parse(unit: IndexUnit, _cursor: Cursor): Generator<IndexRecord,
|
||||
}
|
||||
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 toolId = codexCallId(threadRawId, 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 });
|
||||
out.push({ kind: 'tool_call', id: toolId, message_uuid: uuid, session_id: sessionId, name: 'Agent', presentation: 'default', 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;
|
||||
@@ -235,13 +280,13 @@ export function* parse(unit: IndexUnit, _cursor: Cursor): Generator<IndexRecord,
|
||||
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 });
|
||||
const toolId = codexCallId(threadRawId, payload.call_id) as string;
|
||||
out.push({ kind: 'tool_call', id: toolId, message_uuid: uuid, session_id: sessionId, name, presentation: name === 'Skill' ? 'skill' : 'default', 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;
|
||||
const toolId = codexCallId(threadRawId, 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 });
|
||||
}
|
||||
}
|
||||
@@ -309,8 +354,7 @@ function rawCodex(rootDir: string, input: RawLookup): RawRecord | null {
|
||||
? payload.text
|
||||
: null;
|
||||
} else if (obj?.type === 'response_item' && payload.type === 'message' && Array.isArray(payload.content)) {
|
||||
const parts = payload.content.map((part: any) => part?.text).filter((part: unknown) => typeof part === 'string');
|
||||
messageText = parts.length > 0 ? parts.join('\n') : null;
|
||||
messageText = codexMessagePayloadText(payload);
|
||||
}
|
||||
} catch { /* malformed source line */ }
|
||||
}
|
||||
@@ -323,6 +367,7 @@ export function createCodexProvider({ rootDir = join(homedir(), '.codex') }: { r
|
||||
return {
|
||||
name,
|
||||
descriptor: { id: name, name: 'Codex', vendor: 'OpenAI', defaultRoot: rootDir, color: '#10a37f' },
|
||||
indexVersionMarker: CODEX_CANONICAL_TRANSCRIPT_MARKER,
|
||||
watchRoots: (configuredRoot) => [join(configuredRoot, 'sessions'), join(configuredRoot, 'session_index.jsonl')],
|
||||
discover: (ctx) => discoverAt(rootDir, ctx),
|
||||
parse,
|
||||
|
||||
@@ -11,7 +11,7 @@ import { filePath, projectSlugFromPath, trunc, truncJson } from '../parsing.ts';
|
||||
import type {
|
||||
Cursor,
|
||||
DiscoverContext,
|
||||
IndexRecord,
|
||||
TranscriptRecord,
|
||||
IndexUnit,
|
||||
MessageRecord,
|
||||
ProviderAdapter,
|
||||
@@ -50,12 +50,13 @@ interface ProjectedSession {
|
||||
readonly toolResults: ToolResultRecord[];
|
||||
readonly summaries: SummaryRecord[];
|
||||
readonly subagents: SubagentRecord[];
|
||||
readonly durations: IndexRecord[];
|
||||
readonly durations: TranscriptRecord[];
|
||||
readonly mainMessageCount: number;
|
||||
readonly mainWirePath: string;
|
||||
}
|
||||
|
||||
const SOURCE = 'kimi';
|
||||
export const KIMI_CANONICAL_TRANSCRIPT_MARKER = '__kimi_canonical_transcript_v2__';
|
||||
|
||||
function defaultKimiRoot(): string {
|
||||
return process.env['KIMI_CODE_HOME'] ?? join(homedir(), '.kimi-code');
|
||||
@@ -203,6 +204,46 @@ function isRealUserMessage(message: JsonRecord): boolean {
|
||||
&& origin.trigger === 'user-slash';
|
||||
}
|
||||
|
||||
function slashCommandText(command: string, args: unknown): string {
|
||||
const trimmedArgs = typeof args === 'string' ? args.trim() : '';
|
||||
return trimmedArgs.length > 0 ? `${command} ${trimmedArgs}` : command;
|
||||
}
|
||||
|
||||
function userSlashCommandText(message: JsonRecord): string | null {
|
||||
const origin = message.origin as JsonRecord | undefined;
|
||||
if (message.role === 'user' && origin?.trigger === 'user-slash') {
|
||||
if (origin.kind === 'skill_activation' && typeof origin.skillName === 'string') {
|
||||
return slashCommandText(`/${origin.skillName}`, origin.skillArgs);
|
||||
}
|
||||
if (
|
||||
origin.kind === 'plugin_command'
|
||||
&& typeof origin.pluginId === 'string'
|
||||
&& typeof origin.commandName === 'string'
|
||||
) {
|
||||
return slashCommandText(`/${origin.pluginId}:${origin.commandName}`, origin.commandArgs);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function projectedMessageText(message: JsonRecord): string | null {
|
||||
const slashCommand = userSlashCommandText(message);
|
||||
return slashCommand === null ? messageText(message.content) : trunc(slashCommand);
|
||||
}
|
||||
|
||||
function isMetaMessage(message: JsonRecord): boolean {
|
||||
const origin = message.origin as JsonRecord | undefined;
|
||||
if (origin === undefined || origin.kind === 'user') return false;
|
||||
return !isRealUserMessage(message);
|
||||
}
|
||||
|
||||
function canonicalMessageContentType(message: JsonRecord): string {
|
||||
const origin = message.origin as JsonRecord | undefined;
|
||||
return origin?.kind === 'skill_activation' && !isRealUserMessage(message)
|
||||
? 'skill_instructions'
|
||||
: messageContentType(message.content);
|
||||
}
|
||||
|
||||
function projectSession(meta: KimiSessionUnitMeta, sessionId: string, state: JsonRecord): ProjectedSession {
|
||||
const cwd = typeof state.cwd === 'string'
|
||||
? state.cwd
|
||||
@@ -213,7 +254,7 @@ function projectSession(meta: KimiSessionUnitMeta, sessionId: string, state: Jso
|
||||
const toolCalls: ToolCallRecord[] = [];
|
||||
const toolResults: ToolResultRecord[] = [];
|
||||
const summaries: SummaryRecord[] = [];
|
||||
const durations: IndexRecord[] = [];
|
||||
const durations: TranscriptRecord[] = [];
|
||||
const childParentCalls = new Map<string, string>();
|
||||
let mainMessageCount = 0;
|
||||
|
||||
@@ -314,9 +355,10 @@ function projectSession(meta: KimiSessionUnitMeta, sessionId: string, state: Jso
|
||||
parent_uuid: previousUuid,
|
||||
timestamp,
|
||||
role: source.role,
|
||||
text: messageText(source.content),
|
||||
content_type: messageContentType(source.content),
|
||||
is_meta: origin !== undefined && origin.kind !== 'user' ? 1 : 0,
|
||||
text: projectedMessageText(source),
|
||||
content_type: canonicalMessageContentType(source),
|
||||
is_meta: isMetaMessage(source) ? 1 : 0,
|
||||
visibility: 'visible',
|
||||
model,
|
||||
is_sidechain: wire.main ? 0 : 1,
|
||||
agent_id: agentDbId,
|
||||
@@ -344,6 +386,7 @@ function projectSession(meta: KimiSessionUnitMeta, sessionId: string, state: Jso
|
||||
message_uuid: messageUuid,
|
||||
session_id: sessionId,
|
||||
name,
|
||||
presentation: name === 'Skill' ? 'skill' : 'default',
|
||||
input_json: truncJson(args) ?? '{}',
|
||||
file_path: filePath(name, args as JsonRecord | undefined),
|
||||
});
|
||||
@@ -405,6 +448,7 @@ function projectSession(meta: KimiSessionUnitMeta, sessionId: string, state: Jso
|
||||
text: partText(part),
|
||||
content_type: typeof part.type === 'string' ? part.type : 'unknown',
|
||||
is_meta: 0,
|
||||
visibility: 'visible',
|
||||
model,
|
||||
is_sidechain: wire.main ? 0 : 1,
|
||||
agent_id: agentDbId,
|
||||
@@ -421,7 +465,8 @@ function projectSession(meta: KimiSessionUnitMeta, sessionId: string, state: Jso
|
||||
const toolId = namespacedToolId(sessionId, wire.agentId, event.toolCallId);
|
||||
pushMessage({
|
||||
kind: 'message', uuid, session_id: sessionId, type: 'assistant', parent_uuid: previousUuid,
|
||||
timestamp, role: 'assistant', text: null, content_type: 'tool_use', is_meta: 0, model,
|
||||
timestamp, role: 'assistant', text: null, content_type: 'tool_use', is_meta: 0,
|
||||
visibility: 'visible', model,
|
||||
is_sidechain: wire.main ? 0 : 1, agent_id: agentDbId, input_tokens: null,
|
||||
output_tokens: null, cwd, skill: null, source: SOURCE,
|
||||
}, event.stepUuid);
|
||||
@@ -431,6 +476,7 @@ function projectSession(meta: KimiSessionUnitMeta, sessionId: string, state: Jso
|
||||
message_uuid: uuid,
|
||||
session_id: sessionId,
|
||||
name: String(event.name ?? 'tool'),
|
||||
presentation: event.name === 'Skill' ? 'skill' : 'default',
|
||||
input_json: truncJson(event.args ?? {}) ?? '{}',
|
||||
file_path: filePath(String(event.name ?? 'tool'), event.args as JsonRecord | undefined),
|
||||
});
|
||||
@@ -552,13 +598,19 @@ function rawFromWire(path: string, messageUuid: string): RawRecord | null {
|
||||
try {
|
||||
const record = JSON.parse(line) as JsonRecord;
|
||||
if (record.type === 'context.append_message') {
|
||||
const content = (record.message as JsonRecord | undefined)?.content;
|
||||
const parts = contentParts(content).map((part) => {
|
||||
const message = record.message as JsonRecord | undefined;
|
||||
if (message !== undefined) {
|
||||
const slashCommand = userSlashCommandText(message);
|
||||
if (slashCommand !== null) projectedText = slashCommand;
|
||||
else {
|
||||
const parts = contentParts(message.content).map((part) => {
|
||||
if (part.type === 'text' && typeof part.text === 'string') return part.text;
|
||||
if (part.type === 'thinking' && typeof part.thinking === 'string') return part.thinking;
|
||||
return null;
|
||||
}).filter((part): part is string => part !== null);
|
||||
projectedText = parts.length > 0 ? parts.join('\n') : null;
|
||||
}
|
||||
}
|
||||
} else if (record.type === 'context.append_loop_event') {
|
||||
const part = (record.event as JsonRecord | undefined)?.part as JsonRecord | undefined;
|
||||
if (part?.type === 'text' && typeof part.text === 'string') projectedText = part.text;
|
||||
@@ -580,6 +632,7 @@ export function createKimiProvider({ rootDir = defaultKimiRoot() }: { rootDir?:
|
||||
return {
|
||||
name,
|
||||
descriptor: { id: name, name: 'Kimi Code', vendor: 'Moonshot AI', defaultRoot: rootDir, color: '#6d6afc' },
|
||||
indexVersionMarker: KIMI_CANONICAL_TRANSCRIPT_MARKER,
|
||||
watchRoots: (configuredRoot) => [join(configuredRoot, 'sessions'), join(configuredRoot, 'session_index.jsonl')],
|
||||
discover(ctx: DiscoverContext): IndexUnit[] {
|
||||
const units: IndexUnit[] = [];
|
||||
@@ -604,7 +657,7 @@ export function createKimiProvider({ rootDir = defaultKimiRoot() }: { rootDir?:
|
||||
}
|
||||
return units;
|
||||
},
|
||||
*parse(unit: IndexUnit, _cursor: Cursor): Generator<IndexRecord, Cursor> {
|
||||
*parse(unit: IndexUnit, _cursor: Cursor): Generator<TranscriptRecord, Cursor> {
|
||||
const meta = unit.meta as KimiSessionUnitMeta;
|
||||
const before = cursorFor(meta.statePath, meta.wireFiles);
|
||||
const state = readState(meta.statePath);
|
||||
|
||||
@@ -6,12 +6,13 @@
|
||||
// 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).
|
||||
// - Consumer axis: shared, provider-agnostic modules consume the canonical
|
||||
// transcript for persistence and session-detail presentation.
|
||||
//
|
||||
// This file defines only the shapes crossing that boundary. Record fields mirror
|
||||
// the columns in packages/core/src/schema.sql; keep them in sync. Types only — no runtime
|
||||
// code — so consumers must import with `import type`.
|
||||
// This file defines only the shapes crossing that seam. Many fields mirror the
|
||||
// SQLite schema, but the database is a serialization adapter rather than the
|
||||
// source of transcript semantics. 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
|
||||
@@ -46,12 +47,10 @@ export interface DiscoverContext {
|
||||
changedPaths?: string[];
|
||||
}
|
||||
|
||||
/** Discriminated union of everything an adapter's parse can emit. Each record
|
||||
* kind maps to one schema table (see packages/core/src/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 =
|
||||
/** Canonical language emitted by every provider adapter. Persist serializes it;
|
||||
* session-detail assembly consumes it directly. Most record kinds map to one
|
||||
* schema table; update/retraction records encode canonical state transitions. */
|
||||
export type TranscriptRecord =
|
||||
| SessionRecord
|
||||
| MessageRecord
|
||||
| ToolCallRecord
|
||||
@@ -63,6 +62,8 @@ export type IndexRecord =
|
||||
| MessageTurnDurationRecord
|
||||
| DeleteSessionRecord;
|
||||
|
||||
export type MessageVisibility = 'visible' | 'hidden';
|
||||
|
||||
export interface MessageRecord {
|
||||
kind: 'message';
|
||||
uuid: string;
|
||||
@@ -74,6 +75,8 @@ export interface MessageRecord {
|
||||
text: string | null;
|
||||
content_type: string | null;
|
||||
is_meta: 0 | 1;
|
||||
/** Provider-normalized display eligibility. Assemblers never infer this from text. */
|
||||
visibility: MessageVisibility;
|
||||
model: string | null;
|
||||
is_sidechain: 0 | 1;
|
||||
agent_id: string | null;
|
||||
@@ -91,6 +94,7 @@ export interface ToolCallRecord {
|
||||
message_uuid: string;
|
||||
session_id: string;
|
||||
name: string;
|
||||
presentation: 'default' | 'skill';
|
||||
input_json: string;
|
||||
file_path: string | null;
|
||||
}
|
||||
@@ -128,17 +132,18 @@ export interface SubagentRecord {
|
||||
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.
|
||||
// A workflow run. `agent_count` is optional presentation metadata; persist still
|
||||
// computes the authoritative aggregate because agents may arrive on other runs.
|
||||
export interface WorkflowRecord {
|
||||
kind: 'workflow';
|
||||
run_id: string;
|
||||
session_id: string;
|
||||
parent_tool_use_id?: string | null;
|
||||
task_id: string | null;
|
||||
script: string | null;
|
||||
result_json: string | null;
|
||||
timestamp: string | null;
|
||||
agent_count: number;
|
||||
duration_ms: number | null;
|
||||
total_tokens: number | null;
|
||||
status: string | null;
|
||||
@@ -223,7 +228,7 @@ export interface Provider {
|
||||
/** Discover units needing (re)indexing, using stored cursors to detect change. */
|
||||
discover(ctx: DiscoverContext): IndexUnit[];
|
||||
/** Yield records for one unit resuming from `cursor`; return the new cursor. */
|
||||
parse(unit: IndexUnit, cursor: Cursor): Generator<IndexRecord, Cursor>;
|
||||
parse(unit: IndexUnit, cursor: Cursor): Generator<TranscriptRecord, Cursor>;
|
||||
}
|
||||
|
||||
/** Serializable source metadata consumed by settings and renderer surfaces. */
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { SqliteDb } from './sqlite-types.ts';
|
||||
|
||||
const COLUMN_MIGRATIONS = [
|
||||
['sessions', 'source', "TEXT DEFAULT 'claude'"],
|
||||
['messages', 'content_type', 'TEXT'],
|
||||
['messages', 'is_meta', 'INTEGER DEFAULT 0'],
|
||||
['messages', 'visibility', "TEXT DEFAULT 'visible'"],
|
||||
['messages', 'source', "TEXT DEFAULT 'claude'"],
|
||||
['tool_calls', 'presentation', "TEXT DEFAULT 'default'"],
|
||||
['workflows', 'parent_tool_use_id', 'TEXT'],
|
||||
['memories', 'anchors', 'TEXT'],
|
||||
['memories', 'deleted_at', 'TEXT'],
|
||||
['memories', 'deleted_reason', 'TEXT'],
|
||||
] as const;
|
||||
|
||||
function tableExists(db: SqliteDb, table: string): boolean {
|
||||
return Boolean(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(table));
|
||||
}
|
||||
|
||||
/** Binding-agnostic additive migrations shared by the CLI and desktop app. */
|
||||
export function migrateCoreSchemaColumns(db: SqliteDb): void {
|
||||
const columnsByTable = new Map<string, Set<string>>();
|
||||
for (const [table, column, definition] of COLUMN_MIGRATIONS) {
|
||||
if (!tableExists(db, table)) continue;
|
||||
let columns = columnsByTable.get(table);
|
||||
if (!columns) {
|
||||
columns = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((row) => String(row.name)));
|
||||
columnsByTable.set(table, columns);
|
||||
}
|
||||
if (columns.has(column)) continue;
|
||||
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
||||
columns.add(column);
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,14 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
uuid TEXT PRIMARY KEY, session_id TEXT, type TEXT, parent_uuid TEXT,
|
||||
timestamp TEXT, role TEXT, text TEXT, content_type TEXT,
|
||||
is_meta INTEGER DEFAULT 0, model TEXT,
|
||||
is_meta INTEGER DEFAULT 0, visibility TEXT DEFAULT 'visible', model TEXT,
|
||||
is_sidechain INTEGER DEFAULT 0, agent_id TEXT,
|
||||
input_tokens INTEGER, output_tokens INTEGER,
|
||||
cwd TEXT, skill TEXT, turn_duration_ms INTEGER,
|
||||
source TEXT DEFAULT 'claude');
|
||||
CREATE TABLE IF NOT EXISTS tool_calls (
|
||||
id TEXT PRIMARY KEY, message_uuid TEXT, session_id TEXT,
|
||||
name TEXT, input_json TEXT, file_path TEXT);
|
||||
name TEXT, presentation TEXT DEFAULT 'default', input_json TEXT, file_path TEXT);
|
||||
CREATE TABLE IF NOT EXISTS tool_results (
|
||||
tool_use_id TEXT PRIMARY KEY, message_uuid TEXT, session_id TEXT,
|
||||
content TEXT, file_path TEXT, is_error INTEGER DEFAULT 0);
|
||||
@@ -21,7 +21,7 @@ CREATE TABLE IF NOT EXISTS subagents (
|
||||
agent_id TEXT PRIMARY KEY, session_id TEXT, parent_tool_use_id TEXT,
|
||||
agent_type TEXT, description TEXT, duration_ms INTEGER, total_tokens INTEGER);
|
||||
CREATE TABLE IF NOT EXISTS workflows (
|
||||
run_id TEXT PRIMARY KEY, session_id TEXT, task_id TEXT,
|
||||
run_id TEXT PRIMARY KEY, session_id TEXT, parent_tool_use_id TEXT, task_id TEXT,
|
||||
script TEXT, result_json TEXT, timestamp TEXT, agent_count INTEGER DEFAULT 0,
|
||||
duration_ms INTEGER, total_tokens INTEGER, status TEXT, workflow_name TEXT);
|
||||
CREATE TABLE IF NOT EXISTS workflow_agents (
|
||||
|
||||
@@ -0,0 +1,597 @@
|
||||
import type {
|
||||
TranscriptRecord,
|
||||
MessageRecord,
|
||||
SessionRecord,
|
||||
SummaryRecord,
|
||||
ToolCallRecord,
|
||||
ToolResultRecord,
|
||||
WorkflowAgentRecord,
|
||||
WorkflowRecord,
|
||||
} from './providers/types.ts';
|
||||
|
||||
type WithoutKind<T extends { kind: string }> = Omit<T, 'kind'>;
|
||||
|
||||
export interface SessionDetailMessage {
|
||||
[key: string]: unknown;
|
||||
uuid: string;
|
||||
type: string | null;
|
||||
timestamp: string | null;
|
||||
text: string | null;
|
||||
content_type: string | null;
|
||||
is_meta: 0 | 1;
|
||||
turn_duration_ms: number | null;
|
||||
}
|
||||
|
||||
export type SessionDetailToolResult = WithoutKind<ToolResultRecord>;
|
||||
|
||||
export interface SessionDetailWorkflowAgent {
|
||||
agent_id: string;
|
||||
phase: string | null;
|
||||
label: string | null;
|
||||
state: string | null;
|
||||
tokens: number | null;
|
||||
duration_ms: number | null;
|
||||
}
|
||||
|
||||
export interface SessionDetailWorkflow {
|
||||
[key: string]: unknown;
|
||||
run_id: string;
|
||||
parent_tool_use_id: string | null;
|
||||
workflow_name: string | null;
|
||||
status: string | null;
|
||||
duration_ms: number | null;
|
||||
total_tokens: number | null;
|
||||
agent_count: number | null;
|
||||
agents: SessionDetailWorkflowAgent[];
|
||||
}
|
||||
|
||||
export interface AssembledToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
presentation: 'default' | 'skill';
|
||||
input_json: string | null;
|
||||
result: SessionDetailToolResult | null;
|
||||
subagent?: {
|
||||
agent_id: string;
|
||||
agent_type: string | null;
|
||||
description: string | null;
|
||||
};
|
||||
workflow?: SessionDetailWorkflow;
|
||||
}
|
||||
|
||||
export interface AssembledMessage extends SessionDetailMessage {
|
||||
[key: string]: unknown;
|
||||
tool_calls?: AssembledToolCall[];
|
||||
_thinking?: string;
|
||||
_skillMd?: string;
|
||||
}
|
||||
|
||||
export interface SessionDetailSnapshot {
|
||||
session: SessionDetailSession | null;
|
||||
messages: AssembledMessage[];
|
||||
workflows: SessionDetailWorkflow[];
|
||||
summaries: SessionDetailSummary[];
|
||||
}
|
||||
|
||||
export type SessionDetailSummary = WithoutKind<SummaryRecord> & Record<string, unknown>;
|
||||
|
||||
export type SessionDetailSession = Omit<WithoutKind<SessionRecord>, 'countMode'>;
|
||||
|
||||
export interface SessionDetailSessionRow {
|
||||
[key: string]: unknown;
|
||||
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 | null;
|
||||
jsonl_path?: string | null;
|
||||
source?: string | null;
|
||||
}
|
||||
|
||||
export interface SessionMessageRow {
|
||||
[key: string]: unknown;
|
||||
uuid: string;
|
||||
session_id?: string | null;
|
||||
type?: string | null;
|
||||
role?: string | null;
|
||||
parent_uuid?: string | null;
|
||||
timestamp?: string | null;
|
||||
text?: string | null;
|
||||
content_type?: string | null;
|
||||
is_meta?: number | boolean | null;
|
||||
visibility?: string | null;
|
||||
}
|
||||
|
||||
export interface SessionToolResultRow {
|
||||
[key: string]: unknown;
|
||||
tool_use_id: string;
|
||||
message_uuid?: string | null;
|
||||
session_id?: string | null;
|
||||
content?: string | null;
|
||||
}
|
||||
|
||||
export interface SessionToolCallRow {
|
||||
[key: string]: unknown;
|
||||
id: string;
|
||||
message_uuid: string;
|
||||
session_id?: string | null;
|
||||
name: string;
|
||||
presentation?: string | null;
|
||||
input_json?: string | null;
|
||||
}
|
||||
|
||||
export interface SessionSubagentRow {
|
||||
[key: string]: unknown;
|
||||
agent_id: string;
|
||||
session_id?: string | null;
|
||||
parent_tool_use_id?: string | null;
|
||||
agent_type?: string | null;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface SessionWorkflowAgentRow {
|
||||
[key: string]: unknown;
|
||||
agent_id: string;
|
||||
run_id?: string | null;
|
||||
session_id?: string | null;
|
||||
phase?: string | null;
|
||||
label?: string | null;
|
||||
state?: string | null;
|
||||
tokens?: number | null;
|
||||
duration_ms?: number | null;
|
||||
}
|
||||
|
||||
export interface SessionWorkflowRow {
|
||||
[key: string]: unknown;
|
||||
run_id: string;
|
||||
session_id?: string | null;
|
||||
parent_tool_use_id?: string | null;
|
||||
workflow_name?: string | null;
|
||||
status?: string | null;
|
||||
duration_ms?: number | null;
|
||||
total_tokens?: number | null;
|
||||
agent_count?: number | null;
|
||||
agents?: SessionWorkflowAgentRow[] | null;
|
||||
}
|
||||
|
||||
export interface SessionSummaryRow {
|
||||
[key: string]: unknown;
|
||||
id: string | number;
|
||||
session_id?: string | null;
|
||||
}
|
||||
|
||||
export interface SessionDetailRows {
|
||||
session?: SessionDetailSessionRow | null;
|
||||
messages?: SessionMessageRow[];
|
||||
toolCalls?: SessionToolCallRow[];
|
||||
toolResults?: SessionToolResultRow[];
|
||||
subagents?: SessionSubagentRow[];
|
||||
workflows?: SessionWorkflowRow[];
|
||||
summaries?: SessionSummaryRow[];
|
||||
}
|
||||
|
||||
function withoutKind<T extends { kind: string }>(record: T): WithoutKind<T> {
|
||||
const { kind: _kind, ...value } = record;
|
||||
return value;
|
||||
}
|
||||
|
||||
function assembleMessages(
|
||||
messages: SessionDetailMessage[],
|
||||
toolCalls: ToolCallRecord[],
|
||||
toolResults: ToolResultRecord[],
|
||||
subagents: Extract<TranscriptRecord, { kind: 'subagent' }>[],
|
||||
workflows: SessionDetailWorkflow[],
|
||||
): AssembledMessage[] {
|
||||
const resultsByCallId = new Map<string, SessionDetailToolResult>();
|
||||
for (const result of toolResults) resultsByCallId.set(result.tool_use_id, withoutKind(result));
|
||||
|
||||
const subagentsByCallId = new Map<string, Extract<TranscriptRecord, { kind: 'subagent' }>>();
|
||||
for (const subagent of subagents) {
|
||||
if (subagent.parent_tool_use_id) subagentsByCallId.set(subagent.parent_tool_use_id, subagent);
|
||||
}
|
||||
|
||||
const callsByMessageUuid = new Map<string, AssembledToolCall[]>();
|
||||
const workflowsByCallId = new Map(
|
||||
workflows
|
||||
.filter((workflow) => workflow.parent_tool_use_id)
|
||||
.map((workflow) => [workflow.parent_tool_use_id as string, workflow]),
|
||||
);
|
||||
for (const toolCall of toolCalls) {
|
||||
const call: AssembledToolCall = {
|
||||
id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
presentation: toolCall.presentation,
|
||||
input_json: toolCall.input_json,
|
||||
result: resultsByCallId.get(toolCall.id) ?? null,
|
||||
};
|
||||
const subagent = subagentsByCallId.get(toolCall.id);
|
||||
if (subagent) {
|
||||
call.subagent = {
|
||||
agent_id: subagent.agent_id,
|
||||
agent_type: subagent.agent_type ?? null,
|
||||
description: subagent.description ?? null,
|
||||
};
|
||||
}
|
||||
const workflow = workflowsByCallId.get(toolCall.id);
|
||||
if (workflow) call.workflow = workflow;
|
||||
const calls = callsByMessageUuid.get(toolCall.message_uuid) ?? [];
|
||||
calls.push(call);
|
||||
callsByMessageUuid.set(toolCall.message_uuid, calls);
|
||||
}
|
||||
|
||||
const raw = messages.map((message): AssembledMessage => {
|
||||
const assembled: AssembledMessage = { ...message };
|
||||
const calls = callsByMessageUuid.get(message.uuid);
|
||||
if (calls?.length) assembled.tool_calls = calls;
|
||||
return assembled;
|
||||
});
|
||||
|
||||
const output: AssembledMessage[] = [];
|
||||
for (let index = 0; index < raw.length; index++) {
|
||||
const message = raw[index];
|
||||
if (message.content_type === 'tool_result') continue;
|
||||
|
||||
if (message.type === 'assistant' && message.content_type === 'thinking') {
|
||||
const thinkingParts = [message.text ?? ''];
|
||||
let nextIndex = index + 1;
|
||||
while (
|
||||
nextIndex < raw.length
|
||||
&& raw[nextIndex].type === 'assistant'
|
||||
&& raw[nextIndex].content_type === 'thinking'
|
||||
) {
|
||||
thinkingParts.push(raw[nextIndex].text ?? '');
|
||||
nextIndex++;
|
||||
}
|
||||
if (
|
||||
nextIndex < raw.length
|
||||
&& raw[nextIndex].type === 'assistant'
|
||||
&& raw[nextIndex].content_type !== 'thinking'
|
||||
) {
|
||||
raw[nextIndex]._thinking = thinkingParts.join('\n\n');
|
||||
index = nextIndex - 1;
|
||||
continue;
|
||||
}
|
||||
output.push({ ...message, text: thinkingParts.join('\n\n'), content_type: 'thinking' });
|
||||
index = nextIndex - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.type === 'assistant' && message.content_type === 'tool_use') {
|
||||
const merged: AssembledMessage = {
|
||||
...message,
|
||||
tool_calls: [...(message.tool_calls ?? [])],
|
||||
};
|
||||
const mergedCalls = merged.tool_calls ?? [];
|
||||
if (message._thinking) merged._thinking = message._thinking;
|
||||
const skillOnly = mergedCalls.length === 1 && mergedCalls[0].presentation === 'skill';
|
||||
let nextIndex = index + 1;
|
||||
while (nextIndex < raw.length) {
|
||||
const next = raw[nextIndex];
|
||||
if (next.content_type === 'tool_result') {
|
||||
nextIndex++;
|
||||
continue;
|
||||
}
|
||||
if (next.content_type === 'skill_instructions' && next.text) {
|
||||
merged._skillMd = next.text;
|
||||
nextIndex++;
|
||||
continue;
|
||||
}
|
||||
if (!skillOnly && next.type === 'assistant' && next.content_type === 'tool_use') {
|
||||
if (next.tool_calls) mergedCalls.push(...next.tool_calls);
|
||||
if (next.text && !merged.text) merged.text = next.text;
|
||||
nextIndex++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
output.push(merged);
|
||||
index = nextIndex - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const assembled: AssembledMessage = { ...message };
|
||||
if (message._thinking) assembled._thinking = message._thinking;
|
||||
if (message.type === 'assistant'
|
||||
&& message.content_type !== 'tool_use'
|
||||
&& message.content_type !== 'thinking') {
|
||||
if (!assembled.tool_calls) assembled.tool_calls = [];
|
||||
let nextIndex = index + 1;
|
||||
while (nextIndex < raw.length) {
|
||||
const next = raw[nextIndex];
|
||||
if (next.content_type === 'tool_result') {
|
||||
nextIndex++;
|
||||
continue;
|
||||
}
|
||||
if (next.type === 'assistant' && next.content_type === 'tool_use') {
|
||||
if (next.tool_calls) assembled.tool_calls.push(...next.tool_calls);
|
||||
nextIndex++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (!assembled.tool_calls.length) delete assembled.tool_calls;
|
||||
index = nextIndex - 1;
|
||||
}
|
||||
output.push(assembled);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Project one canonical provider record stream into the app's session detail.
|
||||
* Provider-specific wire semantics must already be resolved before this seam.
|
||||
*/
|
||||
function assembleTranscriptRecords(records: Iterable<TranscriptRecord>): SessionDetailSnapshot {
|
||||
let session: SessionDetailSession | null = null;
|
||||
const messages: SessionDetailMessage[] = [];
|
||||
const messagesByUuid = new Map<string, SessionDetailMessage>();
|
||||
const mainMessageUuids = new Set<string>();
|
||||
const toolCalls: ToolCallRecord[] = [];
|
||||
const toolResults: ToolResultRecord[] = [];
|
||||
const subagents: Extract<TranscriptRecord, { kind: 'subagent' }>[] = [];
|
||||
const workflows: WorkflowRecord[] = [];
|
||||
const workflowAgentsById = new Map<string, WorkflowAgentRecord>();
|
||||
const summaries: SessionDetailSummary[] = [];
|
||||
|
||||
for (const record of records) {
|
||||
switch (record.kind) {
|
||||
case 'session':
|
||||
if (record.countMode === 'delta') {
|
||||
throw new Error('Direct session detail assembly requires a fresh full parse (cursor = null), not a provider delta');
|
||||
}
|
||||
session = {
|
||||
id: record.id,
|
||||
title: record.title,
|
||||
project: record.project,
|
||||
started_at: record.started_at,
|
||||
ended_at: record.ended_at,
|
||||
git_branch: record.git_branch,
|
||||
version: record.version,
|
||||
message_count: record.message_count,
|
||||
jsonl_path: record.jsonl_path,
|
||||
source: record.source,
|
||||
};
|
||||
break;
|
||||
case 'message': {
|
||||
if (record.visibility === 'hidden') break;
|
||||
const message: SessionDetailMessage = {
|
||||
uuid: record.uuid,
|
||||
type: record.type || record.role,
|
||||
timestamp: record.timestamp,
|
||||
text: record.text,
|
||||
content_type: record.content_type,
|
||||
is_meta: record.is_meta,
|
||||
turn_duration_ms: typeof (record as MessageRecord & { turn_duration_ms?: unknown }).turn_duration_ms === 'number'
|
||||
? (record as MessageRecord & { turn_duration_ms: number }).turn_duration_ms
|
||||
: null,
|
||||
};
|
||||
messages.push(message);
|
||||
messagesByUuid.set(message.uuid, message);
|
||||
if (record.agent_id === null) mainMessageUuids.add(message.uuid);
|
||||
break;
|
||||
}
|
||||
case 'tool_call':
|
||||
toolCalls.push(record);
|
||||
break;
|
||||
case 'tool_result':
|
||||
toolResults.push(record);
|
||||
break;
|
||||
case 'subagent':
|
||||
subagents.push(record);
|
||||
break;
|
||||
case 'workflow':
|
||||
workflows.push(record);
|
||||
break;
|
||||
case 'workflow_agent':
|
||||
workflowAgentsById.set(record.agent_id, {
|
||||
...(workflowAgentsById.get(record.agent_id) ?? record),
|
||||
...Object.fromEntries(
|
||||
Object.entries(record).filter(([, value]) => value !== null && value !== undefined),
|
||||
),
|
||||
} as WorkflowAgentRecord);
|
||||
break;
|
||||
case 'summary':
|
||||
summaries.push(withoutKind(record));
|
||||
break;
|
||||
case 'message-turn-duration': {
|
||||
const message = messagesByUuid.get(record.uuid);
|
||||
if (message) message.turn_duration_ms = record.turn_duration_ms;
|
||||
break;
|
||||
}
|
||||
case 'delete-session':
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const assembledWorkflows: SessionDetailWorkflow[] = workflows.map((workflow) => {
|
||||
const agents = [...workflowAgentsById.values()]
|
||||
.filter((agent) => agent.run_id === workflow.run_id)
|
||||
.map((agent) => ({
|
||||
agent_id: agent.agent_id,
|
||||
phase: agent.phase ?? null,
|
||||
label: agent.label ?? null,
|
||||
state: agent.state ?? null,
|
||||
tokens: agent.tokens ?? null,
|
||||
duration_ms: agent.duration_ms ?? null,
|
||||
}));
|
||||
return {
|
||||
run_id: workflow.run_id,
|
||||
parent_tool_use_id: workflow.parent_tool_use_id ?? null,
|
||||
workflow_name: workflow.workflow_name,
|
||||
status: workflow.status,
|
||||
duration_ms: workflow.duration_ms,
|
||||
total_tokens: workflow.total_tokens,
|
||||
agent_count: workflow.agent_count ?? agents.length,
|
||||
agents,
|
||||
};
|
||||
});
|
||||
|
||||
const detailMessages = session === null
|
||||
? messages
|
||||
: messages.filter((message) => mainMessageUuids.has(message.uuid));
|
||||
detailMessages.sort((left, right) => {
|
||||
const leftTimestamp = left.timestamp ?? '';
|
||||
const rightTimestamp = right.timestamp ?? '';
|
||||
if (leftTimestamp !== rightTimestamp) return leftTimestamp < rightTimestamp ? -1 : 1;
|
||||
return left.uuid < right.uuid ? -1 : left.uuid > right.uuid ? 1 : 0;
|
||||
});
|
||||
|
||||
return {
|
||||
session,
|
||||
messages: assembleMessages(detailMessages, toolCalls, toolResults, subagents, assembledWorkflows),
|
||||
workflows: assembledWorkflows,
|
||||
summaries,
|
||||
};
|
||||
}
|
||||
|
||||
/** Adapt persisted rows back into the same canonical record language providers emit. */
|
||||
function sessionDetailRecordsFromRows(input: SessionDetailRows): TranscriptRecord[] {
|
||||
const records: TranscriptRecord[] = [];
|
||||
if (input.session) {
|
||||
const session = input.session;
|
||||
records.push({
|
||||
kind: 'session',
|
||||
id: session.id,
|
||||
title: typeof session.title === 'string' ? session.title : null,
|
||||
project: typeof session.project === 'string' ? session.project : null,
|
||||
started_at: typeof session.started_at === 'string' ? session.started_at : null,
|
||||
ended_at: typeof session.ended_at === 'string' ? session.ended_at : null,
|
||||
git_branch: typeof session.git_branch === 'string' ? session.git_branch : null,
|
||||
version: typeof session.version === 'string' ? session.version : null,
|
||||
message_count: typeof session.message_count === 'number' ? session.message_count : 0,
|
||||
countMode: 'total',
|
||||
jsonl_path: typeof session.jsonl_path === 'string' ? session.jsonl_path : '',
|
||||
source: typeof session.source === 'string' ? session.source : '',
|
||||
});
|
||||
}
|
||||
for (const message of input.messages ?? []) {
|
||||
records.push({
|
||||
...message,
|
||||
kind: 'message',
|
||||
uuid: message.uuid,
|
||||
session_id: typeof message.session_id === 'string' ? message.session_id : '',
|
||||
type: typeof message.type === 'string' ? message.type : typeof message.role === 'string' ? message.role : '',
|
||||
parent_uuid: typeof message.parent_uuid === 'string' ? message.parent_uuid : null,
|
||||
timestamp: typeof message.timestamp === 'string' ? message.timestamp : null,
|
||||
role: typeof message.role === 'string' ? message.role : null,
|
||||
text: typeof message.text === 'string' ? message.text : null,
|
||||
content_type: typeof message.content_type === 'string' ? message.content_type : null,
|
||||
is_meta: message.is_meta ? 1 : 0,
|
||||
visibility: message.visibility === 'hidden' ? 'hidden' : 'visible',
|
||||
model: typeof message.model === 'string' ? message.model : null,
|
||||
is_sidechain: message.is_sidechain ? 1 : 0,
|
||||
agent_id: typeof message.agent_id === 'string' ? message.agent_id : null,
|
||||
input_tokens: typeof message.input_tokens === 'number' ? message.input_tokens : null,
|
||||
output_tokens: typeof message.output_tokens === 'number' ? message.output_tokens : null,
|
||||
cwd: typeof message.cwd === 'string' ? message.cwd : null,
|
||||
skill: typeof message.skill === 'string' ? message.skill : null,
|
||||
source: typeof message.source === 'string' ? message.source : '',
|
||||
});
|
||||
}
|
||||
for (const toolCall of input.toolCalls ?? []) {
|
||||
records.push({
|
||||
...toolCall,
|
||||
kind: 'tool_call',
|
||||
id: toolCall.id,
|
||||
message_uuid: toolCall.message_uuid,
|
||||
session_id: typeof toolCall.session_id === 'string' ? toolCall.session_id : '',
|
||||
name: toolCall.name,
|
||||
presentation: toolCall.presentation === 'skill' ? 'skill' : 'default',
|
||||
input_json: typeof toolCall.input_json === 'string' ? toolCall.input_json : '',
|
||||
file_path: typeof toolCall.file_path === 'string' ? toolCall.file_path : null,
|
||||
});
|
||||
}
|
||||
for (const result of input.toolResults ?? []) {
|
||||
records.push({
|
||||
...result,
|
||||
kind: 'tool_result',
|
||||
tool_use_id: result.tool_use_id,
|
||||
message_uuid: typeof result.message_uuid === 'string' ? result.message_uuid : '',
|
||||
session_id: typeof result.session_id === 'string' ? result.session_id : '',
|
||||
content: typeof result.content === 'string' ? result.content : '',
|
||||
file_path: typeof result.file_path === 'string' ? result.file_path : null,
|
||||
is_error: result.is_error ? 1 : 0,
|
||||
});
|
||||
}
|
||||
for (const subagent of input.subagents ?? []) {
|
||||
records.push({
|
||||
...subagent,
|
||||
kind: 'subagent',
|
||||
agent_id: subagent.agent_id,
|
||||
session_id: typeof subagent.session_id === 'string' ? subagent.session_id : '',
|
||||
parent_tool_use_id: typeof subagent.parent_tool_use_id === 'string' ? subagent.parent_tool_use_id : null,
|
||||
agent_type: typeof subagent.agent_type === 'string' ? subagent.agent_type : null,
|
||||
description: typeof subagent.description === 'string' ? subagent.description : null,
|
||||
duration_ms: typeof subagent.duration_ms === 'number' ? subagent.duration_ms : null,
|
||||
total_tokens: typeof subagent.total_tokens === 'number' ? subagent.total_tokens : null,
|
||||
});
|
||||
}
|
||||
for (const workflow of input.workflows ?? []) {
|
||||
records.push({
|
||||
...workflow,
|
||||
kind: 'workflow',
|
||||
run_id: workflow.run_id,
|
||||
session_id: typeof workflow.session_id === 'string' ? workflow.session_id : '',
|
||||
parent_tool_use_id: typeof workflow.parent_tool_use_id === 'string' ? workflow.parent_tool_use_id : null,
|
||||
task_id: typeof workflow.task_id === 'string' ? workflow.task_id : null,
|
||||
script: typeof workflow.script === 'string' ? workflow.script : null,
|
||||
result_json: typeof workflow.result_json === 'string' ? workflow.result_json : null,
|
||||
timestamp: typeof workflow.timestamp === 'string' ? workflow.timestamp : null,
|
||||
agent_count: typeof workflow.agent_count === 'number' ? workflow.agent_count : 0,
|
||||
duration_ms: typeof workflow.duration_ms === 'number' ? workflow.duration_ms : null,
|
||||
total_tokens: typeof workflow.total_tokens === 'number' ? workflow.total_tokens : null,
|
||||
status: typeof workflow.status === 'string' ? workflow.status : null,
|
||||
workflow_name: typeof workflow.workflow_name === 'string' ? workflow.workflow_name : null,
|
||||
});
|
||||
for (const agent of workflow.agents ?? []) {
|
||||
records.push({
|
||||
...agent,
|
||||
kind: 'workflow_agent',
|
||||
agent_id: agent.agent_id,
|
||||
run_id: typeof agent.run_id === 'string' ? agent.run_id : workflow.run_id,
|
||||
session_id: typeof agent.session_id === 'string'
|
||||
? agent.session_id
|
||||
: typeof workflow.session_id === 'string' ? workflow.session_id : '',
|
||||
...(typeof agent.agent_type === 'string' ? { agent_type: agent.agent_type } : {}),
|
||||
...(typeof agent.description === 'string' ? { description: agent.description } : {}),
|
||||
...(typeof agent.phase === 'string' ? { phase: agent.phase } : {}),
|
||||
...(typeof agent.label === 'string' ? { label: agent.label } : {}),
|
||||
...(typeof agent.model === 'string' ? { model: agent.model } : {}),
|
||||
...(typeof agent.state === 'string' ? { state: agent.state } : {}),
|
||||
...(typeof agent.duration_ms === 'number' ? { duration_ms: agent.duration_ms } : {}),
|
||||
...(typeof agent.tokens === 'number' ? { tokens: agent.tokens } : {}),
|
||||
...(typeof agent.tool_calls === 'number' ? { tool_calls: agent.tool_calls } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const summary of input.summaries ?? []) {
|
||||
records.push({
|
||||
...summary,
|
||||
kind: 'summary',
|
||||
id: String(summary.id),
|
||||
session_id: typeof summary.session_id === 'string' ? summary.session_id : '',
|
||||
timestamp: typeof summary.timestamp === 'string' ? summary.timestamp : null,
|
||||
source: typeof summary.source === 'string' ? summary.source : '',
|
||||
content: typeof summary.content === 'string' ? summary.content : '',
|
||||
});
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble detail from either a provider's complete canonical transcript stream
|
||||
* (a fresh parse with cursor = null) or the same records after a persistence
|
||||
* round-trip. This is the only presentation seam.
|
||||
*/
|
||||
export function assembleSessionDetail(
|
||||
input: Iterable<TranscriptRecord> | SessionDetailRows,
|
||||
): SessionDetailSnapshot {
|
||||
const records = Symbol.iterator in input
|
||||
? input as Iterable<TranscriptRecord>
|
||||
: sessionDetailRecordsFromRows(input as SessionDetailRows);
|
||||
return assembleTranscriptRecords(records);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { join } from 'node:path';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
import { buildIndex } from '../app/src/main/indexer.ts';
|
||||
import { CLAUDE_INPUT_TOKEN_SEMANTICS_MARKER } from '../packages/core/src/providers/claude.ts';
|
||||
import { CLAUDE_CANONICAL_TRANSCRIPT_MARKER } from '../packages/core/src/providers/claude.ts';
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
|
||||
class TestDatabase {
|
||||
@@ -123,7 +123,7 @@ test('app indexer refreshes unchanged Claude usage when input token semantics ch
|
||||
const stale = new TestDatabase(dbPath);
|
||||
stale.prepare('UPDATE messages SET input_tokens = 10 WHERE uuid = ?').run('msg-token-semantics-1');
|
||||
stale.prepare('DELETE FROM index_state WHERE jsonl_path = ?')
|
||||
.run(CLAUDE_INPUT_TOKEN_SEMANTICS_MARKER);
|
||||
.run(CLAUDE_CANONICAL_TRANSCRIPT_MARKER);
|
||||
stale.close();
|
||||
|
||||
buildIndex({
|
||||
@@ -140,7 +140,7 @@ test('app indexer refreshes unchanged Claude usage when input token semantics ch
|
||||
);
|
||||
assert.ok(
|
||||
refreshed.prepare('SELECT jsonl_path FROM index_state WHERE jsonl_path = ?')
|
||||
.get(CLAUDE_INPUT_TOKEN_SEMANTICS_MARKER),
|
||||
.get(CLAUDE_CANONICAL_TRANSCRIPT_MARKER),
|
||||
);
|
||||
refreshed.close();
|
||||
});
|
||||
@@ -437,11 +437,12 @@ test('app indexer loads Codex root sessions into the shared schema', () => {
|
||||
]);
|
||||
assert.equal(messages[1].turn_duration_ms, 4321);
|
||||
|
||||
const tool = db.prepare('SELECT * FROM tool_calls WHERE id=?').get('codex:call_codex_1');
|
||||
const toolId = `codex:${codexId}:call_codex_1`;
|
||||
const tool = db.prepare('SELECT * FROM tool_calls WHERE id=?').get(toolId);
|
||||
assert.equal(tool.session_id, `codex:${codexId}`);
|
||||
assert.equal(tool.name, 'exec_command');
|
||||
assert.equal(tool.message_uuid, `codex:${codexId}:000004`);
|
||||
const toolResult = db.prepare('SELECT message_uuid, content FROM tool_results WHERE tool_use_id=?').get('codex:call_codex_1');
|
||||
const toolResult = db.prepare('SELECT message_uuid, content FROM tool_results WHERE tool_use_id=?').get(toolId);
|
||||
assert.equal(toolResult.message_uuid, `codex:${codexId}:000004`);
|
||||
assert.equal(toolResult.content, '/tmp/obelisk-app');
|
||||
db.close();
|
||||
@@ -771,7 +772,8 @@ test('app indexer maps Codex subagent threads onto parent sessions', () => {
|
||||
assert.equal(db.prepare('SELECT COUNT(*) AS c FROM sessions WHERE id=?').get(`codex:${childId}`).c, 0);
|
||||
const subagent = db.prepare('SELECT * FROM subagents WHERE agent_id=?').get(`codex:${childId}`);
|
||||
assert.equal(subagent.session_id, `codex:${parentId}`);
|
||||
assert.equal(subagent.parent_tool_use_id, 'codex:call_spawn_1');
|
||||
const spawnToolId = `codex:${parentId}:call_spawn_1`;
|
||||
assert.equal(subagent.parent_tool_use_id, spawnToolId);
|
||||
assert.equal(subagent.agent_type, 'worker');
|
||||
assert.equal(subagent.description, 'Plato');
|
||||
|
||||
@@ -779,7 +781,7 @@ test('app indexer maps Codex subagent threads onto parent sessions', () => {
|
||||
assert.equal(spawnMessage.session_id, `codex:${parentId}`);
|
||||
assert.equal(spawnMessage.content_type, 'tool_use');
|
||||
assert.equal(spawnMessage.source, 'codex');
|
||||
assert.equal(db.prepare('SELECT name, message_uuid FROM tool_calls WHERE id=?').get('codex:call_spawn_1').message_uuid, spawnMessage.uuid);
|
||||
assert.equal(db.prepare('SELECT name, message_uuid FROM tool_calls WHERE id=?').get(spawnToolId).message_uuid, spawnMessage.uuid);
|
||||
|
||||
const childMessages = db.prepare('SELECT session_id, agent_id, is_sidechain, source, text FROM messages WHERE agent_id=? ORDER BY timestamp, uuid').all(`codex:${childId}`);
|
||||
assert.deepEqual(childMessages.map(m => [m.session_id, m.agent_id, m.is_sidechain, m.source, m.text]), [
|
||||
|
||||
@@ -6,6 +6,7 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { buildIndex } from '../app/src/main/indexer.ts';
|
||||
import { createKimiProvider } from '../packages/core/src/providers/kimi.ts';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
@@ -21,7 +22,7 @@ class TestDatabase {
|
||||
close() { return this.db.close(); }
|
||||
}
|
||||
|
||||
function writeSession(kimiDir) {
|
||||
function writeSession(kimiDir, { userSlash = false } = {}) {
|
||||
const sessionDir = join(kimiDir, 'sessions', 'workspace-1', 'session-index-1');
|
||||
const mainDir = join(sessionDir, 'agents', 'main');
|
||||
mkdirSync(mainDir, { recursive: true });
|
||||
@@ -35,7 +36,15 @@ function writeSession(kimiDir) {
|
||||
const wirePath = join(mainDir, 'wire.jsonl');
|
||||
const records = [
|
||||
{ type: 'metadata', protocol_version: '1.5', created_at: 1753005600000 },
|
||||
{ type: 'context.append_message', time: 1753005601000, message: { role: 'user', content: [{ type: 'text', text: 'kimi index needle' }], toolCalls: [], origin: { kind: 'user' } } },
|
||||
{ type: 'context.append_message', time: 1753005601000, message: userSlash
|
||||
? {
|
||||
role: 'user', content: 'Expanded skill instructions.', toolCalls: [],
|
||||
origin: {
|
||||
kind: 'skill_activation', trigger: 'user-slash', skillName: 'obelisk',
|
||||
skillArgs: 'find prior decisions',
|
||||
},
|
||||
}
|
||||
: { role: 'user', content: [{ type: 'text', text: 'kimi index needle' }], toolCalls: [], origin: { kind: 'user' } } },
|
||||
];
|
||||
writeFileSync(wirePath, records.map((record) => JSON.stringify(record)).join('\n') + '\n');
|
||||
return { sessionDir, wirePath, records };
|
||||
@@ -123,3 +132,37 @@ test('Kimi undo and clear replace the indexed session instead of leaving stale r
|
||||
assert.equal(db.prepare('SELECT message_count FROM sessions WHERE id=?').get('kimi:session-index-1').message_count, 0);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('Kimi prompt semantics marker replays unchanged sessions once', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-kimi-prompt-marker-'));
|
||||
const claudeDir = join(home, '.claude');
|
||||
const codexDir = join(home, '.codex');
|
||||
const kimiDir = join(home, '.kimi-code');
|
||||
const dbPath = join(home, '.obelisk', 'obelisk.sqlite');
|
||||
writeSession(kimiDir, { userSlash: true });
|
||||
const options = {
|
||||
claudeDir,
|
||||
codexDir,
|
||||
providerRoots: { kimi: kimiDir },
|
||||
dbPath,
|
||||
DatabaseImpl: TestDatabase,
|
||||
};
|
||||
|
||||
buildIndex(options);
|
||||
let db = new TestDatabase(dbPath);
|
||||
const marker = createKimiProvider({ rootDir: kimiDir }).indexVersionMarker;
|
||||
assert.equal(typeof marker, 'string');
|
||||
db.prepare("UPDATE messages SET text='stale expanded instructions', is_meta=1 WHERE source='kimi'").run();
|
||||
db.prepare('DELETE FROM index_state WHERE jsonl_path=?').run(marker);
|
||||
db.close();
|
||||
|
||||
const replay = buildIndex(options);
|
||||
assert.deepEqual(replay.affectedSessionIds, ['kimi:session-index-1']);
|
||||
db = new TestDatabase(dbPath);
|
||||
assert.deepEqual(
|
||||
{ ...db.prepare("SELECT text,is_meta FROM messages WHERE source='kimi'").get() },
|
||||
{ text: '/obelisk find prior decisions', is_meta: 0 },
|
||||
);
|
||||
assert.equal(db.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?').get(marker).c, 1);
|
||||
db.close();
|
||||
});
|
||||
|
||||
@@ -258,6 +258,7 @@ test('main process watches every root declared by the built-in provider registry
|
||||
assert.equal(serviceOptions.length, 1);
|
||||
assert.deepEqual(serviceOptions[0].watchDirs, [
|
||||
join(claudeDir, 'projects'),
|
||||
join(claudeDir, 'history.jsonl'),
|
||||
join(codexDir, 'sessions'),
|
||||
join(codexDir, 'session_index.jsonl'),
|
||||
join(home, '.kimi-code', 'sessions'),
|
||||
|
||||
@@ -41,7 +41,7 @@ test('app indexer persists every provider through one registry-driven loop', ()
|
||||
yield {
|
||||
kind: 'message', uuid: 'alpha:message', session_id: unit.sessionId, type: 'user',
|
||||
parent_uuid: null, timestamp: '2026-07-20T10:00:00.000Z', role: 'user',
|
||||
text: 'registry tracer bullet', content_type: 'text', is_meta: 0, model: null,
|
||||
text: 'registry tracer bullet', content_type: 'text', is_meta: 0, visibility: 'visible', model: null,
|
||||
is_sidechain: 0, agent_id: null, input_tokens: null, output_tokens: null,
|
||||
cwd: '/tmp/alpha', skill: null, source: 'alpha',
|
||||
};
|
||||
|
||||
@@ -223,13 +223,7 @@ test('BEGIN contention during finalize defers the build', () => {
|
||||
|
||||
test('a finalize database error is propagated instead of swallowed as malformed input', () => {
|
||||
const { home, dbPath, projectsDir } = twoFileHome('hello alpha', 'hello beta');
|
||||
const workflowDir = join(projectsDir, '-tmp-proj', 'alpha', 'workflows');
|
||||
mkdirSync(workflowDir, { recursive: true });
|
||||
writeFileSync(join(workflowDir, 'run.json'), JSON.stringify({
|
||||
runId: 'workflow-1',
|
||||
workflowName: 'POISON WORKFLOW',
|
||||
}));
|
||||
const Db = makeDbClass(args => args.some(arg => typeof arg === 'string' && arg.includes('POISON WORKFLOW')));
|
||||
const Db = makeDbClass(args => args.some(arg => arg === '__last_build__'));
|
||||
|
||||
assert.throws(() => buildIndex({
|
||||
force: false,
|
||||
|
||||
@@ -4,11 +4,16 @@
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, writeFileSync, statSync } from 'node:fs';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync, statSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import { parse } from '../packages/core/src/providers/claude.ts';
|
||||
import { createClaudeProvider, parse } from '../packages/core/src/providers/claude.ts';
|
||||
import { assembleSessionDetail } from '../packages/core/src/session-detail.ts';
|
||||
import { persist } from '../packages/core/src/persist.ts';
|
||||
|
||||
const SCHEMA = readFileSync(new URL('../packages/core/src/schema.sql', import.meta.url), 'utf8');
|
||||
|
||||
function writeFixture() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'obelisk-claude-parse-'));
|
||||
@@ -69,6 +74,10 @@ test('claude parse() yields the expected record stream for a main session', () =
|
||||
assert.equal(sessions[0].ended_at, '2026-06-10T10:00:10Z');
|
||||
assert.equal(sessions[0].git_branch, 'main');
|
||||
|
||||
const detail = assembleSessionDetail(values);
|
||||
assert.deepEqual(detail.messages.map((message) => message.text), ['hi', 'ok']);
|
||||
assert.equal(detail.messages[1].tool_calls[0].result.content, 'file body');
|
||||
|
||||
// Cursor encodes mtime:lines (6 lines consumed).
|
||||
assert.equal(ret, `${statSync(path).mtimeMs}:6`);
|
||||
});
|
||||
@@ -90,3 +99,66 @@ test('claude parse() resumes from a cursor, skipping already-indexed lines', ()
|
||||
assert.deepEqual(values.filter(r => r.kind !== 'session'), []);
|
||||
assert.equal(values.find(r => r.kind === 'session').message_count, 0);
|
||||
});
|
||||
|
||||
test('claude provider emits workflow artifacts with an explicit canonical tool edge', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'obelisk-claude-workflow-'));
|
||||
const projectDir = join(root, 'projects', '-proj');
|
||||
const workflowDir = join(projectDir, 'sid-workflow', 'workflows');
|
||||
const workflowAgentDir = join(projectDir, 'sid-workflow', 'subagents', 'workflows', 'run-workflow');
|
||||
mkdirSync(workflowDir, { recursive: true });
|
||||
mkdirSync(workflowAgentDir, { recursive: true });
|
||||
writeFileSync(join(projectDir, 'sid-workflow.jsonl'), [
|
||||
{
|
||||
uuid: 'assistant-workflow', type: 'assistant', timestamp: '2026-06-10T10:00:00Z',
|
||||
message: { role: 'assistant', content: [{ type: 'tool_use', id: 'workflow-tool', name: 'Workflow', input: {} }] },
|
||||
},
|
||||
{
|
||||
uuid: 'workflow-result', type: 'user', timestamp: '2026-06-10T10:00:01Z',
|
||||
message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'workflow-tool', content: 'run-workflow complete' }] },
|
||||
},
|
||||
].map(line => JSON.stringify(line)).join('\n') + '\n');
|
||||
writeFileSync(join(workflowDir, 'run-workflow.json'), JSON.stringify({
|
||||
runId: 'run-workflow',
|
||||
workflowName: 'Review',
|
||||
status: 'complete',
|
||||
workflowProgress: [{ type: 'workflow_agent', agentId: '7', phaseTitle: 'review', label: 'Reviewer' }],
|
||||
}));
|
||||
writeFileSync(join(workflowAgentDir, 'agent-7.jsonl'), `${JSON.stringify({
|
||||
uuid: 'workflow-agent-message', type: 'user', timestamp: '2026-06-10T10:00:00Z',
|
||||
message: { role: 'user', content: 'review it' },
|
||||
})}\n`);
|
||||
writeFileSync(join(workflowAgentDir, 'agent-7.meta.json'), JSON.stringify({
|
||||
agentType: 'reviewer', description: 'Review the implementation',
|
||||
}));
|
||||
writeFileSync(join(root, 'history.jsonl'), `${JSON.stringify({
|
||||
sessionId: 'sid-workflow', title: 'History-owned title',
|
||||
})}\n`);
|
||||
const provider = createClaudeProvider({ rootDir: root });
|
||||
const units = provider.discover({ lastCursor: () => null });
|
||||
const records = units.flatMap(unit => drain(provider.parse(unit, null)).values);
|
||||
|
||||
const workflow = records.find(record => record.kind === 'workflow');
|
||||
assert.equal(workflow.parent_tool_use_id, 'workflow-tool');
|
||||
const detail = assembleSessionDetail(records);
|
||||
assert.equal(detail.session.title, 'History-owned title');
|
||||
assert.equal(detail.messages[0].tool_calls[0].workflow.run_id, 'run-workflow');
|
||||
assert.equal(detail.workflows[0].agents[0].label, 'Reviewer');
|
||||
assert.equal(detail.workflows[0].agents.length, 1);
|
||||
|
||||
const db = new DatabaseSync(':memory:');
|
||||
db.exec(SCHEMA);
|
||||
for (const unit of units) persist(db, unit, provider.parse(unit, null));
|
||||
const workflows = db.prepare('SELECT * FROM workflows').all();
|
||||
for (const row of workflows) row.agents = db.prepare('SELECT * FROM workflow_agents WHERE run_id=?').all(row.run_id);
|
||||
const persistedDetail = assembleSessionDetail({
|
||||
session: db.prepare('SELECT * FROM sessions').get(),
|
||||
messages: db.prepare('SELECT * FROM messages ORDER BY timestamp, uuid').all(),
|
||||
toolCalls: db.prepare('SELECT * FROM tool_calls').all(),
|
||||
toolResults: db.prepare('SELECT * FROM tool_results').all(),
|
||||
subagents: db.prepare('SELECT * FROM subagents').all(),
|
||||
workflows,
|
||||
summaries: db.prepare('SELECT * FROM summaries').all(),
|
||||
});
|
||||
assert.deepEqual(persistedDetail, detail);
|
||||
db.close();
|
||||
});
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { parse } from '../packages/core/src/providers/codex.ts';
|
||||
import { createCodexProvider, parse } from '../packages/core/src/providers/codex.ts';
|
||||
|
||||
function writeFixture(lines) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'obelisk-codex-parse-'));
|
||||
@@ -56,9 +56,9 @@ test('codex parse() yields a deduped, tool-aware record stream with a total sess
|
||||
assert.equal(textAssistant.output_tokens, 50);
|
||||
|
||||
// Tool call + result.
|
||||
assert.deepEqual(byKind('tool_call').map(t => ({ id: t.id, name: t.name })), [{ id: 'codex:call_1', name: 'shell' }]);
|
||||
assert.deepEqual(byKind('tool_call').map(t => ({ id: t.id, name: t.name })), [{ id: `codex:${META.id}:call_1`, name: 'shell' }]);
|
||||
assert.equal(byKind('tool_result').length, 1);
|
||||
assert.equal(byKind('tool_result')[0].tool_use_id, 'codex:call_1');
|
||||
assert.equal(byKind('tool_result')[0].tool_use_id, `codex:${META.id}:call_1`);
|
||||
|
||||
// task_complete → turn duration on the text-assistant message.
|
||||
assert.deepEqual(byKind('message-turn-duration').map(d => d.turn_duration_ms), [1500]);
|
||||
@@ -84,3 +84,25 @@ test('codex parse() retracts a guardian thread via delete-session and emits noth
|
||||
assert.equal(values[0].kind, 'delete-session');
|
||||
assert.match(values[0].sessionId, /^codex:/);
|
||||
});
|
||||
|
||||
test('codex provider folds session_index metadata into its canonical session record', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'obelisk-codex-index-meta-'));
|
||||
const sessionsDir = join(root, 'sessions', '2026', '06', '10');
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
const path = join(sessionsDir, `rollout-${META.id}.jsonl`);
|
||||
writeFileSync(path, `${JSON.stringify({
|
||||
type: 'session_meta', timestamp: '2026-06-10T10:00:00Z', payload: META,
|
||||
})}\n`);
|
||||
const indexPath = join(root, 'session_index.jsonl');
|
||||
writeFileSync(indexPath, `${JSON.stringify({
|
||||
id: META.id, thread_name: 'Indexed title', updated_at: '2026-06-10T11:00:00Z',
|
||||
})}\n`);
|
||||
const provider = createCodexProvider({ rootDir: root });
|
||||
const units = provider.discover({ lastCursor: () => '9999999999999:1', changedPaths: [indexPath] });
|
||||
|
||||
assert.equal(units.length, 1);
|
||||
const { values } = drain(provider.parse(units[0], null));
|
||||
const session = values.find(record => record.kind === 'session');
|
||||
assert.equal(session.title, 'Indexed title');
|
||||
assert.equal(session.ended_at, '2026-06-10T11:00:00Z');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { assembleSessionDetail } from '../app/src/shared/session-detail-assembly.mjs';
|
||||
import { persist } from '../packages/core/src/persist.ts';
|
||||
import { parse } from '../packages/core/src/providers/codex.ts';
|
||||
|
||||
const SCHEMA = readFileSync(new URL('../packages/core/src/schema.sql', import.meta.url), 'utf8');
|
||||
const PARENT_ID = '019ed000-0000-7000-8000-000000000101';
|
||||
const REPLAY_ID = '019ed000-0000-7000-8000-000000000102';
|
||||
|
||||
function writeRollout(path, meta, source) {
|
||||
writeFileSync(path, [
|
||||
{ timestamp: '2026-06-15T10:00:00Z', type: 'session_meta', payload: meta },
|
||||
{
|
||||
timestamp: '2026-06-15T10:00:01Z',
|
||||
type: 'response_item',
|
||||
payload: { type: 'custom_tool_call', call_id: 'call_shared', name: 'exec', input: source },
|
||||
},
|
||||
{
|
||||
timestamp: '2026-06-15T10:00:02Z',
|
||||
type: 'response_item',
|
||||
payload: { type: 'custom_tool_call_output', call_id: 'call_shared', output: 'done' },
|
||||
},
|
||||
].map(line => JSON.stringify(line)).join('\n') + '\n');
|
||||
}
|
||||
|
||||
test('a replayed Codex call cannot steal the visible message tool association', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'obelisk-codex-replay-'));
|
||||
const parentPath = join(dir, 'parent.jsonl');
|
||||
const replayPath = join(dir, 'replay.jsonl');
|
||||
writeRollout(parentPath, {
|
||||
id: PARENT_ID,
|
||||
timestamp: '2026-06-15T10:00:00Z',
|
||||
cwd: '/proj',
|
||||
}, 'text("parent")');
|
||||
writeRollout(replayPath, {
|
||||
id: REPLAY_ID,
|
||||
forked_from_id: PARENT_ID,
|
||||
timestamp: '2026-06-15T10:00:00Z',
|
||||
cwd: '/proj',
|
||||
}, 'text("replay")');
|
||||
|
||||
const db = new DatabaseSync(':memory:');
|
||||
db.exec(SCHEMA);
|
||||
for (const path of [parentPath, replayPath]) {
|
||||
const unit = { key: path, sessionId: '', meta: { source: 'codex' } };
|
||||
persist(db, unit, parse(unit, null));
|
||||
}
|
||||
|
||||
const sessionId = `codex:${PARENT_ID}`;
|
||||
const messages = db.prepare(
|
||||
'SELECT * FROM messages WHERE session_id=? AND agent_id IS NULL ORDER BY timestamp, uuid',
|
||||
).all(sessionId);
|
||||
const toolCalls = db.prepare('SELECT * FROM tool_calls WHERE session_id=?').all(sessionId);
|
||||
const toolResults = db.prepare('SELECT * FROM tool_results WHERE session_id=?').all(sessionId);
|
||||
const assembled = assembleSessionDetail({ messages, toolCalls, toolResults, subagents: [], workflows: [] }).messages;
|
||||
|
||||
assert.equal(messages.length, 1, 'the replay remains outside the visible session timeline');
|
||||
assert.equal(toolCalls.length, 2, 'each rollout owns an independently addressable tool call');
|
||||
assert.equal(toolResults.length, 2, 'each rollout owns an independently addressable tool result');
|
||||
assert.deepEqual(assembled[0].tool_calls?.map(call => call.input_json), ['"text(\\"parent\\")"']);
|
||||
assert.equal(assembled[0].tool_calls?.[0].result?.content, 'done');
|
||||
db.close();
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { createKimiProvider } from '../packages/core/src/providers/kimi.ts';
|
||||
import { assembleSessionDetail } from '../packages/core/src/session-detail.ts';
|
||||
|
||||
function drain(gen) {
|
||||
const values = [];
|
||||
@@ -71,7 +72,7 @@ test('kimi provider discovers a changed session directory and returns a stable c
|
||||
assert.deepEqual(unchanged, []);
|
||||
});
|
||||
|
||||
test('kimi provider folds main and subagent wire logs into the existing record language', () => {
|
||||
test('kimi provider folds main and subagent wire logs into the canonical transcript language', () => {
|
||||
const { root } = writeKimiFixture();
|
||||
const provider = createKimiProvider({ rootDir: root });
|
||||
const unit = provider.discover({ lastCursor: () => null })[0];
|
||||
@@ -83,7 +84,7 @@ test('kimi provider folds main and subagent wire logs into the existing record l
|
||||
: record);
|
||||
assert.equal(
|
||||
createHash('sha256').update(JSON.stringify(goldenRecords)).digest('hex'),
|
||||
'ce3c70798bbc50e438605d86eafb28482630ee38dc2baa41a695975c84646822',
|
||||
'09d76616919435a46a3395194349e696e0d8f6717a24b018515e1f3867ec347a',
|
||||
'complete yielded record sequence changed',
|
||||
);
|
||||
|
||||
@@ -125,6 +126,10 @@ test('kimi provider folds main and subagent wire logs into the existing record l
|
||||
assert.deepEqual(byKind('subagent').map((record) => [record.agent_id, record.parent_tool_use_id, record.agent_type]), [
|
||||
['kimi:session-native-1:agent-7', 'kimi:session-native-1:main:call-1', 'explore'],
|
||||
]);
|
||||
|
||||
const detail = assembleSessionDetail(values);
|
||||
assert.equal(detail.messages.some((message) => message.text === 'child prompt'), false);
|
||||
assert.equal(detail.messages.flatMap((message) => message.tool_calls ?? [])[0].result.content.includes('file body'), true);
|
||||
});
|
||||
|
||||
test('kimi provider ignores a torn final wire line until it is completed', () => {
|
||||
@@ -193,6 +198,56 @@ test('kimi provider scopes changed-path discovery to one session and bypasses an
|
||||
assert.deepEqual(units.map(unit => unit.key), [firstDir]);
|
||||
});
|
||||
|
||||
test('kimi provider presents user-slash activations as real user prompts', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'obelisk-kimi-user-slash-'));
|
||||
const sessionDir = join(root, 'sessions', 'workspace-1', 'session-user-slash-1');
|
||||
const mainDir = join(sessionDir, 'agents', 'main');
|
||||
mkdirSync(mainDir, { recursive: true });
|
||||
writeFileSync(join(sessionDir, 'state.json'), JSON.stringify({ workDir: '/tmp/user-slash' }));
|
||||
const records = [
|
||||
{ type: 'metadata', protocol_version: '1.5', created_at: 1 },
|
||||
{ type: 'context.append_message', time: 2, message: {
|
||||
role: 'user', content: 'User activated the skill and loaded its full instructions.', toolCalls: [],
|
||||
origin: {
|
||||
kind: 'skill_activation', trigger: 'user-slash', skillName: 'obelisk',
|
||||
skillArgs: ' synthesize my history ',
|
||||
},
|
||||
} },
|
||||
{ type: 'context.append_message', time: 3, message: {
|
||||
role: 'user', content: 'Expanded plugin command implementation.', toolCalls: [],
|
||||
origin: {
|
||||
kind: 'plugin_command', trigger: 'user-slash', pluginId: 'demo',
|
||||
commandName: 'ship', commandArgs: ' --fast ',
|
||||
},
|
||||
} },
|
||||
{ type: 'context.append_message', time: 4, message: {
|
||||
role: 'user', content: 'Model-triggered skill instructions.', toolCalls: [],
|
||||
origin: { kind: 'skill_activation', trigger: 'model-tool', skillName: 'review' },
|
||||
} },
|
||||
];
|
||||
writeFileSync(join(mainDir, 'wire.jsonl'), records.map(record => JSON.stringify(record)).join('\n') + '\n');
|
||||
const provider = createKimiProvider({ rootDir: root });
|
||||
const unit = provider.discover({ lastCursor: () => null })[0];
|
||||
|
||||
const { values } = drain(provider.parse(unit, null));
|
||||
|
||||
const messages = values.filter(record => record.kind === 'message');
|
||||
assert.deepEqual(messages.map(record => ({
|
||||
text: record.text,
|
||||
is_meta: record.is_meta,
|
||||
})), [
|
||||
{ text: '/obelisk synthesize my history', is_meta: 0 },
|
||||
{ text: '/demo:ship --fast', is_meta: 0 },
|
||||
{ text: 'Model-triggered skill instructions.', is_meta: 1 },
|
||||
]);
|
||||
assert.equal(provider.raw({
|
||||
source: 'kimi',
|
||||
messageUuid: messages[0].uuid,
|
||||
session: { jsonl_path: join(mainDir, 'wire.jsonl') },
|
||||
agentId: null,
|
||||
}).messageText, '/obelisk synthesize my history');
|
||||
});
|
||||
|
||||
test('kimi provider maps protocol-1.0 embedded tool calls and results', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'obelisk-kimi-legacy-tools-'));
|
||||
const sessionDir = join(root, 'sessions', 'workspace-1', 'session-tools-1');
|
||||
|
||||
@@ -92,10 +92,34 @@ test('fresh full re-scan (no prior cursor) resets message_count instead of accum
|
||||
assert.equal(db.prepare('SELECT COUNT(*) c FROM messages').get().c, 3);
|
||||
});
|
||||
|
||||
test('persist round-trips canonical workflow records and their tool relationship', () => {
|
||||
const db = freshDb();
|
||||
function* records() {
|
||||
yield {
|
||||
kind: 'workflow', run_id: 'run-1', session_id: 'sid-p', parent_tool_use_id: 'tool-1',
|
||||
task_id: 'task-1', script: 'review', result_json: '{}', timestamp: '2026-06-10T10:00:00Z',
|
||||
agent_count: 1, duration_ms: 10, total_tokens: 20, status: 'complete', workflow_name: 'Review',
|
||||
};
|
||||
yield {
|
||||
kind: 'workflow_agent', agent_id: 'agent-1', run_id: 'run-1', session_id: 'sid-p',
|
||||
agent_type: 'reviewer', phase: 'review', label: 'Reviewer', state: 'complete', tokens: 20,
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
persist(db, { key: 'workflow', sessionId: 'sid-p' }, records());
|
||||
|
||||
assert.equal(db.prepare('SELECT parent_tool_use_id FROM workflows WHERE run_id=?').get('run-1').parent_tool_use_id, 'tool-1');
|
||||
assert.equal(db.prepare('SELECT phase FROM workflow_agents WHERE agent_id=?').get('agent-1').phase, 'review');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('delete-session cascades across tables', () => {
|
||||
const db = freshDb();
|
||||
const unit = fixtureUnit();
|
||||
persist(db, unit, parse(unit, null));
|
||||
db.prepare('INSERT INTO workflows (run_id,session_id) VALUES (?,?)').run('run-delete', 'sid-p');
|
||||
db.prepare('INSERT INTO workflow_agents (agent_id,run_id,session_id) VALUES (?,?,?)').run('agent-delete', 'run-delete', 'sid-p');
|
||||
|
||||
// Hand-roll a one-shot generator emitting a delete for the session.
|
||||
function* del() { yield { kind: 'delete-session', sessionId: 'sid-p' }; return null; }
|
||||
@@ -104,4 +128,6 @@ test('delete-session cascades across tables', () => {
|
||||
assert.equal(db.prepare('SELECT COUNT(*) c FROM sessions WHERE id=?').get('sid-p').c, 0);
|
||||
assert.equal(db.prepare('SELECT COUNT(*) c FROM messages WHERE session_id=?').get('sid-p').c, 0);
|
||||
assert.equal(db.prepare('SELECT COUNT(*) c FROM tool_calls WHERE session_id=?').get('sid-p').c, 0);
|
||||
assert.equal(db.prepare('SELECT COUNT(*) c FROM workflows WHERE session_id=?').get('sid-p').c, 0);
|
||||
assert.equal(db.prepare('SELECT COUNT(*) c FROM workflow_agents WHERE session_id=?').get('sid-p').c, 0);
|
||||
});
|
||||
|
||||
@@ -70,6 +70,7 @@ test('built-in provider registry exposes every source without caller-side branch
|
||||
]);
|
||||
assert.deepEqual(registry.watchRoots(), [
|
||||
'/sources/claude/projects',
|
||||
'/sources/claude/history.jsonl',
|
||||
'/sources/codex/sessions',
|
||||
'/sources/codex/session_index.jsonl',
|
||||
'/sources/kimi/sessions',
|
||||
|
||||
@@ -3,10 +3,10 @@ import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
test('provider adapters do not change the frozen SQLite schema', () => {
|
||||
test('canonical transcript persistence schema changes only by explicit decision', () => {
|
||||
const schema = readFileSync(new URL('../packages/core/src/schema.sql', import.meta.url));
|
||||
assert.equal(
|
||||
createHash('sha256').update(schema).digest('hex'),
|
||||
'3e0615ed2db0d7338561df4d51c4240395714c191aa69567ffcdb70efec49826',
|
||||
'ef5d0eea6f91c50e78ca5e28ecdc7b3ed5db83db59200642cc25866158f9d307',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import { assembleSessionDetail } from '../packages/core/src/session-detail.ts';
|
||||
import { persist } from '../packages/core/src/persist.ts';
|
||||
import { parse as parseCodex } from '../packages/core/src/providers/codex.ts';
|
||||
|
||||
const SCHEMA = readFileSync(new URL('../packages/core/src/schema.sql', import.meta.url), 'utf8');
|
||||
|
||||
function writeCodexFixture(lines) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'obelisk-provider-detail-'));
|
||||
const path = join(dir, 'rollout.jsonl');
|
||||
writeFileSync(path, `${lines.map(line => JSON.stringify(line)).join('\n')}\n`);
|
||||
return path;
|
||||
}
|
||||
|
||||
test('a provider record stream assembles directly into session detail', () => {
|
||||
const threadId = '019e8951-3e7d-7343-a3e3-05bff48a317d';
|
||||
const path = writeCodexFixture([
|
||||
{
|
||||
type: 'session_meta',
|
||||
timestamp: '2026-06-10T10:00:00Z',
|
||||
payload: { id: threadId, cwd: '/proj', timestamp: '2026-06-10T10:00:00Z' },
|
||||
},
|
||||
{
|
||||
type: 'event_msg',
|
||||
timestamp: '2026-06-10T10:00:01Z',
|
||||
payload: { type: 'user_message', message: 'inspect the repository' },
|
||||
},
|
||||
{
|
||||
type: 'event_msg',
|
||||
timestamp: '2026-06-10T10:00:02Z',
|
||||
payload: { type: 'agent_message', message: 'I will inspect it.' },
|
||||
},
|
||||
{
|
||||
type: 'response_item',
|
||||
timestamp: '2026-06-10T10:00:03Z',
|
||||
payload: { type: 'function_call', call_id: 'call_1', name: 'shell', arguments: '{"cmd":"ls"}' },
|
||||
},
|
||||
{
|
||||
type: 'response_item',
|
||||
timestamp: '2026-06-10T10:00:04Z',
|
||||
payload: { type: 'function_call_output', call_id: 'call_1', output: 'package.json' },
|
||||
},
|
||||
]);
|
||||
|
||||
const records = [...parseCodex({ key: path, sessionId: '' }, null)];
|
||||
const detail = assembleSessionDetail(records);
|
||||
|
||||
assert.deepEqual(detail.messages.map(message => message.text), [
|
||||
'inspect the repository',
|
||||
'I will inspect it.',
|
||||
]);
|
||||
assert.equal(detail.messages[1].tool_calls?.[0].name, 'shell');
|
||||
assert.equal(detail.messages[1].tool_calls?.[0].result?.content, 'package.json');
|
||||
|
||||
const db = new DatabaseSync(':memory:');
|
||||
db.exec(SCHEMA);
|
||||
persist(db, { key: path, sessionId: '' }, parseCodex({ key: path, sessionId: '' }, null));
|
||||
const persistedDetail = assembleSessionDetail({
|
||||
session: db.prepare('SELECT * FROM sessions').get(),
|
||||
messages: db.prepare('SELECT * FROM messages ORDER BY timestamp, uuid').all(),
|
||||
toolCalls: db.prepare('SELECT * FROM tool_calls').all(),
|
||||
toolResults: db.prepare('SELECT * FROM tool_results').all(),
|
||||
});
|
||||
assert.deepEqual(persistedDetail, detail);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('provider-classified hidden context never reaches session detail', () => {
|
||||
const threadId = '019e8951-3e7d-7343-a3e3-05bff48a317e';
|
||||
const path = writeCodexFixture([
|
||||
{
|
||||
type: 'session_meta',
|
||||
timestamp: '2026-06-10T10:00:00Z',
|
||||
payload: { id: threadId, cwd: '/proj', timestamp: '2026-06-10T10:00:00Z' },
|
||||
},
|
||||
{
|
||||
type: 'response_item',
|
||||
timestamp: '2026-06-10T10:00:01Z',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: '<environment_context>\n <cwd>/proj</cwd>\n</environment_context>' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'response_item',
|
||||
timestamp: '2026-06-10T10:00:02Z',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: '<codex_internal_context source="goal">\nsecret state\n</codex_internal_context>' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'event_msg',
|
||||
timestamp: '2026-06-10T10:00:03Z',
|
||||
payload: { type: 'user_message', message: 'show the actual request' },
|
||||
},
|
||||
]);
|
||||
|
||||
const records = [...parseCodex({ key: path, sessionId: '' }, null)];
|
||||
const detail = assembleSessionDetail(records);
|
||||
|
||||
assert.deepEqual(detail.messages.map(message => message.text), ['show the actual request']);
|
||||
assert.equal(
|
||||
records.filter(record => record.kind === 'message' && record.visibility === 'hidden').length,
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test('provider normalization removes only structural image wrappers before deduplication', () => {
|
||||
const threadId = '019e8951-3e7d-7343-a3e3-05bff48a317f';
|
||||
const path = writeCodexFixture([
|
||||
{
|
||||
type: 'session_meta',
|
||||
timestamp: '2026-06-10T10:00:00Z',
|
||||
payload: { id: threadId, cwd: '/proj', timestamp: '2026-06-10T10:00:00Z' },
|
||||
},
|
||||
{
|
||||
type: 'event_msg',
|
||||
timestamp: '2026-06-10T10:00:01Z',
|
||||
payload: { type: 'user_message', message: 'look at this screenshot' },
|
||||
},
|
||||
{
|
||||
type: 'response_item',
|
||||
timestamp: '2026-06-10T10:00:01Z',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'input_text', text: 'look at this screenshot' },
|
||||
{ type: 'input_text', text: '<image>' },
|
||||
{ type: 'input_image', image_url: 'data:image/png;base64,AAAA' },
|
||||
{ type: 'input_text', text: '</image>' },
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const records = [...parseCodex({ key: path, sessionId: '' }, null)];
|
||||
const detail = assembleSessionDetail(records);
|
||||
|
||||
assert.deepEqual(detail.messages.map(message => message.text), ['look at this screenshot']);
|
||||
});
|
||||
|
||||
test('canonical visibility survives persistence before row-based assembly', () => {
|
||||
const threadId = '019e8951-3e7d-7343-a3e3-05bff48a3180';
|
||||
const path = writeCodexFixture([
|
||||
{
|
||||
type: 'session_meta',
|
||||
timestamp: '2026-06-10T10:00:00Z',
|
||||
payload: { id: threadId, cwd: '/proj', timestamp: '2026-06-10T10:00:00Z' },
|
||||
},
|
||||
{
|
||||
type: 'response_item',
|
||||
timestamp: '2026-06-10T10:00:01Z',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: '<environment_context>hidden</environment_context>' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'event_msg',
|
||||
timestamp: '2026-06-10T10:00:02Z',
|
||||
payload: { type: 'user_message', message: 'visible request' },
|
||||
},
|
||||
]);
|
||||
const db = new DatabaseSync(':memory:');
|
||||
db.exec(SCHEMA);
|
||||
|
||||
persist(db, { key: path, sessionId: '' }, parseCodex({ key: path, sessionId: '' }, null));
|
||||
const messages = db.prepare('SELECT * FROM messages ORDER BY timestamp, uuid').all();
|
||||
const assembled = assembleSessionDetail({ messages }).messages;
|
||||
|
||||
assert.equal(messages[0].visibility, 'hidden');
|
||||
assert.deepEqual(assembled.map(message => message.text), ['visible request']);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('provider normalization classifies Skill instructions before assembly', () => {
|
||||
const threadId = '019e8951-3e7d-7343-a3e3-05bff48a3181';
|
||||
const path = writeCodexFixture([
|
||||
{
|
||||
type: 'session_meta',
|
||||
timestamp: '2026-06-10T10:00:00Z',
|
||||
payload: { id: threadId, cwd: '/proj', timestamp: '2026-06-10T10:00:00Z' },
|
||||
},
|
||||
{
|
||||
type: 'response_item',
|
||||
timestamp: '2026-06-10T10:00:01Z',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: 'Base directory for this skill: /tmp/skill\n# Instructions' }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const records = [...parseCodex({ key: path, sessionId: '' }, null)];
|
||||
const message = records.find(record => record.kind === 'message');
|
||||
|
||||
assert.equal(message.content_type, 'skill_instructions');
|
||||
assert.equal(message.is_meta, 1);
|
||||
assert.equal(message.visibility, 'visible');
|
||||
});
|
||||
@@ -173,8 +173,8 @@ test('runtime indexes Codex root sessions into the shared query helpers', () =>
|
||||
})),
|
||||
developerReplay: search('developer replay', { source: 'codex', limit: 5 }).length,
|
||||
rawHasEventLine: raw(${JSON.stringify(`codex:${codexId}:000002`)}, { limit: 1000 })?.text.includes('codex user asks for runtime indexing') || false,
|
||||
tool: sql('SELECT id, message_uuid, session_id, name FROM tool_calls WHERE id=?', 'codex:call_codex_1')[0],
|
||||
toolResult: sql('SELECT tool_use_id, message_uuid, session_id, content FROM tool_results WHERE tool_use_id=?', 'codex:call_codex_1')[0],
|
||||
tool: sql('SELECT id, message_uuid, session_id, name FROM tool_calls WHERE id=?', ${JSON.stringify(`codex:${codexId}:call_codex_1`)})[0],
|
||||
toolResult: sql('SELECT tool_use_id, message_uuid, session_id, content FROM tool_results WHERE tool_use_id=?', ${JSON.stringify(`codex:${codexId}:call_codex_1`)})[0],
|
||||
overviewSources: overview({ limit: 5 }).totals.sources
|
||||
};
|
||||
`);
|
||||
@@ -440,7 +440,7 @@ test('runtime maps Codex child threads onto subagents', () => {
|
||||
assert.deepEqual(payload.subagents, [{
|
||||
agent_id: `codex:${childId}`,
|
||||
session_id: `codex:${parentId}`,
|
||||
parent_tool_use_id: 'codex:call_spawn_1',
|
||||
parent_tool_use_id: `codex:${parentId}:call_spawn_1`,
|
||||
agent_type: 'worker',
|
||||
description: 'Plato',
|
||||
messageCount: 2,
|
||||
|
||||
@@ -1,21 +1,33 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { assembleSessionMessages } from '../app/src/shared/session-detail-assembly.mjs';
|
||||
import { assembleSessionDetail } from '../app/src/shared/session-detail-assembly.mjs';
|
||||
|
||||
test('session assembly preserves thinking and attaches tool result and subagent evidence', () => {
|
||||
const messages = [
|
||||
{ uuid: 'thinking-1', type: 'assistant', content_type: 'thinking', text: 'reasoning' },
|
||||
{ uuid: 'answer-1', type: 'assistant', content_type: 'text', text: 'answer' },
|
||||
{ uuid: 'tool-1', type: 'assistant', content_type: 'tool_use', text: '' },
|
||||
{ uuid: 'result-1', type: 'user', content_type: 'tool_result', text: '' },
|
||||
{
|
||||
uuid: 'thinking-1', timestamp: '2026-06-10T10:00:00Z',
|
||||
type: 'assistant', content_type: 'thinking', text: 'reasoning',
|
||||
},
|
||||
{
|
||||
uuid: 'answer-1', timestamp: '2026-06-10T10:00:01Z',
|
||||
type: 'assistant', content_type: 'text', text: 'answer',
|
||||
},
|
||||
{
|
||||
uuid: 'tool-1', timestamp: '2026-06-10T10:00:02Z',
|
||||
type: 'assistant', content_type: 'tool_use', text: '',
|
||||
},
|
||||
{
|
||||
uuid: 'result-1', timestamp: '2026-06-10T10:00:03Z',
|
||||
type: 'user', content_type: 'tool_result', text: '',
|
||||
},
|
||||
];
|
||||
const assembled = assembleSessionMessages({
|
||||
const assembled = assembleSessionDetail({
|
||||
messages,
|
||||
toolCalls: [{ id: 'call-1', message_uuid: 'tool-1', name: 'Agent', input_json: '{"description":"inspect"}' }],
|
||||
toolResults: [{ tool_use_id: 'call-1', message_uuid: 'result-1', content: 'done', is_error: 0 }],
|
||||
subagents: [{ agent_id: 'agent-1', parent_tool_use_id: 'call-1', agent_type: 'reviewer', description: 'inspect' }],
|
||||
workflows: [],
|
||||
});
|
||||
}).messages;
|
||||
|
||||
assert.equal(assembled.length, 1);
|
||||
assert.equal(assembled[0].uuid, 'answer-1');
|
||||
@@ -25,34 +37,76 @@ test('session assembly preserves thinking and attaches tool result and subagent
|
||||
});
|
||||
|
||||
test('session assembly keeps Skill evidence standalone and embeds matching workflow agents', () => {
|
||||
const assembled = assembleSessionMessages({
|
||||
const assembled = assembleSessionDetail({
|
||||
messages: [
|
||||
{ uuid: 'skill-1', type: 'assistant', content_type: 'tool_use', text: '' },
|
||||
{ uuid: 'skill-md', type: 'user', content_type: 'text', is_meta: 1, text: 'Base directory for this skill\n# Skill' },
|
||||
{ uuid: 'skill-md', type: 'user', content_type: 'skill_instructions', is_meta: 1, text: '# Skill instructions' },
|
||||
{ uuid: 'workflow-1', type: 'assistant', content_type: 'tool_use', text: '' },
|
||||
],
|
||||
toolCalls: [
|
||||
{ id: 'call-skill', message_uuid: 'skill-1', name: 'Skill', input_json: '{"skill":"obelisk"}' },
|
||||
{ id: 'call-workflow', message_uuid: 'workflow-1', name: 'Workflow', input_json: '{}' },
|
||||
{ id: 'call-skill', message_uuid: 'skill-1', name: 'Skill', presentation: 'skill', input_json: '{"skill":"obelisk"}' },
|
||||
{ id: 'call-workflow', message_uuid: 'workflow-1', name: 'Workflow', presentation: 'default', input_json: '{}' },
|
||||
],
|
||||
toolResults: [{ tool_use_id: 'call-workflow', content: 'run-1 complete', is_error: 0 }],
|
||||
toolResults: [{ tool_use_id: 'call-workflow', content: 'complete', is_error: 0 }],
|
||||
subagents: [],
|
||||
workflows: [{
|
||||
run_id: 'run-1',
|
||||
parent_tool_use_id: 'call-workflow',
|
||||
workflow_name: 'review',
|
||||
status: 'complete',
|
||||
agents: [{ agent_id: 'agent-1', phase: 'review', label: 'Reviewer', state: 'complete' }],
|
||||
}],
|
||||
});
|
||||
}).messages;
|
||||
|
||||
assert.equal(assembled[0]._skillMd, 'Base directory for this skill\n# Skill');
|
||||
assert.equal(assembled[0]._skillMd, '# Skill instructions');
|
||||
assert.equal(assembled[1].tool_calls[0].workflow.run_id, 'run-1');
|
||||
assert.deepEqual(assembled[1].tool_calls[0].workflow.agents, [{
|
||||
agent_id: 'agent-1',
|
||||
phase: 'review',
|
||||
label: 'Reviewer',
|
||||
state: 'complete',
|
||||
tokens: undefined,
|
||||
duration_ms: undefined,
|
||||
tokens: null,
|
||||
duration_ms: null,
|
||||
}]);
|
||||
});
|
||||
|
||||
test('session assembly trusts canonical classification instead of parsing provider text', () => {
|
||||
const detail = assembleSessionDetail({
|
||||
messages: [{
|
||||
uuid: 'provider-owned-classification',
|
||||
type: 'user',
|
||||
content_type: 'text',
|
||||
is_meta: 0,
|
||||
text: '<system-reminder>text alone does not define presentation semantics</system-reminder>',
|
||||
}],
|
||||
});
|
||||
|
||||
assert.equal(detail.messages[0].is_meta, 0);
|
||||
});
|
||||
|
||||
test('canonical ordering is stable across provider and SQLite iteration order', () => {
|
||||
const detail = assembleSessionDetail([
|
||||
{
|
||||
kind: 'message', uuid: 'b', session_id: 'session', type: 'user', parent_uuid: null,
|
||||
timestamp: '2026-06-10T10:00:00Z', role: 'user', text: 'second', content_type: 'text',
|
||||
is_meta: 0, visibility: 'visible', model: null, is_sidechain: 0, agent_id: null,
|
||||
input_tokens: null, output_tokens: null, cwd: null, skill: null, source: 'test',
|
||||
},
|
||||
{
|
||||
kind: 'message', uuid: 'a', session_id: 'session', type: 'user', parent_uuid: null,
|
||||
timestamp: '2026-06-10T10:00:00Z', role: 'user', text: 'first', content_type: 'text',
|
||||
is_meta: 0, visibility: 'visible', model: null, is_sidechain: 0, agent_id: null,
|
||||
input_tokens: null, output_tokens: null, cwd: null, skill: null, source: 'test',
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(detail.messages.map(message => message.uuid), ['a', 'b']);
|
||||
});
|
||||
|
||||
test('direct session assembly rejects an incomplete provider delta', () => {
|
||||
assert.throws(() => assembleSessionDetail([{
|
||||
kind: 'session', id: 'session', title: null, project: null,
|
||||
started_at: null, ended_at: null, git_branch: null, version: null,
|
||||
message_count: 1, countMode: 'delta', jsonl_path: '/session.jsonl', source: 'test',
|
||||
}]), /fresh full parse/);
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
loadSessionDetail,
|
||||
materializeSessionDetailPatch,
|
||||
} from '../app/src/renderer/src/data.js';
|
||||
import { assembleSessionMessages } from '../app/src/shared/session-detail-assembly.mjs';
|
||||
import { assembleSessionDetail } from '../app/src/shared/session-detail-assembly.mjs';
|
||||
import { createSessionPatch } from '../app/src/shared/session-patch.mjs';
|
||||
|
||||
test('live updates coalesce while scrolling and load only the latest after scroll end', async () => {
|
||||
@@ -123,13 +123,13 @@ test('a skipped live patch does not advance the visible patch baseline', async t
|
||||
getSessionWorkflows: async () => [],
|
||||
getSessionSummaries: async () => [],
|
||||
getSessionPatch: async (_id, cursor) => {
|
||||
const snapshotAtCall = { messages: assembleSessionMessages({
|
||||
const snapshotAtCall = { messages: assembleSessionDetail({
|
||||
messages: rows,
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
subagents: [],
|
||||
workflows: [],
|
||||
}), workflows: [] };
|
||||
}).messages, workflows: [] };
|
||||
patchCalls++;
|
||||
if (patchCalls === 1) {
|
||||
firstPatchStarted();
|
||||
|
||||
Reference in New Issue
Block a user