feat(core): add first-class Pi session indexing (#23)

Pi cannot be read as another linear JSONL stream. Its history is a tree with
a durable leaf, orphan roots, branch summaries, and two compaction forms, so
the active context is something the format states rather than something line
order implies. The adapter keeps those semantics inside itself and projects
the result into the existing canonical tables.

Sessions are keyed by (normalized header cwd, header id) rather than by path,
because Pi's --session-id lookup is project-local: two projects may reuse an
id, while a move or an identical copy is still one session. Discovery covers
both layouts Pi writes and fingerprints each file by mtime, ctime, size and
inode, so a rewrite that preserves mtime is not read as unchanged.

Abandoned branches are preserved rather than dropped. Visibility becomes
three-state -- visible, inactive, hidden -- and helpers return only visible
rows until includeInactive asks for the superseded path, labeling every row
so a caller knows which it holds. Usage counts all three, because an
abandoned call still spent tokens; message_count reports only the visible
transcript.

A committed MIT-licensed oracle transcribed from Pi 0.83.0 pins the context
algorithms, and a fixed-seed differential runs 512 generated sessions against
it on every test run. Schema changes are additive.
This commit is contained in:
SaladDay
2026-08-04 23:33:01 +08:00
committed by GitHub
parent 1941e64572
commit 2589384e68
63 changed files with 7796 additions and 374 deletions
+20 -11
View File
@@ -9,7 +9,7 @@
[![version](https://img.shields.io/github/v/tag/tommy0103/obelisk?label=version&style=flat-square)](https://github.com/tommy0103/obelisk/releases) [![version](https://img.shields.io/github/v/tag/tommy0103/obelisk?label=version&style=flat-square)](https://github.com/tommy0103/obelisk/releases)
[![license](https://img.shields.io/badge/license-AGPL--3.0-blue.svg?style=flat-square)](LICENSE) [![license](https://img.shields.io/badge/license-AGPL--3.0-blue.svg?style=flat-square)](LICENSE)
Past Claude Code, Codex, and Kimi Code sessions -- queryable by your agent, browsable by you. Past Claude Code, Codex, Kimi Code, and Pi sessions -- queryable by your agent, browsable by you.
</div> </div>
@@ -25,7 +25,7 @@ The agent writes JS queries, runs them locally, and answers in plain language.
**App side** — an Electron desktop app for humans to browse sessions, manage memories, view usage stats, and see weekly recap cards. **App side** — an Electron desktop app for humans to browse sessions, manage memories, view usage stats, and see weekly recap cards.
Both read from the same `~/.obelisk/obelisk.sqlite` database. The indexer reads Claude Code transcripts from `~/.claude/projects`, Codex transcripts from `~/.codex/sessions`, and Kimi Code sessions from `~/.kimi-code/sessions` (or `$KIMI_CODE_HOME/sessions`). Both read from the same `~/.obelisk/obelisk.sqlite` database. The indexer reads Claude Code transcripts from `~/.claude/projects`, Codex transcripts from `~/.codex/sessions`, Kimi Code sessions from `~/.kimi-code/sessions` (or `$KIMI_CODE_HOME/sessions`), and Pi sessions from `~/.pi/agent/sessions`.
## Multi-provider support ## Multi-provider support
@@ -38,7 +38,20 @@ Kimi session directories become one Obelisk session each. Main and child-agent
subagents tables. Undo/clear is handled as a full session replay, so retracted subagents tables. Undo/clear is handled as a full session replay, so retracted
wire records do not remain in the index. wire records do not remain in the index.
For live app refresh, Obelisk watches the roots declared by every registered provider, including `~/.claude/projects`, `~/.codex/sessions`, and `~/.kimi-code/sessions`. Codex's `session_index.jsonl` is used as lightweight title/update metadata during indexing, not as the message transcript source. Pi JSONL v1-v3 sessions are projected through the same provider contract. Pi's tree, branch summaries, compactions, durable leaf, retained checkpoint tail, custom messages, bash records, tool calls, token usage, and raw JSONL evidence stay inside the adapter; no Pi-specific database or renderer branch is needed. Active visibility follows Pi's own context rules: a retained tail replaces pre-compaction ancestors even when those physical entries still exist and bounds any later legacy compaction, while a legacy-only chain retains ancestors beginning at `firstKeptEntryId`. Missing parents form orphan branch roots, matching Pi's recovery behavior. Pi entries that the source explicitly superseded are stored as `inactive`: the app and normal agent queries omit them, while supported query helpers can include them with `includeInactive: true`. Display-suppressed or transport-only records remain `hidden` and are never returned by those helpers.
| Provider | Superseded-history support |
| --- | --- |
| Pi | Branch, leaf, and compaction state attests inactive history |
| Kimi Code | Undo/clear can attest supersession; preservation is a follow-up |
| Claude Code | The source does not attest rewind or current-leaf state |
| Codex | Sessions have no branching semantics |
Because Pi's explicit `--session-id` is project-local, Obelisk combines the header ID with a deterministic hash of the normalized header `cwd`; this keeps the identity stable across file moves and v1-v3 migration while allowing two projects to use the same custom ID. Replacement and deletion replay is provenance-aware, so stale session snapshots are retracted atomically; compaction and branch-summary model usage is included in usage totals.
For live app refresh, Obelisk watches the roots declared by every registered provider, including `~/.claude/projects`, `~/.codex/sessions`, `~/.kimi-code/sessions`, and `~/.pi/agent/sessions`. Codex's `session_index.jsonl` is used as lightweight title/update metadata during indexing, not as the message transcript source.
Pi chooses its session directory in this order: `--session-dir`, `PI_CODING_AGENT_SESSION_DIR`, `sessionDir` in settings, then the default under `~/.pi/agent/sessions`. Obelisk automatically follows absolute or `~`-prefixed environment/global settings and the project setting for Obelisk's launch cwd; a relative project setting is resolved against that cwd. CLI-only roots, relative environment/global settings, and project settings from another launch cwd cannot be inferred safely, so select the resolved directory in Obelisk **Settings** instead of letting Obelisk guess.
## Skill: agent-first retrieval ## Skill: agent-first retrieval
@@ -155,12 +168,7 @@ npm ci
npm run dev npm run dev
``` ```
`electron-vite` starts the renderer dev server and launches Electron. On first `electron-vite` starts the renderer dev server and launches Electron. On first run, Obelisk creates `~/.obelisk/obelisk.sqlite`, indexes the available registered-provider transcripts, and then watches them for changes. The default sources include `~/.claude/projects`, `~/.codex/sessions`, `~/.kimi-code/sessions`, and `~/.pi/agent/sessions`; use **Settings** to point the app at different directories. On Windows, Obelisk also checks common WSL distributions for the Claude Code directory.
run, Obelisk creates `~/.obelisk/obelisk.sqlite`, indexes the available Claude
Code and Codex transcripts, and then watches them for changes. The default
sources are `~/.claude/projects` and `~/.codex/sessions`; use **Settings** to
point the app at different directories. On Windows, Obelisk also checks common
WSL distributions for the Claude Code directory.
### Debug the app ### Debug the app
@@ -184,7 +192,7 @@ run `npm ci` again.
| Layer | Source | What's captured | | Layer | Source | What's captured |
|-------|--------|----------------| |-------|--------|----------------|
| **Sessions** | Claude `<project>/<sessionId>.jsonl`; Codex `sessions/YYYY/MM/DD/*.jsonl` | Title, project, timestamps, git branch, source | | **Sessions** | Claude `<project>/<sessionId>.jsonl`; Codex `sessions/YYYY/MM/DD/*.jsonl`; Kimi session directories; Pi recursive `*.jsonl` | Title, project, timestamps, git branch, source |
| **Messages** | user + assistant turns | Full text, model, token usage, parent chain | | **Messages** | user + assistant turns | Full text, model, token usage, parent chain |
| **Tool calls** | every tool invocation | Tool name, input, file paths | | **Tool calls** | every tool invocation | Tool name, input, file paths |
| **Subagents** | Claude `subagents/agent-<id>.jsonl`; Codex child threads | Agent type, description, full conversation | | **Subagents** | Claude `subagents/agent-<id>.jsonl`; Codex child threads | Agent type, description, full conversation |
@@ -203,7 +211,8 @@ packages/core/ # @obelisk/core npm workspace (TypeScript + ESM)
│ │ ├── types.ts # Provider + TranscriptRecord contract │ │ ├── types.ts # Provider + TranscriptRecord contract
│ │ ├── claude.ts # Claude Code adapter (line-incremental) │ │ ├── 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) │ │ ── kimi.ts # Kimi Code adapter (session projection)
│ │ └── pi.ts # Pi adapter (tree-aware full-reparse)
│ ├── session-detail.ts # Provider-independent transcript projection │ ├── session-detail.ts # Provider-independent transcript projection
│ ├── persist.ts # Binding-agnostic record writer (upsert/merge) │ ├── persist.ts # Binding-agnostic record writer (upsert/merge)
│ ├── tx.ts # Write transaction + connection config │ ├── tx.ts # Write transaction + connection config
+140 -36
View File
@@ -13,10 +13,14 @@ import { buildEditorUrl, DEFAULT_EDITOR_SCHEME, EDITOR_SCHEMES, resolveFileRefer
import { acquireWriterLease, writerLockPathFor } from '../../../packages/core/src/writer-lease.ts'; import { acquireWriterLease, writerLockPathFor } from '../../../packages/core/src/writer-lease.ts';
import { migrateCoreSchemaColumns } from '../../../packages/core/src/schema-migrations.ts'; import { migrateCoreSchemaColumns } from '../../../packages/core/src/schema-migrations.ts';
import { createBuiltinProviderRegistry } from '../../../packages/core/src/providers/builtins.ts'; import { createBuiltinProviderRegistry } from '../../../packages/core/src/providers/builtins.ts';
import {
createConfiguredBuiltinProviderRuntime,
readPersistedProviderSettings,
} from '../../../packages/core/src/provider-settings.ts';
import { import {
buildSourceCatalog, buildSourceCatalog,
resolveProviderRoots,
setPersistedSetting, setPersistedSetting,
type ProviderSourceIssue,
} from './provider-settings.ts'; } from './provider-settings.ts';
import type { import type {
SessionPatchCursor, SessionPatchCursor,
@@ -66,6 +70,8 @@ const DEFAULT_CODEX_DIR = path.join(os.homedir(), '.codex');
let db; let db;
let indexerService; let indexerService;
let indexerWorker; let indexerWorker;
let latestSourceIssues: ProviderSourceIssue[] = [];
let latestSettingsError: string | null = null;
type WriterLeaseMode = 'acquire' | 'caller-held'; type WriterLeaseMode = 'acquire' | 'caller-held';
@@ -78,16 +84,19 @@ function acquireAppWriterLease(dbPath: string, waitMs = 0) {
} }
function getRuntimePaths(persisted = loadPersistedSettings()) { function getRuntimePaths(persisted = loadPersistedSettings()) {
const defaultRegistry = createBuiltinProviderRegistry({ const runtime = createConfiguredBuiltinProviderRuntime(persisted, {
claude: DEFAULT_CLAUDE_DIR, baseRoots: {
codex: DEFAULT_CODEX_DIR, claude: DEFAULT_CLAUDE_DIR,
codex: DEFAULT_CODEX_DIR,
},
}); });
const providerRoots = resolveProviderRoots(defaultRegistry, persisted); const providerRoots = runtime.roots;
const providerRegistry = createBuiltinProviderRegistry(providerRoots); const providerRegistry = runtime.registry;
const claudeDir = providerRoots['claude'] ?? DEFAULT_CLAUDE_DIR; const claudeDir = providerRoots['claude'] ?? DEFAULT_CLAUDE_DIR;
const codexDir = providerRoots['codex'] ?? DEFAULT_CODEX_DIR; const codexDir = providerRoots['codex'] ?? DEFAULT_CODEX_DIR;
return { return {
providerRoots, providerRoots,
providerSettings: persisted,
providerRegistry, providerRegistry,
claudeDir, claudeDir,
codexDir, codexDir,
@@ -209,11 +218,37 @@ function runAppDbWrite(work: () => void): boolean {
} }
} }
function notifyIndexUpdated(result: { affectedSessionIds?: unknown } = {}) { function notifyIndexUpdated(result: {
affectedSessionIds?: unknown;
inventoryIssues?: unknown;
skippedFiles?: unknown;
} = {}) {
const affectedSessionIds = Array.isArray(result.affectedSessionIds) const affectedSessionIds = Array.isArray(result.affectedSessionIds)
? [...new Set(result.affectedSessionIds.filter(Boolean))] ? [...new Set(result.affectedSessionIds.filter(Boolean))]
: []; : [];
const payload = { affectedSessionIds }; const issueLists = [result.inventoryIssues, result.skippedFiles]
.filter(Array.isArray)
.flat();
if (Array.isArray(result.inventoryIssues) || Array.isArray(result.skippedFiles)) {
const unique = new Map<string, ProviderSourceIssue>();
for (const value of issueLists) {
const issue = value as Partial<ProviderSourceIssue> | null;
if (
issue !== null
&& typeof issue.provider === 'string'
&& typeof issue.path === 'string'
&& typeof issue.error === 'string'
) {
unique.set(`${issue.provider}\0${issue.path}\0${issue.error}`, {
provider: issue.provider,
path: issue.path,
error: issue.error,
});
}
}
latestSourceIssues = [...unique.values()];
}
const payload = { affectedSessionIds, sourceIssues: latestSourceIssues };
for (const win of BrowserWindow.getAllWindows()) { for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send('obelisk:index-updated', payload); win.webContents.send('obelisk:index-updated', payload);
for (const sessionId of affectedSessionIds) { for (const sessionId of affectedSessionIds) {
@@ -235,6 +270,7 @@ function appendWhere(sql, params, clause) {
function startIndexerService({ buildOnStart = false } = {}) { function startIndexerService({ buildOnStart = false } = {}) {
const paths = getRuntimePaths(); const paths = getRuntimePaths();
if (latestSettingsError !== null) return null;
migrateLegacyDbIfNeeded(paths); migrateLegacyDbIfNeeded(paths);
indexerService = createIndexerService({ indexerService = createIndexerService({
projectsDir: paths.projectsDir, projectsDir: paths.projectsDir,
@@ -244,13 +280,18 @@ function startIndexerService({ buildOnStart = false } = {}) {
reason, reason,
changedPaths, changedPaths,
providerRoots: paths.providerRoots, providerRoots: paths.providerRoots,
providerSettings: paths.providerSettings,
claudeDir: paths.claudeDir, claudeDir: paths.claudeDir,
codexDir: paths.codexDir, codexDir: paths.codexDir,
projectsDir: paths.projectsDir, projectsDir: paths.projectsDir,
dbPath: paths.dbPath, dbPath: paths.dbPath,
}); });
if (result?.deferred) { if (result?.deferred) {
if (Array.isArray(result.affectedSessionIds) && result.affectedSessionIds.length) { if (
(Array.isArray(result.affectedSessionIds) && result.affectedSessionIds.length)
|| (Array.isArray(result.inventoryIssues) && result.inventoryIssues.length)
|| (Array.isArray(result.skippedFiles) && result.skippedFiles.length)
) {
notifyIndexUpdated(result); notifyIndexUpdated(result);
} }
} else { } else {
@@ -268,11 +309,11 @@ function startIndexerService({ buildOnStart = false } = {}) {
function startBackgroundResources({ runStartupBuild = false } = {}) { function startBackgroundResources({ runStartupBuild = false } = {}) {
if (!indexerWorker) indexerWorker = createWorkerBuildIndex(); if (!indexerWorker) indexerWorker = createWorkerBuildIndex();
const paths = getRuntimePaths(); const paths = getRuntimePaths();
migrateLegacyDbIfNeeded(paths); if (latestSettingsError === null) migrateLegacyDbIfNeeded(paths);
openDb(paths.dbPath); openDb(paths.dbPath);
if (!indexerService) { if (!indexerService && latestSettingsError === null) {
const service = startIndexerService({ buildOnStart: false }); const service = startIndexerService({ buildOnStart: false });
if (runStartupBuild) service.runBuildNow('startup'); if (runStartupBuild) service?.runBuildNow('startup');
} }
if (!obeliskWatcher) startObeliskWatcher(); if (!obeliskWatcher) startObeliskWatcher();
} }
@@ -440,18 +481,29 @@ function querySessionMessages(sessionId: string): SessionMessageRow[] {
SELECT m.uuid, m.session_id, m.type, m.parent_uuid, m.timestamp, m.role, m.text, m.model, 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.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.visibility, 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 FROM messages m
WHERE m.session_id = ? AND m.agent_id IS NULL
AND COALESCE(m.visibility, 'visible') = 'visible'
ORDER BY m.timestamp, m.uuid
`).all(sessionId) as SessionMessageRow[]; `).all(sessionId) as SessionMessageRow[];
} }
function querySessionToolCalls(sessionId: string): SessionToolCallRow[] { function querySessionToolCalls(sessionId: string): SessionToolCallRow[] {
if (!db) return []; if (!db) return [];
return db.prepare(`SELECT * FROM tool_calls WHERE session_id = ?`).all(sessionId) as SessionToolCallRow[]; return db.prepare(`
SELECT tc.* FROM tool_calls tc
JOIN messages m ON m.uuid = tc.message_uuid
WHERE tc.session_id = ? AND COALESCE(m.visibility, 'visible') = 'visible'
`).all(sessionId) as SessionToolCallRow[];
} }
function querySessionToolResults(sessionId: string): SessionToolResultRow[] { function querySessionToolResults(sessionId: string): SessionToolResultRow[] {
if (!db) return []; if (!db) return [];
return db.prepare(`SELECT * FROM tool_results WHERE session_id = ?`).all(sessionId) as SessionToolResultRow[]; return db.prepare(`
SELECT tr.* FROM tool_results tr
JOIN messages m ON m.uuid = tr.message_uuid
WHERE tr.session_id = ? AND COALESCE(m.visibility, 'visible') = 'visible'
`).all(sessionId) as SessionToolResultRow[];
} }
function querySessionSubagents(sessionId: string): SessionSubagentRow[] { function querySessionSubagents(sessionId: string): SessionSubagentRow[] {
@@ -470,7 +522,10 @@ function querySessionWorkflows(sessionId: string): SessionWorkflowRow[] {
function querySessionSummaries(sessionId: string): SessionSummaryRow[] { function querySessionSummaries(sessionId: string): SessionSummaryRow[] {
if (!db) return []; if (!db) return [];
return db.prepare(`SELECT * FROM summaries WHERE session_id = ?`).all(sessionId) as SessionSummaryRow[]; return db.prepare(`
SELECT * FROM summaries
WHERE session_id = ? AND COALESCE(visibility, 'visible') = 'visible'
`).all(sessionId) as SessionSummaryRow[];
} }
function querySessionSnapshot(sessionId: string): SessionDetailAssemblyInput { function querySessionSnapshot(sessionId: string): SessionDetailAssemblyInput {
@@ -569,7 +624,9 @@ ipcMain.handle('db:getSubagentMessages', (_, agentId) => {
SELECT m.uuid, m.session_id, m.type, m.parent_uuid, m.timestamp, m.role, m.text, m.model, 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.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.visibility, 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 FROM messages m
WHERE m.agent_id = ? AND COALESCE(m.visibility, 'visible') = 'visible'
ORDER BY m.timestamp, m.uuid
`).all(agentId); `).all(agentId);
}); });
@@ -578,7 +635,7 @@ ipcMain.handle('db:getSubagentToolCalls', (_, agentId) => {
return db.prepare(` return db.prepare(`
SELECT tc.* FROM tool_calls tc SELECT tc.* FROM tool_calls tc
JOIN messages m ON m.uuid = tc.message_uuid JOIN messages m ON m.uuid = tc.message_uuid
WHERE m.agent_id = ? WHERE m.agent_id = ? AND COALESCE(m.visibility, 'visible') = 'visible'
`).all(agentId); `).all(agentId);
}); });
@@ -587,7 +644,7 @@ ipcMain.handle('db:getSubagentToolResults', (_, agentId) => {
return db.prepare(` return db.prepare(`
SELECT tr.* FROM tool_results tr SELECT tr.* FROM tool_results tr
JOIN messages m ON m.uuid = tr.message_uuid JOIN messages m ON m.uuid = tr.message_uuid
WHERE m.agent_id = ? WHERE m.agent_id = ? AND COALESCE(m.visibility, 'visible') = 'visible'
`).all(agentId); `).all(agentId);
}); });
@@ -606,7 +663,7 @@ ipcMain.handle('db:getMemories', () => {
ipcMain.handle('db:getMessageFullText', (_, uuid) => { ipcMain.handle('db:getMessageFullText', (_, uuid) => {
if (!db) return null; if (!db) return null;
const msg = db.prepare('SELECT * FROM messages WHERE uuid=?').get(uuid); const msg = db.prepare('SELECT * FROM messages WHERE uuid=?').get(uuid);
if (!msg) return null; if (!msg || (msg.visibility ?? 'visible') !== 'visible') return null;
const session = db.prepare('SELECT * FROM sessions WHERE id=?').get(msg.session_id) ?? null; const session = db.prepare('SELECT * FROM sessions WHERE id=?').get(msg.session_id) ?? null;
const subagent = msg.agent_id const subagent = msg.agent_id
? db.prepare('SELECT * FROM subagents WHERE agent_id=?').get(msg.agent_id) ?? null ? db.prepare('SELECT * FROM subagents WHERE agent_id=?').get(msg.agent_id) ?? null
@@ -614,12 +671,16 @@ ipcMain.handle('db:getMessageFullText', (_, uuid) => {
const workflowAgent = msg.agent_id const workflowAgent = msg.agent_id
? db.prepare('SELECT * FROM workflow_agents WHERE agent_id=?').get(msg.agent_id) ?? null ? db.prepare('SELECT * FROM workflow_agents WHERE agent_id=?').get(msg.agent_id) ?? null
: null; : null;
const cursorRow = typeof session?.jsonl_path === 'string'
? db.prepare('SELECT cursor FROM index_state WHERE jsonl_path=?').get(session.jsonl_path)
: undefined;
const paths = getRuntimePaths(); const paths = getRuntimePaths();
const raw = paths.providerRegistry.raw({ const raw = paths.providerRegistry.raw({
source: msg.source || session?.source || 'claude', source: msg.source || session?.source || 'claude',
messageUuid: String(uuid), messageUuid: String(uuid),
session, session,
agentId: msg.agent_id || null, agentId: msg.agent_id || null,
cursor: typeof cursorRow?.cursor === 'string' ? cursorRow.cursor : null,
subagent, subagent,
workflowAgent, workflowAgent,
}); });
@@ -641,7 +702,9 @@ function querySessionFileRoots(sessionId: unknown): string[] {
const roots: string[] = []; const roots: string[] = [];
try { try {
const rows = db.prepare( const rows = db.prepare(
`SELECT DISTINCT cwd FROM messages WHERE session_id = ? AND cwd IS NOT NULL AND cwd != ''` `SELECT DISTINCT cwd FROM messages
WHERE session_id = ? AND cwd IS NOT NULL AND cwd != ''
AND COALESCE(visibility, 'visible') = 'visible'`
).all(sessionId); ).all(sessionId);
for (const row of rows) roots.push(row.cwd); for (const row of rows) roots.push(row.cwd);
const session = db.prepare(`SELECT project_path FROM sessions WHERE id = ?`).get(sessionId); const session = db.prepare(`SELECT project_path FROM sessions WHERE id = ?`).get(sessionId);
@@ -659,7 +722,12 @@ ipcMain.handle('file-ref:open', async (_, ref) => {
if (!filePath) return { opened: false }; if (!filePath) return { opened: false };
const { editorScheme } = loadPersistedSettings(); const { editorScheme } = loadPersistedSettings();
try { try {
await shell.openExternal(buildEditorUrl({ scheme: editorScheme, filePath, line, column })); await shell.openExternal(buildEditorUrl({
scheme: typeof editorScheme === 'string' ? editorScheme : undefined,
filePath,
line,
column,
}));
return { opened: true, path: filePath }; return { opened: true, path: filePath };
} catch { } catch {
return { opened: false, path: filePath }; return { opened: false, path: filePath };
@@ -705,11 +773,25 @@ ipcMain.handle('db:getUsageStats', (_, opts = {}) => {
if (!db) return { daily: [], totalTokens: 0, peakDay: null, longestTurn: null }; if (!db) return { daily: [], totalTokens: 0, peakDay: null, longestTurn: null };
const sourceFilter = sourceWhereClause(opts, 'source'); const sourceFilter = sourceWhereClause(opts, 'source');
const sourceSql = sourceFilter.sql ? `AND ${sourceFilter.sql}` : ''; const sourceSql = sourceFilter.sql ? `AND ${sourceFilter.sql}` : '';
const usageEvents = `
WITH usage_events AS (
SELECT timestamp, input_tokens, output_tokens, COALESCE(source, 'claude') AS source
FROM messages
UNION ALL
SELECT su.timestamp, su.input_tokens, su.output_tokens,
COALESCE(s.source, 'claude') AS source
FROM summaries su
LEFT JOIN sessions s ON s.id = su.session_id
)
`;
// Visibility controls evidence display, not accounting. Abandoned model calls
// still consumed tokens, so aggregate usage intentionally includes them.
const daily = db.prepare(` const daily = db.prepare(`
${usageEvents}
SELECT DATE(timestamp) as day, SELECT DATE(timestamp) as day,
SUM(COALESCE(input_tokens,0) + COALESCE(output_tokens,0)) as tokens SUM(COALESCE(input_tokens,0) + COALESCE(output_tokens,0)) as tokens
FROM messages FROM usage_events
WHERE timestamp IS NOT NULL AND (input_tokens IS NOT NULL OR output_tokens IS NOT NULL) WHERE timestamp IS NOT NULL AND (input_tokens IS NOT NULL OR output_tokens IS NOT NULL)
${sourceSql} ${sourceSql}
GROUP BY DATE(timestamp) GROUP BY DATE(timestamp)
@@ -717,15 +799,17 @@ ipcMain.handle('db:getUsageStats', (_, opts = {}) => {
`).all(...sourceFilter.params); `).all(...sourceFilter.params);
const totalTokens = db.prepare(` const totalTokens = db.prepare(`
${usageEvents}
SELECT SUM(COALESCE(input_tokens,0) + COALESCE(output_tokens,0)) as total SELECT SUM(COALESCE(input_tokens,0) + COALESCE(output_tokens,0)) as total
FROM messages FROM usage_events
${sourceFilter.sql ? `WHERE ${sourceFilter.sql}` : ''} ${sourceFilter.sql ? `WHERE ${sourceFilter.sql}` : ''}
`).get(...sourceFilter.params)?.total || 0; `).get(...sourceFilter.params)?.total || 0;
const peakDay = db.prepare(` const peakDay = db.prepare(`
${usageEvents}
SELECT DATE(timestamp) as day, SELECT DATE(timestamp) as day,
SUM(COALESCE(input_tokens,0) + COALESCE(output_tokens,0)) as tokens SUM(COALESCE(input_tokens,0) + COALESCE(output_tokens,0)) as tokens
FROM messages FROM usage_events
WHERE timestamp IS NOT NULL AND (input_tokens IS NOT NULL OR output_tokens IS NOT NULL) WHERE timestamp IS NOT NULL AND (input_tokens IS NOT NULL OR output_tokens IS NOT NULL)
${sourceSql} ${sourceSql}
GROUP BY DATE(timestamp) GROUP BY DATE(timestamp)
@@ -837,15 +921,25 @@ ipcMain.handle('recap:read', (_, filename) => {
const SETTINGS_PATH = path.join(OBELISK_DIR, 'settings.json'); const SETTINGS_PATH = path.join(OBELISK_DIR, 'settings.json');
function loadPersistedSettings() { function loadPersistedSettings() {
try { const result = readPersistedProviderSettings(SETTINGS_PATH);
if (fs.existsSync(SETTINGS_PATH)) return JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf-8')); latestSettingsError = result.ok ? null : result.error ?? 'Obelisk settings are unavailable';
} catch {} if (latestSettingsError !== null) console.warn(latestSettingsError);
return {}; return result.settings;
} }
function savePersistedSettings(settings) { function savePersistedSettings(settings) {
if (!fs.existsSync(OBELISK_DIR)) fs.mkdirSync(OBELISK_DIR, { recursive: true }); if (!fs.existsSync(OBELISK_DIR)) fs.mkdirSync(OBELISK_DIR, { recursive: true });
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2)); const temporaryPath = `${SETTINGS_PATH}.${process.pid}.tmp`;
try {
fs.writeFileSync(temporaryPath, JSON.stringify(settings, null, 2));
fs.renameSync(temporaryPath, SETTINGS_PATH);
latestSettingsError = null;
} catch (error) {
try {
fs.unlinkSync(temporaryPath);
} catch {}
throw error;
}
} }
ipcMain.handle('settings:get', () => { ipcMain.handle('settings:get', () => {
@@ -878,11 +972,13 @@ ipcMain.handle('settings:get', () => {
registry: providerRegistry, registry: providerRegistry,
roots: providerRoots, roots: providerRoots,
stats: sourceStats, stats: sourceStats,
sourceIssues: latestSourceIssues,
pathExists: fs.existsSync, pathExists: fs.existsSync,
}); });
const sessionCount = sources.reduce((sum, source) => sum + source.sessionCount, 0); const sessionCount = sources.reduce((sum, source) => sum + source.sessionCount, 0);
const lastIndexed = sources.map((source) => source.lastIndexed).filter(Boolean).sort().at(-1) || ''; const lastIndexed = sources.map((source) => source.lastIndexed).filter(Boolean).sort().at(-1) || '';
const connected = sources.some((source) => source.status !== 'error'); const connected = latestSettingsError === null
&& sources.some((source) => source.status !== 'error');
return { return {
version: app.getVersion(), version: app.getVersion(),
@@ -898,7 +994,7 @@ ipcMain.handle('settings:get', () => {
sessionCount, sessionCount,
lastIndexed, lastIndexed,
status: connected ? 'ok' : 'error', status: connected ? 'ok' : 'error',
statusText: connected ? 'Connected' : 'No source folders found', statusText: latestSettingsError ?? (connected ? 'Connected' : 'No source folders found'),
}; };
}); });
@@ -910,8 +1006,8 @@ ipcMain.handle('settings:set', async (_, key, value) => {
if (key === 'autoRefresh') { if (key === 'autoRefresh') {
if (value === false && indexerService) { if (value === false && indexerService) {
await stopIndexerServiceAndWait(); await stopIndexerServiceAndWait();
} else if (value !== false && indexerService) { } else if (value !== false) {
await stopIndexerServiceAndWait(); if (indexerService) await stopIndexerServiceAndWait();
startIndexerService({ buildOnStart: false }); startIndexerService({ buildOnStart: false });
} }
} }
@@ -928,7 +1024,7 @@ ipcMain.handle('settings:set', async (_, key, value) => {
if (persisted.autoRefresh !== false) { if (persisted.autoRefresh !== false) {
startIndexerService({ buildOnStart: true }); startIndexerService({ buildOnStart: true });
} }
notifyIndexUpdated(); notifyIndexUpdated({ inventoryIssues: [] });
} }
return true; return true;
}); });
@@ -951,6 +1047,7 @@ ipcMain.handle('settings:revealPath', (_, p) => {
ipcMain.handle('settings:rebuildIndex', async () => { ipcMain.handle('settings:rebuildIndex', async () => {
if (!indexerWorker) return null; if (!indexerWorker) return null;
const persisted = loadPersistedSettings(); const persisted = loadPersistedSettings();
if (latestSettingsError !== null) throw new Error(latestSettingsError);
const paths = getRuntimePaths(persisted); const paths = getRuntimePaths(persisted);
const tempDbPath = rebuildTempDbPath(paths.dbPath); const tempDbPath = rebuildTempDbPath(paths.dbPath);
const shouldRestartWatcher = persisted.autoRefresh !== false; const shouldRestartWatcher = persisted.autoRefresh !== false;
@@ -977,6 +1074,9 @@ ipcMain.handle('settings:rebuildIndex', async () => {
skipped: 0, skipped: 0,
skippedFiles: [], skippedFiles: [],
deferred: true, deferred: true,
complete: false,
incompleteProviders: [],
inventoryIssues: [],
reason: 'writer_busy', reason: 'writer_busy',
}; };
} }
@@ -985,6 +1085,7 @@ ipcMain.handle('settings:rebuildIndex', async () => {
reason: 'manual-rebuild', reason: 'manual-rebuild',
force: true, force: true,
providerRoots: paths.providerRoots, providerRoots: paths.providerRoots,
providerSettings: paths.providerSettings,
claudeDir: paths.claudeDir, claudeDir: paths.claudeDir,
codexDir: paths.codexDir, codexDir: paths.codexDir,
projectsDir: paths.projectsDir, projectsDir: paths.projectsDir,
@@ -993,7 +1094,10 @@ ipcMain.handle('settings:rebuildIndex', async () => {
writerLeasePath, writerLeasePath,
writerLeaseMode: 'caller-held', writerLeaseMode: 'caller-held',
}); });
if (result?.deferred) return result; if (result?.deferred || result?.complete !== true) {
notifyIndexUpdated(result);
return result;
}
closeDb(); closeDb();
replaceDbWithTemp(tempDbPath, paths.dbPath); replaceDbWithTemp(tempDbPath, paths.dbPath);
openDb(paths.dbPath, { writerLeaseMode: 'caller-held' }); openDb(paths.dbPath, { writerLeaseMode: 'caller-held' });
+65 -12
View File
@@ -21,10 +21,17 @@ interface Timers {
interface Watcher { interface Watcher {
close(): unknown; close(): unknown;
refreshMissingRoots?(): boolean;
} }
interface IndexerBuildResult { interface IndexerBuildResult {
deferred?: boolean; deferred?: boolean;
complete?: boolean;
inventoryIssues?: Array<{
provider: string;
path: string;
error: string;
}>;
} }
type IndexerBuild = (args: { type IndexerBuild = (args: {
@@ -42,7 +49,7 @@ interface IndexerServiceOptions {
deferredRetryMs?: number; deferredRetryMs?: number;
buildIndex?: IndexerBuild; buildIndex?: IndexerBuild;
writeHeartbeat?: () => unknown; writeHeartbeat?: () => unknown;
watchProjects?: (onChange: (changedPath: string) => void) => Watcher | null; watchProjects?: (onChange: (changedPath?: string) => void) => Watcher | null;
chokidar?: any; chokidar?: any;
timers?: Timers; timers?: Timers;
logger?: { warn?: (msg: string) => void }; logger?: { warn?: (msg: string) => void };
@@ -71,10 +78,11 @@ function createIndexerService({
if (typeof buildIndex !== 'function') throw new Error('createIndexerService() requires buildIndex'); if (typeof buildIndex !== 'function') throw new Error('createIndexerService() requires buildIndex');
const watch = watchProjects || ((onChange) => { const watch = watchProjects || ((onChange) => {
const roots = [...new Set((Array.isArray(watchDirs) ? watchDirs : [watchDirs]).filter(Boolean))]; const roots = [...new Set((Array.isArray(watchDirs) ? watchDirs : [watchDirs]).filter(Boolean))];
const existingRoots = roots.filter(root => fs.existsSync(root)); if (!roots.length) return null;
if (!existingRoots.length) return null;
const watchers: any[] = []; const watchers: any[] = [];
for (const root of existingRoots) { const watchedRoots = new Set<string>();
const addRoot = (root: string) => {
if (watchedRoots.has(root) || !fs.existsSync(root)) return false;
const onFileChange = (filename) => { const onFileChange = (filename) => {
const name = filename ? String(filename) : ''; const name = filename ? String(filename) : '';
if (!name || name.endsWith('.jsonl') || name.endsWith('.json')) { if (!name || name.endsWith('.jsonl') || name.endsWith('.json')) {
@@ -102,11 +110,25 @@ function createIndexerService({
logger.warn?.(`Obelisk watcher failed: ${(error as Error).message}`); logger.warn?.(`Obelisk watcher failed: ${(error as Error).message}`);
}); });
watchers.push(watcher); watchers.push(watcher);
} watchedRoots.add(root);
return true;
};
const refreshMissingRoots = (notify: boolean) => {
let added = false;
for (const root of roots) {
if (addRoot(root)) added = true;
}
if (added && notify) onChange();
return watchedRoots.size === roots.length;
};
refreshMissingRoots(false);
return { return {
close() { close() {
return Promise.all(watchers.map(w => Promise.resolve(w.close?.()))); return Promise.all(watchers.map(w => Promise.resolve(w.close?.())));
}, },
refreshMissingRoots() {
return refreshMissingRoots(true);
},
}; };
}); });
@@ -121,18 +143,29 @@ function createIndexerService({
let pending = false; let pending = false;
let lastReason: string | null = null; let lastReason: string | null = null;
let changedPaths = new Set<string>(); let changedPaths = new Set<string>();
let fullInventoryPending = false;
let idlePromise = Promise.resolve(); let idlePromise = Promise.resolve();
const requestFullInventory = () => {
fullInventoryPending = true;
changedPaths.clear();
};
const addChangedPath = (changedPath?: string | string[]) => { const addChangedPath = (changedPath?: string | string[]) => {
if (Array.isArray(changedPath)) { if (Array.isArray(changedPath)) {
for (const p of changedPath) addChangedPath(p); for (const p of changedPath) addChangedPath(p);
return; return;
} }
const name = changedPath ? String(changedPath) : ''; const name = changedPath ? String(changedPath) : '';
if (name) changedPaths.add(name); if (name && !fullInventoryPending) changedPaths.add(name);
}; };
const takeChangedPaths = () => { const takeChangedPaths = () => {
if (fullInventoryPending) {
fullInventoryPending = false;
changedPaths.clear();
return undefined;
}
if (!changedPaths.size) return undefined; if (!changedPaths.size) return undefined;
const paths = [...changedPaths]; const paths = [...changedPaths];
changedPaths = new Set(); changedPaths = new Set();
@@ -160,8 +193,16 @@ function createIndexerService({
const buildChangedPaths = takeChangedPaths(); const buildChangedPaths = takeChangedPaths();
idlePromise = (async () => { idlePromise = (async () => {
const result = await buildIndex({ reason, changedPaths: buildChangedPaths }); const result = await buildIndex({ reason, changedPaths: buildChangedPaths });
if (result?.complete === false) {
for (const issue of result.inventoryIssues ?? []) {
logger.warn?.(
`Obelisk indexed a partial ${issue.provider} inventory at ${issue.path}: ${issue.error}`,
);
}
}
if (result?.deferred) { if (result?.deferred) {
addChangedPath(buildChangedPaths); if (buildChangedPaths === undefined) requestFullInventory();
else addChangedPath(buildChangedPaths);
if (!stopped && !deferredRetryTimer) { if (!stopped && !deferredRetryTimer) {
deferredRetryTimer = timers.setTimeout(() => { deferredRetryTimer = timers.setTimeout(() => {
deferredRetryTimer = null; deferredRetryTimer = null;
@@ -189,7 +230,8 @@ function createIndexerService({
const scheduleBuild = (reason = "change", changedPath: string | undefined = undefined) => { const scheduleBuild = (reason = "change", changedPath: string | undefined = undefined) => {
if (stopped) return; if (stopped) return;
addChangedPath(changedPath); if (changedPath === undefined) requestFullInventory();
else addChangedPath(changedPath);
lastReason = reason; lastReason = reason;
if (running) pending = true; if (running) pending = true;
if (deferredRetryTimer) timers.clearTimeout(deferredRetryTimer); if (deferredRetryTimer) timers.clearTimeout(deferredRetryTimer);
@@ -209,14 +251,25 @@ function createIndexerService({
}, debounceMs); }, debounceMs);
}; };
const scheduleWatchRetry = () => {
if (stopped || watchRetryTimer) return;
watchRetryTimer = timers.setTimeout(() => {
watchRetryTimer = null;
if (!watcher) {
startWatching();
return;
}
if (watcher.refreshMissingRoots?.() === false) scheduleWatchRetry();
}, watchRetryMs);
};
const startWatching = () => { const startWatching = () => {
if (stopped || watcher) return; if (stopped || watcher) return;
watcher = watch((changedPath) => scheduleBuild('watch', changedPath)); watcher = watch((changedPath) => scheduleBuild('watch', changedPath));
if (!watcher) { if (!watcher) {
watchRetryTimer = timers.setTimeout(() => { scheduleWatchRetry();
watchRetryTimer = null; } else if (watcher.refreshMissingRoots?.() === false) {
startWatching(); scheduleWatchRetry();
}, watchRetryMs);
} }
}; };
+214 -82
View File
@@ -5,10 +5,18 @@ import { fileURLToPath } from 'node:url';
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { createBuiltinProviderRegistry } from '../../../packages/core/src/providers/builtins.ts'; import { createBuiltinProviderRegistry } from '../../../packages/core/src/providers/builtins.ts';
import type { ProviderRegistry } from '../../../packages/core/src/providers/registry.ts'; import type { ProviderRegistry } from '../../../packages/core/src/providers/registry.ts';
import {
createConfiguredBuiltinProviderRuntime,
type PersistedProviderSettings,
} from '../../../packages/core/src/provider-settings.ts';
import { import {
createProviderIndexPlan, createProviderIndexPlan,
indexProviderPlan, indexProviderPlan,
indexProviderPlanStrict,
ProviderIndexFailure,
writeProviderIndexMarkers, writeProviderIndexMarkers,
type ProviderInventoryIssue,
type ProviderSessionProvenance,
} from '../../../packages/core/src/provider-indexing.ts'; } from '../../../packages/core/src/provider-indexing.ts';
import { runWriteTransaction, configureConnection, betterSqliteTransactionAdapter } from '../../../packages/core/src/tx.ts'; import { runWriteTransaction, configureConnection, betterSqliteTransactionAdapter } from '../../../packages/core/src/tx.ts';
import { migrateCoreSchemaColumns } from '../../../packages/core/src/schema-migrations.ts'; import { migrateCoreSchemaColumns } from '../../../packages/core/src/schema-migrations.ts';
@@ -48,40 +56,68 @@ function openIndexDb({ dbPath = DEFAULT_DB_PATH, schemaPath = resolveSchemaPath(
return db; return db;
} }
function copyMemoriesFromDb(db, sourceDbPath) { function copyPreservedDataFromDb(db, sourceDbPath): ProviderSessionProvenance[] {
if (!sourceDbPath || !fs.existsSync(sourceDbPath)) return false; if (!sourceDbPath || !fs.existsSync(sourceDbPath)) {
throw new Error(`Preserved Obelisk database is unavailable: ${sourceDbPath}`);
}
db.prepare('ATTACH DATABASE ? AS previous_obelisk').run(sourceDbPath); db.prepare('ATTACH DATABASE ? AS previous_obelisk').run(sourceDbPath);
try { try {
const hasMemories = db.prepare(` const hasMemories = db.prepare(`
SELECT name FROM previous_obelisk.sqlite_master SELECT name FROM previous_obelisk.sqlite_master
WHERE type='table' AND name='memories' WHERE type='table' AND name='memories'
`).get(); `).get();
if (!hasMemories) return false; if (hasMemories) {
const sourceColumns = new Set(
db.prepare('PRAGMA previous_obelisk.table_info(memories)').all().map(column => column.name),
);
const targetColumns = [
'id',
'session_id',
'project',
'message_start',
'message_end',
'path',
'anchors',
'summary',
'created_at',
'deleted_at',
'deleted_reason',
];
const selectList = targetColumns
.map(column => sourceColumns.has(column) ? column : `NULL AS ${column}`)
.join(',');
db.exec(`
INSERT OR REPLACE INTO memories (${targetColumns.join(',')})
SELECT ${selectList} FROM previous_obelisk.memories
`);
}
const sourceColumns = new Set( const hasSessions = db.prepare(`
db.prepare('PRAGMA previous_obelisk.table_info(memories)').all().map(column => column.name), SELECT name FROM previous_obelisk.sqlite_master
WHERE type='table' AND name='sessions'
`).get();
if (!hasSessions) {
throw new Error(`Preserved Obelisk database has no sessions table: ${sourceDbPath}`);
}
const sessionColumns = new Set(
db.prepare('PRAGMA previous_obelisk.table_info(sessions)').all().map(column => column.name),
); );
const targetColumns = [ if (!sessionColumns.has('id') || !sessionColumns.has('jsonl_path')) {
'id', throw new Error(`Preserved Obelisk sessions schema is incomplete: ${sourceDbPath}`);
'session_id', }
'project', const sourceExpression = sessionColumns.has('source')
'message_start', ? "COALESCE(source, 'claude')"
'message_end', : "'claude'";
'path', return db.prepare(`
'anchors', SELECT id, jsonl_path, ${sourceExpression} AS source
'summary', FROM previous_obelisk.sessions
'created_at', WHERE jsonl_path IS NOT NULL
'deleted_at', AND jsonl_path != ''
'deleted_reason', `).all().map(row => ({
]; source: String(row.source),
const selectList = targetColumns sessionId: String(row.id),
.map(column => sourceColumns.has(column) ? column : `NULL AS ${column}`) jsonlPath: String(row.jsonl_path),
.join(','); }));
db.exec(`
INSERT OR REPLACE INTO memories (${targetColumns.join(',')})
SELECT ${selectList} FROM previous_obelisk.memories
`);
return true;
} finally { } finally {
db.exec('DETACH DATABASE previous_obelisk'); db.exec('DETACH DATABASE previous_obelisk');
} }
@@ -190,6 +226,7 @@ function writeHeartbeat({
interface BuildIndexOptions { interface BuildIndexOptions {
providerRoots?: Record<string, string>; providerRoots?: Record<string, string>;
providerSettings?: PersistedProviderSettings;
providerRegistry?: ProviderRegistry; providerRegistry?: ProviderRegistry;
claudeDir?: string; claudeDir?: string;
codexDir?: string; codexDir?: string;
@@ -207,6 +244,7 @@ interface BuildIndexOptions {
} }
interface SkippedFile { interface SkippedFile {
provider: string;
path: string; path: string;
error: string; error: string;
diagnostics?: unknown; diagnostics?: unknown;
@@ -220,6 +258,9 @@ interface BuildIndexResult {
skipped: number; skipped: number;
skippedFiles: SkippedFile[]; skippedFiles: SkippedFile[];
deferred: boolean; deferred: boolean;
complete: boolean;
incompleteProviders: string[];
inventoryIssues: ProviderInventoryIssue[];
reason?: string; reason?: string;
} }
@@ -234,6 +275,9 @@ function deferredBuildResult(
ftsRebuilt: false, ftsRebuilt: false,
skipped: 0, skipped: 0,
skippedFiles: [], skippedFiles: [],
complete: false,
incompleteProviders: [],
inventoryIssues: [],
...overrides, ...overrides,
deferred: true, deferred: true,
reason, reason,
@@ -242,6 +286,7 @@ function deferredBuildResult(
function buildIndex({ function buildIndex({
providerRoots = {}, providerRoots = {},
providerSettings,
providerRegistry, providerRegistry,
claudeDir = DEFAULT_CLAUDE_DIR, claudeDir = DEFAULT_CLAUDE_DIR,
codexDir = path.join(path.dirname(claudeDir), '.codex'), codexDir = path.join(path.dirname(claudeDir), '.codex'),
@@ -276,13 +321,15 @@ function buildIndex({
const txDb = betterSqliteTransactionAdapter(db); const txDb = betterSqliteTransactionAdapter(db);
let messageFtsTriggersDropped = false; let messageFtsTriggersDropped = false;
try { try {
let priorSessions: ProviderSessionProvenance[] | undefined;
if (preserveDbPath && path.resolve(preserveDbPath) !== path.resolve(dbPath)) { if (preserveDbPath && path.resolve(preserveDbPath) !== path.resolve(dbPath)) {
copyMemoriesFromDb(db, preserveDbPath); priorSessions = copyPreservedDataFromDb(db, preserveDbPath);
} }
const defaultHome = os.homedir(); const defaultHome = os.homedir();
const compatibilityHome = path.dirname(claudeDir); const compatibilityHome = path.dirname(claudeDir);
const relocatedDefaults = Object.fromEntries( const relocatedDefaults = Object.fromEntries(
createBuiltinProviderRegistry().catalog().map((descriptor) => { createBuiltinProviderRegistry().catalog().flatMap((descriptor) => {
if (descriptor.requiresExplicitRoot) return [];
const relativeDefault = path.relative(defaultHome, descriptor.defaultRoot); const relativeDefault = path.relative(defaultHome, descriptor.defaultRoot);
const root = compatibilityHome !== defaultHome const root = compatibilityHome !== defaultHome
&& relativeDefault && relativeDefault
@@ -290,7 +337,7 @@ function buildIndex({
&& !path.isAbsolute(relativeDefault) && !path.isAbsolute(relativeDefault)
? path.join(compatibilityHome, relativeDefault) ? path.join(compatibilityHome, relativeDefault)
: descriptor.defaultRoot; : descriptor.defaultRoot;
return [descriptor.id, root]; return [[descriptor.id, root]];
}), }),
); );
const roots = { const roots = {
@@ -299,8 +346,15 @@ function buildIndex({
codex: codexDir, codex: codexDir,
...providerRoots, ...providerRoots,
}; };
const registry = providerRegistry ?? createBuiltinProviderRegistry(roots); const registry = providerRegistry
const providerPlan = createProviderIndexPlan(db, registry, { force, changedPaths }); ?? (providerSettings === undefined
? createBuiltinProviderRegistry(roots)
: createConfiguredBuiltinProviderRuntime(providerSettings, { baseRoots: roots }).registry);
const providerPlan = createProviderIndexPlan(db, registry, {
force,
changedPaths,
priorSessions,
});
let latestSourceMtime = providerPlan.items.reduce((latest, { unit }) => { let latestSourceMtime = providerPlan.items.reduce((latest, { unit }) => {
const providerCursor = (unit.meta as { currentCursor?: unknown } | undefined)?.currentCursor; const providerCursor = (unit.meta as { currentCursor?: unknown } | undefined)?.currentCursor;
if (typeof providerCursor === 'string') { if (typeof providerCursor === 'string') {
@@ -312,45 +366,32 @@ function buildIndex({
return latest; return latest;
} }
}, 0); }, 0);
const discoveredSourceMtime = latestSourceMtime;
try { const incompleteProviders = [...providerPlan.incompleteProviders].sort();
if (force) { const inventoryIssues = [...providerPlan.inventoryIssues];
runRetryableWriteTransaction(txDb, () => { if (force && incompleteProviders.length > 0) {
dropMessageFtsTriggers(db); return {
db.prepare("DELETE FROM index_state WHERE substr(jsonl_path, 1, 2) != '__'").run(); files: providerPlan.items.length,
db.prepare("DELETE FROM messages").run(); latestSourceMtime,
db.prepare("DELETE FROM tool_calls").run(); affectedSessionIds: [],
db.prepare("DELETE FROM tool_results").run(); ftsRebuilt: false,
db.prepare("DELETE FROM sessions").run(); skipped: 0,
db.prepare("DELETE FROM summaries").run(); skippedFiles: [],
db.prepare("DELETE FROM subagents").run(); deferred: false,
db.prepare("DELETE FROM workflows").run(); complete: false,
db.prepare("DELETE FROM workflow_agents").run(); incompleteProviders,
}, { label: 'force-cleanup' }); inventoryIssues,
messageFtsTriggersDropped = true; reason: 'incomplete_snapshot',
} };
} catch (error) {
if (isBeginBusyFailure(error)) {
return deferredBuildResult('database_busy', {
files: providerPlan.items.length,
latestSourceMtime,
});
}
throw error;
} }
const affectedSessionIds = new Set<string>(); const affectedSessionIds = new Set<string>();
const finalizeAffectedSessionIds = new Set<string>(); const finalizeAffectedSessionIds = new Set<string>();
const changedMetaJsonlPaths = new Set<string>();
if (Array.isArray(changedPaths)) { if (Array.isArray(changedPaths)) {
for (const changedPath of changedPaths) { for (const changedPath of changedPaths) {
const sessionId = sessionIdFromChangedPath(projectsDir, changedPath); const sessionId = sessionIdFromChangedPath(projectsDir, changedPath);
const normalizedChangedPath = normalizeChangedPath(projectsDir, changedPath); const normalizedChangedPath = normalizeChangedPath(projectsDir, changedPath);
const isMetaChange = normalizedChangedPath?.toLowerCase().endsWith('.meta.json'); const isMetaChange = normalizedChangedPath?.toLowerCase().endsWith('.meta.json');
if (isMetaChange && normalizedChangedPath) {
changedMetaJsonlPaths.add(
normalizedChangedPath.slice(0, -'.meta.json'.length) + '.jsonl',
);
}
// Transcript files report their session only after their own transaction // Transcript files report their session only after their own transaction
// commits. Workflow changes are applied during finalize, so stage those // commits. Workflow changes are applied during finalize, so stage those
// IDs until the finalize transaction commits. Meta files map back to their // IDs until the finalize transaction commits. Meta files map back to their
@@ -361,23 +402,118 @@ function buildIndex({
} }
} }
const skipped: SkippedFile[] = []; const skipped: SkippedFile[] = [];
let ftsRebuilt = false;
const noteCommitted = ({ unit }, nextCursor) => {
if (nextCursor) {
latestSourceMtime = Math.max(
latestSourceMtime,
Number(nextCursor.split(':')[0]) || 0,
);
}
if (unit.sessionId) affectedSessionIds.add(unit.sessionId);
for (const sessionId of unit.retractSessionIds ?? []) affectedSessionIds.add(sessionId);
};
const finalize = (providerResult) => {
refreshSessionProjectPaths(db);
if (messageFtsTriggersDropped) installSchema(db, schemaPath);
ftsRebuilt = ensureFtsReady(db, { force });
writeIndexMarker(db, '__last_build__');
if (providerResult.complete) writeIndexMarker(db, '__app_last_successful_build__');
writeIndexMarker(db, '__indexer_owner_app__');
writeProviderIndexMarkers(db, providerPlan, providerResult);
if (latestSourceMtime) writeIndexMarker(db, '__last_source_mtime__', latestSourceMtime);
};
if (force) {
try {
runRetryableWriteTransaction(txDb, () => {
latestSourceMtime = discoveredSourceMtime;
affectedSessionIds.clear();
skipped.splice(0);
ftsRebuilt = false;
dropMessageFtsTriggers(db);
messageFtsTriggersDropped = true;
// The provider contract reserves no key prefix. A force snapshot
// recreates every unit cursor, provider marker, and system marker.
db.prepare('DELETE FROM index_state').run();
for (const table of ['messages', 'tool_calls', 'tool_results', 'sessions', 'summaries', 'subagents', 'workflows', 'workflow_agents']) {
db.prepare(`DELETE FROM ${table}`).run();
}
const providerResult = indexProviderPlanStrict({
db,
plan: providerPlan,
onCommitted: noteCommitted,
});
finalize(providerResult);
}, { label: 'force-rebuild' });
} catch (error) {
if (isBeginBusyFailure(error)) {
return deferredBuildResult('database_busy', {
files: providerPlan.items.length,
latestSourceMtime: discoveredSourceMtime,
incompleteProviders,
inventoryIssues,
});
}
if (error instanceof ProviderIndexFailure) {
skipped.push({
provider: error.item.provider.name,
path: error.item.unit.key,
error: error.sourceError instanceof Error
? error.sourceError.message
: String(error.sourceError),
diagnostics: (error as { obelisk?: unknown }).obelisk
?? (error.sourceError as { obelisk?: unknown } | null)?.obelisk,
});
console.warn(
`Warning: failed to index ${error.item.provider.name} unit ${error.item.unit.key}: ${error.message}`,
);
return {
files: providerPlan.items.length,
latestSourceMtime: discoveredSourceMtime,
affectedSessionIds: [],
ftsRebuilt: false,
skipped: skipped.length,
skippedFiles: skipped,
deferred: false,
complete: false,
incompleteProviders,
inventoryIssues,
reason: 'provider_failure',
};
}
throw error;
}
for (const sessionId of finalizeAffectedSessionIds) affectedSessionIds.add(sessionId);
return {
files: providerPlan.items.length,
latestSourceMtime,
affectedSessionIds: [...affectedSessionIds],
ftsRebuilt,
skipped: 0,
skippedFiles: [],
deferred: false,
complete: true,
incompleteProviders,
inventoryIssues,
};
}
const providerResult = indexProviderPlan({ const providerResult = indexProviderPlan({
db, db,
plan: providerPlan, plan: providerPlan,
runTransaction: (label, work) => runRetryableWriteTransaction(txDb, work, { label }), runTransaction: (label, work) => runRetryableWriteTransaction(txDb, work, { label }),
onCommitted: ({ unit }, nextCursor) => { onCommitted: noteCommitted,
if (nextCursor) latestSourceMtime = Math.max(latestSourceMtime, Number(nextCursor.split(':')[0]) || 0);
if (unit.sessionId) affectedSessionIds.add(unit.sessionId);
},
onError: (error, { provider, unit }) => { onError: (error, { provider, unit }) => {
if (isBeginBusyFailure(error)) return 'stop'; if (isBeginBusyFailure(error)) return 'stop';
if (hasUnusableTransaction(error)) throw error; if (hasUnusableTransaction(error)) throw error;
skipped.push({ skipped.push({
provider: provider.name,
path: unit.key, path: unit.key,
error: (error as Error).message, error: error instanceof Error ? error.message : String(error),
diagnostics: (error as { obelisk?: unknown }).obelisk, diagnostics: (error as { obelisk?: unknown })?.obelisk,
}); });
console.warn(`Warning: failed to index ${provider.name} unit ${unit.key}: ${(error as Error).message}`); console.warn(`Warning: failed to index ${provider.name} unit ${unit.key}: ${error instanceof Error ? error.message : String(error)}`);
return 'skip'; return 'skip';
}, },
}); });
@@ -388,22 +524,13 @@ function buildIndex({
affectedSessionIds: [...affectedSessionIds], affectedSessionIds: [...affectedSessionIds],
skipped: skipped.length, skipped: skipped.length,
skippedFiles: skipped, skippedFiles: skipped,
incompleteProviders,
inventoryIssues,
}); });
} }
let ftsRebuilt = false; // Finalize is one transaction; a failure here fails the whole build.
// Finalize is one transaction; a failure here fails the whole build (the
// index would otherwise be left inconsistent).
try { try {
runRetryableWriteTransaction(txDb, () => { runRetryableWriteTransaction(txDb, () => finalize(providerResult), { label: 'finalize' });
refreshSessionProjectPaths(db);
if (messageFtsTriggersDropped) installSchema(db, schemaPath);
ftsRebuilt = ensureFtsReady(db, { force });
writeIndexMarker(db, '__last_build__');
writeIndexMarker(db, '__app_last_successful_build__');
writeIndexMarker(db, '__indexer_owner_app__');
writeProviderIndexMarkers(db, providerPlan, providerResult);
if (latestSourceMtime) writeIndexMarker(db, '__last_source_mtime__', latestSourceMtime);
}, { label: 'finalize' });
} catch (error) { } catch (error) {
if (isBeginBusyFailure(error)) { if (isBeginBusyFailure(error)) {
return deferredBuildResult('database_busy', { return deferredBuildResult('database_busy', {
@@ -412,6 +539,8 @@ function buildIndex({
affectedSessionIds: [...affectedSessionIds], affectedSessionIds: [...affectedSessionIds],
skipped: skipped.length, skipped: skipped.length,
skippedFiles: skipped, skippedFiles: skipped,
incompleteProviders,
inventoryIssues,
}); });
} }
throw error; throw error;
@@ -425,6 +554,9 @@ function buildIndex({
skipped: skipped.length, skipped: skipped.length,
skippedFiles: skipped, skippedFiles: skipped,
deferred: false, deferred: false,
complete: providerResult.complete,
incompleteProviders,
inventoryIssues,
}; };
} finally { } finally {
if (messageFtsTriggersDropped) { if (messageFtsTriggersDropped) {
+26 -23
View File
@@ -1,4 +1,5 @@
import type { ProviderRegistry } from '../../../packages/core/src/providers/registry.ts'; import type { ProviderRegistry } from '../../../packages/core/src/providers/registry.ts';
export { resolveProviderRoots } from '../../../packages/core/src/provider-settings.ts';
type PersistedSettings = Record<string, unknown> & { type PersistedSettings = Record<string, unknown> & {
providerRoots?: Record<string, unknown>; providerRoots?: Record<string, unknown>;
@@ -9,30 +10,20 @@ interface SourceStats {
lastIndexed: string; lastIndexed: string;
} }
export interface ProviderSourceIssue {
readonly provider: string;
readonly path: string;
readonly error: string;
}
interface BuildSourceCatalogOptions { interface BuildSourceCatalogOptions {
registry: ProviderRegistry; registry: ProviderRegistry;
roots: Readonly<Record<string, string>>; roots: Readonly<Record<string, string>>;
stats?: ReadonlyMap<string, SourceStats>; stats?: ReadonlyMap<string, SourceStats>;
sourceIssues?: readonly ProviderSourceIssue[];
pathExists?: (path: string) => boolean; pathExists?: (path: string) => boolean;
} }
function configuredPath(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value : null;
}
export function resolveProviderRoots(
registry: ProviderRegistry,
persisted: PersistedSettings = {},
): Record<string, string> {
const configured = persisted.providerRoots ?? {};
return Object.fromEntries(registry.catalog().map((descriptor) => {
const root = configuredPath(configured[descriptor.id])
?? configuredPath(persisted[`${descriptor.id}Dir`])
?? descriptor.defaultRoot;
return [descriptor.id, root];
}));
}
export function setPersistedSetting( export function setPersistedSetting(
persisted: PersistedSettings, persisted: PersistedSettings,
key: string, key: string,
@@ -46,7 +37,9 @@ export function setPersistedSetting(
} }
const providerId = providerMatch[1]!; const providerId = providerMatch[1]!;
const roots = persisted.providerRoots && typeof persisted.providerRoots === 'object' const roots = persisted.providerRoots
&& typeof persisted.providerRoots === 'object'
&& !Array.isArray(persisted.providerRoots)
? persisted.providerRoots ? persisted.providerRoots
: {}; : {};
if (value === null) delete roots[providerId]; if (value === null) delete roots[providerId];
@@ -60,13 +53,23 @@ export function buildSourceCatalog({
registry, registry,
roots, roots,
stats = new Map(), stats = new Map(),
sourceIssues = [],
pathExists = () => false, pathExists = () => false,
}: BuildSourceCatalogOptions) { }: BuildSourceCatalogOptions) {
return registry.catalog().map((descriptor) => { return registry.catalog().map((descriptor) => {
const path = roots[descriptor.id] ?? descriptor.defaultRoot; const path = roots[descriptor.id] ?? descriptor.defaultRoot;
const exists = pathExists(path); const exists = pathExists(path);
const needsRoot = descriptor.requiresExplicitRoot === true;
const sourceStats = stats.get(descriptor.id) ?? { sessionCount: 0, lastIndexed: '' }; const sourceStats = stats.get(descriptor.id) ?? { sessionCount: 0, lastIndexed: '' };
const status = !exists ? 'error' : sourceStats.sessionCount > 0 ? 'ok' : 'warn'; const issue = sourceIssues.find((candidate) => candidate.provider === descriptor.id);
const status = needsRoot || !exists
? 'error'
: issue !== undefined || sourceStats.sessionCount === 0
? 'warn'
: 'ok';
const partialStatus = issue === undefined
? null
: `Index issue: ${issue.path}${issue.error}`;
return { return {
id: descriptor.id, id: descriptor.id,
name: descriptor.name, name: descriptor.name,
@@ -78,11 +81,11 @@ export function buildSourceCatalog({
sessionCount: sourceStats.sessionCount, sessionCount: sourceStats.sessionCount,
lastIndexed: sourceStats.lastIndexed, lastIndexed: sourceStats.lastIndexed,
status, status,
statusText: !exists statusText: needsRoot
? descriptor.rootResolutionReason ?? 'Select a session folder'
: !exists
? 'Folder not found' ? 'Folder not found'
: sourceStats.sessionCount > 0 : partialStatus ?? (sourceStats.sessionCount > 0 ? 'Connected' : 'No sessions found'),
? 'Connected'
: 'No sessions found',
}; };
}); });
} }
+7 -1
View File
@@ -22,6 +22,7 @@ import { sourceLabel } from './source-catalog.mjs';
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
let searchTimer = null; let searchTimer = null;
let stopSourceUpdates = null;
const routeSession = computed(() => { const routeSession = computed(() => {
return getSessionSummary(route.params.id); return getSessionSummary(route.params.id);
@@ -193,9 +194,14 @@ function handleGlobalKeydown(event) {
} }
} }
onMounted(() => window.addEventListener('keydown', handleGlobalKeydown)); onMounted(() => {
window.addEventListener('keydown', handleGlobalKeydown);
stopSourceUpdates = window.obelisk?.onIndexUpdated?.(() => loadSourceDots()) ?? null;
});
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('keydown', handleGlobalKeydown); window.removeEventListener('keydown', handleGlobalKeydown);
stopSourceUpdates?.();
stopSourceUpdates = null;
clearTimeout(searchTimer); clearTimeout(searchTimer);
}); });
+6 -2
View File
@@ -20,14 +20,18 @@ const editorMenuOpen = ref(false);
const memoryCount = ref(0); const memoryCount = ref(0);
const rebuilding = ref(false); const rebuilding = ref(false);
const version = ref(''); const version = ref('');
let stopIndexUpdates = null;
onMounted(async () => { onMounted(async () => {
stopIndexUpdates = window.obelisk?.onIndexUpdated?.(() => loadSettings()) ?? null;
document.addEventListener('pointerdown', closeEditorMenuOutside); document.addEventListener('pointerdown', closeEditorMenuOutside);
document.addEventListener('keydown', closeEditorMenuOnEscape); document.addEventListener('keydown', closeEditorMenuOnEscape);
await loadSettings(); await loadSettings();
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
stopIndexUpdates?.();
stopIndexUpdates = null;
document.removeEventListener('pointerdown', closeEditorMenuOutside); document.removeEventListener('pointerdown', closeEditorMenuOutside);
document.removeEventListener('keydown', closeEditorMenuOnEscape); document.removeEventListener('keydown', closeEditorMenuOnEscape);
}); });
@@ -364,7 +368,7 @@ function fmtRelative(iso) {
.source-card-name .vendor { font-size: 11.5px; color: var(--muted); font-weight: 400; } .source-card-name .vendor { font-size: 11.5px; color: var(--muted); font-weight: 400; }
.source-card-status { .source-card-status {
font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); font-family: var(--font-mono); font-size: 10.5px; color: var(--muted);
margin-top: 3px; display: flex; align-items: center; gap: 8px; margin-top: 3px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
} }
.source-card-status .stat-dot { width: 6px; height: 6px; border-radius: 50%; position: relative; } .source-card-status .stat-dot { width: 6px; height: 6px; border-radius: 50%; position: relative; }
.source-card-status .stat-dot.ok { background: #34d399; box-shadow: 0 0 5px rgba(52,211,153,0.5); } .source-card-status .stat-dot.ok { background: #34d399; box-shadow: 0 0 5px rgba(52,211,153,0.5); }
@@ -375,7 +379,7 @@ function fmtRelative(iso) {
border: 1px solid #34d399; opacity: 0.5; animation: src-pulse 1.6s ease-out infinite; border: 1px solid #34d399; opacity: 0.5; animation: src-pulse 1.6s ease-out infinite;
} }
@keyframes src-pulse { 0% { transform: scale(0.8); opacity: 0.5; } 100% { transform: scale(1.8); opacity: 0; } } @keyframes src-pulse { 0% { transform: scale(0.8); opacity: 0.5; } 100% { transform: scale(1.8); opacity: 0; } }
.source-card-status .stat-text { color: var(--fg-2); } .source-card-status .stat-text { color: var(--fg-2); min-width: 0; overflow-wrap: anywhere; }
.source-card-status .stat-text.ok { color: #34d399; } .source-card-status .stat-text.ok { color: #34d399; }
.source-card-status .stat-text.warn { color: #fbbf24; } .source-card-status .stat-text.warn { color: #fbbf24; }
.source-card-status .stat-text.error { color: #f87171; } .source-card-status .stat-text.error { color: #f87171; }
+30 -11
View File
@@ -21,19 +21,36 @@ binding-agnostic and does not need a per-binding implementation.
**Decision.** Split indexing along two orthogonal axes. **Decision.** Split indexing along two orthogonal axes.
- **Provider axis — a registry of pure adapters.** Each source (Claude Code, - **Provider axis — a registry of pure adapters.** Each source (Claude Code,
Codex, Kimi Code, later Pi, …) is a provider adapter implementing one complete Codex, Kimi Code, Pi, …) is a provider adapter implementing one complete
boundary: serializable descriptor metadata, `watchRoots(root)`, boundary: serializable descriptor metadata, `watchRoots(root)`,
`discover(context) → IndexUnit[]`, `parse(unit, cursor) → Iterable<Record>`, `discover(context) → IndexUnit[]`,
`parse(unit, cursor) → Iterable<Record>`,
and `raw(lookup)`. An `IndexUnit` is deliberately not a file abstraction: Kimi and `raw(lookup)`. An `IndexUnit` is deliberately not a file abstraction: Kimi
uses one session directory containing state plus multiple agent wire logs. An uses one session directory containing state plus multiple agent wire logs. An
adapter is *pure*: it emits normalized records and never touches a database. adapter is *pure*: it emits normalized records and never touches a database.
Discovery receives a read-only view of the provider's already-indexed session
paths. A full-reparse adapter can therefore attach `retractSessionIds` to a
replacement or tombstone unit without querying SQLite itself. Retraction and
replacement records commit in the same unit transaction, so a failed parse
preserves the last complete snapshot.
Provider identity may therefore be richer than a wire-level ID. Pi, whose
explicit session IDs are project-local, deterministically namespaces the
header ID by the normalized header cwd; source paths remain provenance rather
than identity, so copying or moving a transcript does not rename the session.
Adding a source means adding one adapter and registering it; nothing else Adding a source means adding one adapter and registering it; nothing else
changes. `parse` exposes an iterator as its common interface and streams when changes. `parse` exposes an iterator as its common interface and streams when
the provider semantics permit it. An adapter may buffer one complete the provider semantics permit it. An adapter may buffer one complete
`IndexUnit` when correctness requires whole-unit semantics — for example, `IndexUnit` when correctness requires whole-unit semantics — for example,
Codex duplicate reconciliation or Kimi `context.undo` / `context.clear` Codex duplicate reconciliation, Kimi `context.undo` / `context.clear`
replay. Each adapter maps its own resume/change semantics onto the existing replay, or Pi tree projection. Each adapter maps its own resume/change
`mtime` and `lines_processed` cursor pair in `index_state`. The emitted semantics onto an opaque cursor stored exactly in `index_state.cursor`;
`mtime` and `lines_processed` remain compatibility/index-inspection columns.
A provider-wide replay is a destructive snapshot boundary. Discovery reports
any source location it could not enumerate; destructive rebuilds fail closed
when such a report exists. Cleanup, parsing, persistence, FTS rebuild and
marker publication commit in one transaction, so a parse failure cannot
replace the last-good index.
The emitted
`TranscriptRecord` stream is also the input to provider-independent session `TranscriptRecord` stream is also the input to provider-independent session
detail assembly; see ADR-0007. detail assembly; see ADR-0007.
- **Persist axis — one shared orchestration.** A single provider-agnostic, - **Persist axis — one shared orchestration.** A single provider-agnostic,
@@ -60,9 +77,11 @@ is disentangling the currently interleaved parse-and-write inside `indexJsonl` /
The normalized `TranscriptRecord` union is the stable center of the design, and The normalized `TranscriptRecord` union is the stable center of the design, and
SQLite is one serialization adapter for it. Provider-only concepts are either SQLite is one serialization adapter for it. Provider-only concepts are either
projected lossily into that language or ignored. The registry, projected lossily into that language or ignored. A genuinely shared concept may
not provider switches, drives both indexers, watcher roots, persisted source extend the canonical language by explicit decision: summary-generating model
roots, source catalog/UI labels and colors, and raw-record routing. Adding Pi calls, for example, carry the same normalized input/output usage as ordinary
therefore changes the Pi adapter, its registration, and its conformance tests; messages. The registry, not provider switches, drives both indexers, watcher
the shared schema, persist layer, indexers, settings, query API, and renderer do roots, persisted source roots, source catalog/UI labels and colors, and
not acquire Pi-specific branches. raw-record routing. Adding Pi therefore adds no Pi-specific branch to the
shared schema, persist layer, indexers, settings, query API, or renderer; the
provenance and summary-usage additions are provider-neutral contracts.
@@ -10,11 +10,25 @@ the direct parse path to drift from the persisted path.
**Decision.** Every provider adapter emits a canonical `TranscriptRecord` **Decision.** Every provider adapter emits a canonical `TranscriptRecord`
stream. The adapter owns all source-specific interpretation: duplicate raw stream. The adapter owns all source-specific interpretation: duplicate raw
events, stable identities, tool relationships, message classification, and events, stable identities, tool relationships, message classification, and
visibility. `visibility` is separate from `is_meta`: hidden transport context is visibility. Messages and summaries carry provider-normalized visibility.
omitted from session detail, while visible system evidence can remain a metadata `visibility` is separate from `is_meta`. `visible` is current evidence.
card. Presentation-sensitive concepts are explicit canonical fields: tool calls `inactive` is physical evidence that the provider explicitly attests was
carry a presentation class, Skill instructions carry a content type, and superseded; default queries omit it, and supported helpers may return it only
workflows carry their parent tool-call identity. with `includeInactive: true`. `hidden` is display-suppressed or transport-only
material and no standard helper returns it. Session detail and the desktop app
remain visible-only, 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. Summaries carry normalized input/output
usage when their provider performed a separate model call; cached input is
folded into input usage by the provider, as it is for messages. Visibility does
not erase accounting: aggregate usage includes model calls that were later
abandoned.
Provider capability determines whether `inactive` is meaningful. Pi attests
supersession through branch, leaf, and compaction state. Kimi undo/clear can
attest it, but preserving that history is separate work. Claude transcripts do
not attest rewind or current-leaf state, and Codex sessions do not branch.
The Core `assembleSessionDetail(input)` module is the only session-detail seam. The Core `assembleSessionDetail(input)` module is the only session-detail seam.
It accepts either a provider's complete transcript stream from a fresh parse It accepts either a provider's complete transcript stream from a fresh parse
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "0.2.0", "version": "0.2.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Explicit memory infrastructure for coding agents — a queryable SQLite evidence layer over local Claude Code and Codex history, plus human-approved durable memory.", "description": "Explicit memory infrastructure for coding agents — a queryable SQLite evidence layer over local coding-agent history, plus human-approved durable memory.",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
+3 -3
View File
@@ -1,8 +1,8 @@
# Obelisk CLI # Obelisk CLI
The local Obelisk runtime used by coding agents. It indexes Claude Code and The local Obelisk runtime used by coding agents. It indexes Claude Code, Codex,
Codex transcripts into `~/.obelisk/obelisk.sqlite` and exposes the stable Kimi Code, and Pi transcripts into `~/.obelisk/obelisk.sqlite` and exposes the
`build`, `search`, `query`, and `attune` process interface. stable `build`, `search`, `query`, and `attune` process interface.
```bash ```bash
npm install --global @obelisk-apps/cli npm install --global @obelisk-apps/cli
+21 -1
View File
@@ -32,7 +32,27 @@ async function main() {
} }
if (args[0] === '--build') { if (args[0] === '--build') {
try { try {
buildIndex({ force: true }); const result = buildIndex({ force: true });
if (!('complete' in result) || result.complete !== true) {
const reason = 'reason' in result && typeof result.reason === 'string'
? result.reason
: 'incomplete_snapshot';
const issue = 'inventoryIssues' in result && Array.isArray(result.inventoryIssues)
? result.inventoryIssues[0] as { provider?: unknown; path?: unknown; error?: unknown } | undefined
: undefined;
let detail = '';
if ('error' in result && typeof result.error === 'string') {
detail = ` (${result.error})`;
} else if (
issue
&& typeof issue.provider === 'string'
&& typeof issue.path === 'string'
&& typeof issue.error === 'string'
) {
detail = ` (${issue.provider} at ${issue.path}: ${issue.error})`;
}
throw new Error(`Index rebuild was not published: ${reason}${detail}`);
}
process.stdout.write(JSON.stringify({ ok: true, db: DB_PATH }) + '\n'); process.stdout.write(JSON.stringify({ ok: true, db: DB_PATH }) + '\n');
} catch (error) { fail(error); } } catch (error) { fail(error); }
return; return;
+1
View File
@@ -15,6 +15,7 @@
"./providers/claude": "./dist/providers/claude.js", "./providers/claude": "./dist/providers/claude.js",
"./providers/codex": "./dist/providers/codex.js", "./providers/codex": "./dist/providers/codex.js",
"./providers/kimi": "./dist/providers/kimi.js", "./providers/kimi": "./dist/providers/kimi.js",
"./providers/pi": "./dist/providers/pi.js",
"./providers/registry": "./dist/providers/registry.js", "./providers/registry": "./dist/providers/registry.js",
"./providers/builtins": "./dist/providers/builtins.js", "./providers/builtins": "./dist/providers/builtins.js",
"./providers/types": "./dist/providers/types.js", "./providers/types": "./dist/providers/types.js",
+46 -4
View File
@@ -12,6 +12,11 @@ import { createContext, runInNewContext } from 'node:vm';
import { DB_PATH, openDb, openReadDb, openWriterLeaseDb } from './db.ts'; import { DB_PATH, openDb, openReadDb, openWriterLeaseDb } from './db.ts';
import { buildIndex, shouldSkipBuild } from './indexer.ts'; import { buildIndex, shouldSkipBuild } from './indexer.ts';
import {
createConfiguredBuiltinProviderRuntime,
readPersistedProviderSettings,
} from './provider-settings.ts';
import type { ProviderRegistry } from './providers/registry.ts';
import { createQueryApi, createAttuneApi } from './query.ts'; import { createQueryApi, createAttuneApi } from './query.ts';
import { acquireWriterLease, writerLockPathFor } from './writer-lease.ts'; import { acquireWriterLease, writerLockPathFor } from './writer-lease.ts';
@@ -19,6 +24,42 @@ export { buildIndex, DB_PATH };
type SandboxApi = Record<string, unknown>; type SandboxApi = Record<string, unknown>;
interface InventoryIssue {
provider?: unknown;
path?: unknown;
error?: unknown;
}
function reportIncompleteInventory(build: unknown): void {
if (build === null || typeof build !== 'object' || !('inventoryIssues' in build)) return;
const issues = (build as { inventoryIssues?: unknown }).inventoryIssues;
if (!Array.isArray(issues)) return;
for (const value of issues) {
const issue = value as InventoryIssue | null;
if (
issue !== null
&& typeof issue.provider === 'string'
&& typeof issue.path === 'string'
&& typeof issue.error === 'string'
) {
process.stderr.write(
`Warning: incomplete ${issue.provider} source inventory at ${issue.path}: ${issue.error}\n`,
);
}
}
}
function refreshQueryIndex(): ProviderRegistry {
const settings = readPersistedProviderSettings();
const providerRegistry = createConfiguredBuiltinProviderRuntime(settings.settings).registry;
if (!settings.ok) {
process.stderr.write(`Warning: ${settings.error}; index refresh skipped\n`);
return providerRegistry;
}
reportIncompleteInventory(buildIndex({ providerRegistry }));
return providerRegistry;
}
// Run a user-supplied CodeAct script inside the query/attune sandbox. The script // Run a user-supplied CodeAct script inside the query/attune sandbox. The script
// body runs as an async IIFE with a 30s timeout; its `return` value is resolved. // body runs as an async IIFE with a 30s timeout; its `return` value is resolved.
function runInSandbox(api: SandboxApi, scriptContent: string): Promise<unknown> { function runInSandbox(api: SandboxApi, scriptContent: string): Promise<unknown> {
@@ -32,10 +73,10 @@ function runInSandbox(api: SandboxApi, scriptContent: string): Promise<unknown>
// FTS search over indexed message text. Refreshes the index, then queries. // FTS search over indexed message text. Refreshes the index, then queries.
export function searchText(text: string, opts?: Record<string, unknown>): unknown { export function searchText(text: string, opts?: Record<string, unknown>): unknown {
buildIndex(); const providerRegistry = refreshQueryIndex();
const db = openReadDb(); const db = openReadDb();
try { try {
return createQueryApi(db).search(text, opts); return createQueryApi(db, { providerRegistry }).search(text, opts);
} finally { } finally {
db.close(); db.close();
} }
@@ -43,10 +84,10 @@ export function searchText(text: string, opts?: Record<string, unknown>): unknow
// Execute a read-only CodeAct query script and resolve its returned value. // Execute a read-only CodeAct query script and resolve its returned value.
export async function executeQuery(scriptContent: string): Promise<unknown> { export async function executeQuery(scriptContent: string): Promise<unknown> {
buildIndex(); const providerRegistry = refreshQueryIndex();
const db = openReadDb(); const db = openReadDb();
try { try {
return await runInSandbox(createQueryApi(db), scriptContent); return await runInSandbox(createQueryApi(db, { providerRegistry }), scriptContent);
} finally { } finally {
db.close(); db.close();
} }
@@ -55,6 +96,7 @@ export async function executeQuery(scriptContent: string): Promise<unknown> {
// Execute a memory-mutation CodeAct script (remember/forget only). // Execute a memory-mutation CodeAct script (remember/forget only).
export async function executeAttune(scriptContent: string): Promise<unknown> { export async function executeAttune(scriptContent: string): Promise<unknown> {
const build = buildIndex() as { reason?: string } | undefined; const build = buildIndex() as { reason?: string } | undefined;
reportIncompleteInventory(build);
if (build?.reason === 'daemon_active') { if (build?.reason === 'daemon_active') {
throw new Error('Obelisk daemon owns index writes; attune is read-only until the daemon stops'); throw new Error('Obelisk daemon owns index writes; attune is read-only until the daemon stops');
} }
+128 -22
View File
@@ -5,15 +5,22 @@ import { inferProjectPath } from './parsing.ts';
import { import {
createProviderIndexPlan, createProviderIndexPlan,
indexProviderPlan, indexProviderPlan,
indexProviderPlanStrict,
ProviderIndexFailure,
writeProviderIndexMarkers, writeProviderIndexMarkers,
} from './provider-indexing.ts'; } from './provider-indexing.ts';
import { nodeSqliteTransactionAdapter } from './tx.ts'; import { nodeSqliteTransactionAdapter } from './tx.ts';
import { acquireWriterLease, writerLockPathFor } from './writer-lease.ts'; import { acquireWriterLease, writerLockPathFor } from './writer-lease.ts';
import { runRetryableWriteTransaction, isBeginBusyFailure, hasUnusableTransaction } from './write-coordinator.ts'; import { runRetryableWriteTransaction, isBeginBusyFailure, hasUnusableTransaction } from './write-coordinator.ts';
import { createBuiltinProviderRegistry } from './providers/builtins.ts'; import {
createConfiguredBuiltinProviderRuntime,
readPersistedProviderSettings,
} from './provider-settings.ts';
import type { ProviderRegistry } from './providers/registry.ts';
import type { NodeSqliteDb, SqliteRow } from './sqlite-types.ts'; import type { NodeSqliteDb, SqliteRow } from './sqlite-types.ts';
interface SkippedFile { interface SkippedFile {
provider: string;
path: string; path: string;
error: string; error: string;
diagnostics?: unknown; diagnostics?: unknown;
@@ -24,6 +31,11 @@ interface BuildCheckOptions {
ignoreRecentBuild?: boolean; ignoreRecentBuild?: boolean;
} }
interface BuildIndexOptions {
force?: boolean;
providerRegistry?: ProviderRegistry;
}
function errorMessage(error: unknown): string { function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error); return error instanceof Error ? error.message : String(error);
} }
@@ -82,7 +94,7 @@ function inspectBuildOwnership({ force = false }: { force?: boolean } = {}) {
} }
} }
function buildIndex({ force = false }: { force?: boolean } = {}) { function buildIndex({ force = false, providerRegistry }: BuildIndexOptions = {}) {
const ownership = inspectBuildOwnership({ force }); const ownership = inspectBuildOwnership({ force });
if (ownership.skip) return ownership; if (ownership.skip) return ownership;
const lease = acquireWriterLease({ const lease = acquireWriterLease({
@@ -94,34 +106,100 @@ function buildIndex({ force = false }: { force?: boolean } = {}) {
// Ownership may change between the first read and lease acquisition. // Ownership may change between the first read and lease acquisition.
const ownershipAfterLease = inspectBuildOwnership({ force }); const ownershipAfterLease = inspectBuildOwnership({ force });
if (ownershipAfterLease.skip) return ownershipAfterLease; if (ownershipAfterLease.skip) return ownershipAfterLease;
let registry = providerRegistry;
if (registry === undefined) {
const settings = readPersistedProviderSettings();
if (!settings.ok) {
return { skip: true, reason: 'settings_unavailable', error: settings.error };
}
registry = createConfiguredBuiltinProviderRuntime(settings.settings).registry;
}
const db = openDb(); const db = openDb();
const txDb = nodeSqliteTransactionAdapter(db); const txDb = nodeSqliteTransactionAdapter(db);
const skippedFiles: SkippedFile[] = []; const skippedFiles: SkippedFile[] = [];
try { try {
try { const providerPlan = createProviderIndexPlan(db, registry, { force });
if (force) { const incompleteProviders = [...providerPlan.incompleteProviders].sort();
const inventoryIssues = [...providerPlan.inventoryIssues];
if (force && incompleteProviders.length > 0) {
return {
skip: false,
complete: false,
reason: 'incomplete_snapshot',
incompleteProviders,
inventoryIssues,
skipped: 0,
skippedFiles,
};
}
if (force) {
try {
runRetryableWriteTransaction(txDb, () => { runRetryableWriteTransaction(txDb, () => {
db.prepare("DELETE FROM index_state WHERE jsonl_path != '__last_build__'").run(); // A force build publishes one complete source snapshot or nothing.
// Clearing index_state alone re-indexes existing files but leaves rows for // The provider contract reserves no key prefix. A force snapshot
// files that no longer exist on disk (stale sessions accumulate). A force // recreates every unit cursor, provider marker, and system marker.
// build is a clean rebuild: drop every derived table, then re-index from the db.prepare('DELETE FROM index_state').run();
// current files. `memories` is the durable, human-approved layer and is never
// cleared; messages_fts is repopulated by the 'rebuild' command in finalize.
for (const table of ['messages', 'tool_calls', 'tool_results', 'sessions', 'summaries', 'subagents', 'workflows', 'workflow_agents']) { for (const table of ['messages', 'tool_calls', 'tool_results', 'sessions', 'summaries', 'subagents', 'workflows', 'workflow_agents']) {
db.prepare(`DELETE FROM ${table}`).run(); db.prepare(`DELETE FROM ${table}`).run();
} }
}, { label: 'force-cleanup' }); const providerResult = indexProviderPlanStrict({
db,
plan: providerPlan,
});
refreshSessionProjectPaths(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());
writeProviderIndexMarkers(db, providerPlan, providerResult);
}, { label: 'force-rebuild' });
} catch (error) {
if (isBeginBusyFailure(error)) {
return {
skip: true,
complete: false,
reason: 'database_busy',
incompleteProviders,
inventoryIssues,
skipped: 0,
skippedFiles,
};
}
if (error instanceof ProviderIndexFailure) {
const skippedFile = {
provider: error.item.provider.name,
path: error.item.unit.key,
error: errorMessage(error.sourceError),
diagnostics: (error as { obelisk?: unknown }).obelisk
?? (error.sourceError as { obelisk?: unknown } | null)?.obelisk,
};
skippedFiles.push(skippedFile);
process.stderr.write(
`Warning: failed to index ${error.item.provider.name} unit ${skippedFile.path}: ${skippedFile.error}\n`,
);
return {
skip: false,
complete: false,
reason: 'provider_failure',
incompleteProviders,
inventoryIssues,
skipped: skippedFiles.length,
skippedFiles,
};
}
throw error;
} }
} catch (error) { return {
if (isBeginBusyFailure(error)) { skip: false,
return { skip: true, reason: 'database_busy', skipped: skippedFiles.length, skippedFiles }; complete: true,
} incompleteProviders,
throw error; inventoryIssues,
skipped: 0,
skippedFiles,
};
} }
const registry = createBuiltinProviderRegistry();
const providerPlan = createProviderIndexPlan(db, registry, { force });
const providerResult = indexProviderPlan({ const providerResult = indexProviderPlan({
db, db,
plan: providerPlan, plan: providerPlan,
@@ -131,13 +209,26 @@ function buildIndex({ force = false }: { force?: boolean } = {}) {
if (hasUnusableTransaction(error)) throw error; if (hasUnusableTransaction(error)) throw error;
const detail = error as { message?: unknown; obelisk?: unknown } | null; const detail = error as { message?: unknown; obelisk?: unknown } | null;
const message = errorMessage(error); const message = errorMessage(error);
skippedFiles.push({ path: unit.key, error: message, diagnostics: detail?.obelisk }); skippedFiles.push({
provider: provider.name,
path: unit.key,
error: message,
diagnostics: detail?.obelisk,
});
process.stderr.write(`Warning: failed to index ${provider.name} unit ${unit.key}: ${message}\n`); process.stderr.write(`Warning: failed to index ${provider.name} unit ${unit.key}: ${message}\n`);
return 'skip'; return 'skip';
}, },
}); });
if (providerResult.stopped) { if (providerResult.stopped) {
return { skip: true, reason: 'database_busy', skipped: skippedFiles.length, skippedFiles }; return {
skip: true,
complete: false,
reason: 'database_busy',
incompleteProviders,
inventoryIssues,
skipped: skippedFiles.length,
skippedFiles,
};
} }
// Finalize is one transaction and is NOT swallowed: a finalize failure fails // Finalize is one transaction and is NOT swallowed: a finalize failure fails
// the build (a half-finalized index would be inconsistent). // the build (a half-finalized index would be inconsistent).
@@ -151,11 +242,26 @@ function buildIndex({ force = false }: { force?: boolean } = {}) {
}, { label: 'finalize' }); }, { label: 'finalize' });
} catch (error) { } catch (error) {
if (isBeginBusyFailure(error)) { if (isBeginBusyFailure(error)) {
return { skip: true, reason: 'database_busy', skipped: skippedFiles.length, skippedFiles }; return {
skip: true,
complete: false,
reason: 'database_busy',
incompleteProviders,
inventoryIssues,
skipped: skippedFiles.length,
skippedFiles,
};
} }
throw error; throw error;
} }
return { skip: false, skipped: skippedFiles.length, skippedFiles }; return {
skip: false,
complete: providerResult.complete,
incompleteProviders,
inventoryIssues,
skipped: skippedFiles.length,
skippedFiles,
};
} finally { } finally {
db.close(); db.close();
} }
+23 -9
View File
@@ -6,6 +6,8 @@ import { closeSync, existsSync, openSync, readSync, readdirSync, statSync } from
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { isAbsolute, join, normalize } from 'node:path'; import { isAbsolute, join, normalize } from 'node:path';
import type { InventoryIssue } from './providers/types.ts';
const CLAUDE_DIR = join(homedir(), '.claude'); const CLAUDE_DIR = join(homedir(), '.claude');
const CODEX_DIR = join(homedir(), '.codex'); const CODEX_DIR = join(homedir(), '.codex');
const PROJECTS_DIR = join(CLAUDE_DIR, 'projects'); const PROJECTS_DIR = join(CLAUDE_DIR, 'projects');
@@ -15,6 +17,12 @@ const TEXT_LIMIT = 10000;
type JsonRecord = Record<string, any>; type JsonRecord = Record<string, any>;
type JsonValue = any; type JsonValue = any;
function sourceInventoryIssue(path: string, error: unknown): InventoryIssue {
return { path, error: error instanceof Error ? error.message : String(error) };
}
type DiscoveryIssueHandler = (issue: InventoryIssue) => void;
export interface ClaudeJsonlFile { export interface ClaudeJsonlFile {
path: string; path: string;
sessionId: string; sessionId: string;
@@ -153,16 +161,19 @@ function inferProjectPath(project: string | null | undefined, observedCwds: unkn
return best?.path || legacyProjectPathFromSlug(project); return best?.path || legacyProjectPathFromSlug(project);
} }
function discoverJsonlFiles(projectsDir = PROJECTS_DIR): ClaudeJsonlFile[] { function discoverJsonlFiles(
projectsDir = PROJECTS_DIR,
reportIssue?: DiscoveryIssueHandler,
): ClaudeJsonlFile[] {
const files: ClaudeJsonlFile[] = []; const files: ClaudeJsonlFile[] = [];
if (!existsSync(projectsDir)) return files; if (!existsSync(projectsDir)) return files;
let projects; let projects;
try { projects = readdirSync(projectsDir); } catch (e) { process.stderr.write(`Warning: cannot read projects dir: ${e instanceof Error ? e.message : String(e)}\n`); return files; } try { projects = readdirSync(projectsDir); } catch (error) { reportIssue?.(sourceInventoryIssue(projectsDir, error)); return files; }
for (const proj of projects) { for (const proj of projects) {
const projPath = join(projectsDir, proj); const projPath = join(projectsDir, proj);
if (!isDir(projPath)) continue; if (!isDir(projPath)) continue;
let entries; let entries;
try { entries = readdirSync(projPath); } catch { continue; } try { entries = readdirSync(projPath); } catch (error) { reportIssue?.(sourceInventoryIssue(projPath, error)); continue; }
for (const f of entries) { for (const f of entries) {
if (f.endsWith('.jsonl')) if (f.endsWith('.jsonl'))
files.push({ path: join(projPath, f), sessionId: f.slice(0, -6), project: proj, isSubagent: false }); files.push({ path: join(projPath, f), sessionId: f.slice(0, -6), project: proj, isSubagent: false });
@@ -171,7 +182,7 @@ function discoverJsonlFiles(projectsDir = PROJECTS_DIR): ClaudeJsonlFile[] {
const saDir = join(projPath, sd, 'subagents'); const saDir = join(projPath, sd, 'subagents');
if (!isDir(saDir)) continue; if (!isDir(saDir)) continue;
let saEntries; let saEntries;
try { saEntries = readdirSync(saDir); } catch { continue; } try { saEntries = readdirSync(saDir); } catch (error) { reportIssue?.(sourceInventoryIssue(saDir, error)); continue; }
for (const sf of saEntries) { for (const sf of saEntries) {
if (sf.endsWith('.jsonl')) if (sf.endsWith('.jsonl'))
files.push({ path: join(saDir, sf), sessionId: sd, project: proj, isSubagent: true, agentId: sf.slice(0, -6) }); files.push({ path: join(saDir, sf), sessionId: sd, project: proj, isSubagent: true, agentId: sf.slice(0, -6) });
@@ -179,12 +190,12 @@ function discoverJsonlFiles(projectsDir = PROJECTS_DIR): ClaudeJsonlFile[] {
const wfRoot = join(saDir, 'workflows'); const wfRoot = join(saDir, 'workflows');
if (!isDir(wfRoot)) continue; if (!isDir(wfRoot)) continue;
let wfDirs; let wfDirs;
try { wfDirs = readdirSync(wfRoot); } catch { continue; } try { wfDirs = readdirSync(wfRoot); } catch (error) { reportIssue?.(sourceInventoryIssue(wfRoot, error)); continue; }
for (const wfDir of wfDirs) { for (const wfDir of wfDirs) {
const wfPath = join(wfRoot, wfDir); const wfPath = join(wfRoot, wfDir);
if (!isDir(wfPath)) continue; if (!isDir(wfPath)) continue;
let wfEntries; let wfEntries;
try { wfEntries = readdirSync(wfPath); } catch { continue; } try { wfEntries = readdirSync(wfPath); } catch (error) { reportIssue?.(sourceInventoryIssue(wfPath, error)); continue; }
for (const wf of wfEntries) { for (const wf of wfEntries) {
if (wf.endsWith('.jsonl')) if (wf.endsWith('.jsonl'))
files.push({ path: join(wfPath, wf), sessionId: sd, project: proj, isSubagent: true, agentId: wf.slice(0, -6), workflowRunId: wfDir }); files.push({ path: join(wfPath, wf), sessionId: sd, project: proj, isSubagent: true, agentId: wf.slice(0, -6), workflowRunId: wfDir });
@@ -195,12 +206,15 @@ function discoverJsonlFiles(projectsDir = PROJECTS_DIR): ClaudeJsonlFile[] {
return files; return files;
} }
function discoverCodexJsonlFiles(sessionsDir = CODEX_SESSIONS_DIR): CodexJsonlFile[] { function discoverCodexJsonlFiles(
sessionsDir = CODEX_SESSIONS_DIR,
reportIssue?: DiscoveryIssueHandler,
): CodexJsonlFile[] {
const files: CodexJsonlFile[] = []; const files: CodexJsonlFile[] = [];
if (!existsSync(sessionsDir)) return files; if (!existsSync(sessionsDir)) return files;
const walk = (dir: string): void => { const walk = (dir: string): void => {
let entries; let entries;
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; } try { entries = readdirSync(dir, { withFileTypes: true }); } catch (error) { reportIssue?.(sourceInventoryIssue(dir, error)); return; }
for (const entry of entries) { for (const entry of entries) {
const fp = join(dir, entry.name); const fp = join(dir, entry.name);
if (entry.isDirectory()) { if (entry.isDirectory()) {
@@ -369,7 +383,7 @@ export {
CLAUDE_DIR, CODEX_DIR, PROJECTS_DIR, CODEX_SESSIONS_DIR, TEXT_LIMIT, CLAUDE_DIR, CODEX_DIR, PROJECTS_DIR, CODEX_SESSIONS_DIR, TEXT_LIMIT,
trunc, truncJson, extractText, extractContentType, extractMessageIsMeta, isSkillInstructions, filePath, isDir, readLines, trunc, truncJson, extractText, extractContentType, extractMessageIsMeta, isSkillInstructions, filePath, isDir, readLines,
legacyProjectPathFromSlug, normalizeObservedCwd, projectSlugFromPath, inferProjectPath, legacyProjectPathFromSlug, normalizeObservedCwd, projectSlugFromPath, inferProjectPath,
discoverJsonlFiles, discoverCodexJsonlFiles, discoverJsonlFiles, discoverCodexJsonlFiles, sourceInventoryIssue,
codexDbId, codexRawId, codexLineUuid, codexCallId, codexParentThreadId, codexIsGuardianThread, codexDbId, codexRawId, codexLineUuid, codexCallId, codexParentThreadId, codexIsGuardianThread,
readCodexGuardianThreadInfo, codexAgentNickname, codexAgentRole, parseCodexJsonInput, readCodexGuardianThreadInfo, codexAgentNickname, codexAgentRole, parseCodexJsonInput,
codexUsage, codexEventText, codexMessagePayloadText, codexVisibleMessageKey, codexToolInput, codexToolOutput, codexUsage, codexEventText, codexMessagePayloadText, codexVisibleMessageKey, codexToolInput, codexToolOutput,
+13 -4
View File
@@ -33,7 +33,7 @@ function statements(db: SqliteDb) {
cwd=excluded.cwd, skill=excluded.skill, source=excluded.source`), cwd=excluded.cwd, skill=excluded.skill, source=excluded.source`),
tc: db.prepare('INSERT OR REPLACE INTO tool_calls (id,message_uuid,session_id,name,presentation,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 (?,?,?,?,?,?)'), 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 (?,?,?,?,?)'), sum: db.prepare('INSERT OR REPLACE INTO summaries (id,session_id,timestamp,source,content,visibility,input_tokens,output_tokens) 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 (?,?,?,?,?,?,?,?,?,?,?)'), 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 (?,?,?,?,?,?,?,?,?,?,?)'),
sub: db.prepare(` sub: db.prepare(`
INSERT INTO subagents (agent_id,session_id,parent_tool_use_id,agent_type,description,duration_ms,total_tokens) INSERT INTO subagents (agent_id,session_id,parent_tool_use_id,agent_type,description,duration_ms,total_tokens)
@@ -65,7 +65,7 @@ function statements(db: SqliteDb) {
tokens=COALESCE(excluded.tokens, workflow_agents.tokens), tokens=COALESCE(excluded.tokens, workflow_agents.tokens),
tool_calls=COALESCE(excluded.tool_calls, workflow_agents.tool_calls)`), tool_calls=COALESCE(excluded.tool_calls, workflow_agents.tool_calls)`),
turn: db.prepare('UPDATE messages SET turn_duration_ms=? WHERE uuid=?'), 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 (?,?,?)'), idx: db.prepare('INSERT OR REPLACE INTO index_state (jsonl_path,mtime,lines_processed,cursor) VALUES (?,?,?,?)'),
getSession: db.prepare('SELECT * FROM sessions WHERE id=?'), getSession: db.prepare('SELECT * FROM sessions WHERE id=?'),
}; };
} }
@@ -99,7 +99,16 @@ export function persist(db: SqliteDb, unit: IndexUnit, gen: Generator<Transcript
st.tr.run(r.tool_use_id, r.message_uuid, r.session_id, r.content, r.file_path, r.is_error); st.tr.run(r.tool_use_id, r.message_uuid, r.session_id, r.content, r.file_path, r.is_error);
break; break;
case 'summary': case 'summary':
st.sum.run(r.id, r.session_id, r.timestamp, r.source, r.content); st.sum.run(
r.id,
r.session_id,
r.timestamp,
r.source,
r.content,
r.visibility ?? 'visible',
r.input_tokens ?? null,
r.output_tokens ?? null,
);
break; break;
case 'subagent': 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); 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);
@@ -147,7 +156,7 @@ export function persist(db: SqliteDb, unit: IndexUnit, gen: Generator<Transcript
if (cursor != null) { if (cursor != null) {
const [mtime, lines] = cursor.split(':'); const [mtime, lines] = cursor.split(':');
st.idx.run(unit.key, Number(mtime), Number(lines)); st.idx.run(unit.key, Number(mtime), Number(lines), cursor);
} }
return cursor; return cursor;
} }
+146 -15
View File
@@ -1,52 +1,135 @@
import { persist } from './persist.ts'; import { persist } from './persist.ts';
import type { ProviderRegistry } from './providers/registry.ts'; import type { ProviderRegistry } from './providers/registry.ts';
import type { Cursor, IndexUnit, ProviderAdapter } from './providers/types.ts'; import type {
Cursor,
IndexedSession,
IndexUnit,
InventoryIssue,
ProviderAdapter,
} from './providers/types.ts';
import type { SqliteDb } from './sqlite-types.ts'; import type { SqliteDb } from './sqlite-types.ts';
export interface ProviderSessionProvenance extends IndexedSession {
readonly source: string;
}
export interface ProviderIndexItem { export interface ProviderIndexItem {
readonly provider: ProviderAdapter; readonly provider: ProviderAdapter;
readonly unit: IndexUnit; readonly unit: IndexUnit;
readonly cursor: Cursor; readonly cursor: Cursor;
} }
export interface ProviderInventoryIssue extends InventoryIssue {
readonly provider: string;
}
export interface ProviderIndexPlan { export interface ProviderIndexPlan {
readonly items: ProviderIndexItem[]; readonly items: ProviderIndexItem[];
readonly pendingMarkers: ReadonlyMap<string, string>; readonly pendingMarkers: ReadonlyMap<string, string>;
readonly replayKeys: ReadonlyMap<string, readonly string[]>;
readonly incompleteProviders: ReadonlySet<string>;
readonly inventoryIssues: readonly ProviderInventoryIssue[];
} }
export interface ProviderIndexResult { export interface ProviderIndexResult {
readonly committed: ProviderIndexItem[]; readonly committed: ProviderIndexItem[];
readonly failedProviders: ReadonlySet<string>; readonly failedProviders: ReadonlySet<string>;
readonly failedItems: ProviderIndexItem[];
readonly complete: boolean;
readonly stopped?: { item: ProviderIndexItem; error: unknown }; readonly stopped?: { item: ProviderIndexItem; error: unknown };
} }
export function storedProviderCursor(db: SqliteDb, key: string): Cursor { export class ProviderIndexFailure extends Error {
const row = db.prepare('SELECT mtime, lines_processed FROM index_state WHERE jsonl_path = ?').get(key); readonly item: ProviderIndexItem;
return row ? `${String(row.mtime)}:${String(row.lines_processed)}` : null; readonly sourceError: unknown;
constructor(error: unknown, item: ProviderIndexItem) {
super(error instanceof Error ? error.message : String(error), { cause: error });
this.item = item;
this.sourceError = error;
}
} }
function sourceAlreadyIndexed(db: SqliteDb, source: string): boolean { export function storedProviderCursor(db: SqliteDb, key: string): Cursor {
return Boolean(db.prepare('SELECT 1 FROM sessions WHERE source = ? LIMIT 1').get(source)); const row = db.prepare('SELECT mtime, lines_processed, cursor FROM index_state WHERE jsonl_path = ?').get(key);
if (!row) return null;
return typeof row.cursor === 'string'
? row.cursor
: `${String(row.mtime)}:${String(row.lines_processed)}`;
}
export function readProviderSessionProvenance(db: SqliteDb): ProviderSessionProvenance[] {
return db.prepare(`
SELECT id, jsonl_path, COALESCE(source, 'claude') AS source
FROM sessions
WHERE jsonl_path IS NOT NULL
AND jsonl_path != ''
`).all().map((row) => ({
source: String(row.source),
sessionId: String(row.id),
jsonlPath: String(row.jsonl_path),
}));
} }
export function createProviderIndexPlan( export function createProviderIndexPlan(
db: SqliteDb, db: SqliteDb,
registry: ProviderRegistry, registry: ProviderRegistry,
{ force = false, changedPaths }: { force?: boolean; changedPaths?: string[] } = {}, {
force = false,
changedPaths,
priorSessions,
}: {
force?: boolean;
changedPaths?: string[];
priorSessions?: readonly ProviderSessionProvenance[];
} = {},
): ProviderIndexPlan { ): ProviderIndexPlan {
const items: ProviderIndexItem[] = []; const items: ProviderIndexItem[] = [];
const pendingMarkers = new Map<string, string>(); const pendingMarkers = new Map<string, string>();
const replayKeys = new Map<string, readonly string[]>();
const incompleteProviders = new Set<string>();
const inventoryIssues: ProviderInventoryIssue[] = [];
const provenance = priorSessions ?? readProviderSessionProvenance(db);
for (const provider of registry.list()) { for (const provider of registry.list()) {
const indexedSessions = provenance
.filter((session) => session.source === provider.name)
.map(({ sessionId, jsonlPath }) => ({ sessionId, jsonlPath }));
const marker = provider.indexVersionMarker; const marker = provider.indexVersionMarker;
const markerMissing = marker !== undefined && !db.prepare( const markerMissing = marker !== undefined && !db.prepare(
'SELECT jsonl_path FROM index_state WHERE jsonl_path = ?', 'SELECT jsonl_path FROM index_state WHERE jsonl_path = ?',
).get(marker); ).get(marker);
if (markerMissing) pendingMarkers.set(provider.name, marker); const fullReindex = force || (markerMissing && indexedSessions.length > 0);
const fullReindex = force || (markerMissing && sourceAlreadyIndexed(db, provider.name)); if (markerMissing && indexedSessions.length > 0) {
replayKeys.set(provider.name, [...new Set(indexedSessions.map((session) => session.jsonlPath))]);
}
let inventoryComplete = true;
let reportedIssue: InventoryIssue | undefined;
const units = provider.discover({ const units = provider.discover({
lastCursor: fullReindex ? () => null : (key) => storedProviderCursor(db, key), lastCursor: fullReindex ? () => null : (key) => storedProviderCursor(db, key),
changedPaths: fullReindex ? undefined : changedPaths, changedPaths: fullReindex ? undefined : changedPaths,
indexedSessions: () => indexedSessions,
reportIncompleteInventory: (issue) => {
inventoryComplete = false;
reportedIssue ??= issue;
},
}); });
if (!inventoryComplete) {
incompleteProviders.add(provider.name);
inventoryIssues.push({
provider: provider.name,
...(reportedIssue ?? {
path: provider.descriptor.defaultRoot,
error: 'Source inventory is incomplete',
}),
});
}
if (
marker !== undefined
&& (force || markerMissing)
&& (inventoryComplete || indexedSessions.length > 0)
) {
pendingMarkers.set(provider.name, marker);
}
for (const unit of units) { for (const unit of units) {
items.push({ items.push({
provider, provider,
@@ -55,7 +138,7 @@ export function createProviderIndexPlan(
}); });
} }
} }
return { items, pendingMarkers }; return { items, pendingMarkers, replayKeys, incompleteProviders, inventoryIssues };
} }
export function indexProviderPlan({ export function indexProviderPlan({
@@ -73,6 +156,7 @@ export function indexProviderPlan({
}): ProviderIndexResult { }): ProviderIndexResult {
const committed: ProviderIndexItem[] = []; const committed: ProviderIndexItem[] = [];
const failedProviders = new Set<string>(); const failedProviders = new Set<string>();
const failedItems: ProviderIndexItem[] = [];
for (const item of plan.items) { for (const item of plan.items) {
try { try {
const cursor = runTransaction(`provider:${item.provider.name}:${item.unit.key}`, () => ( const cursor = runTransaction(`provider:${item.provider.name}:${item.unit.key}`, () => (
@@ -82,12 +166,45 @@ export function indexProviderPlan({
onCommitted(item, cursor); onCommitted(item, cursor);
} catch (error) { } catch (error) {
failedProviders.add(item.provider.name); failedProviders.add(item.provider.name);
failedItems.push(item);
if (onError(error, item) === 'stop') { if (onError(error, item) === 'stop') {
return { committed, failedProviders, stopped: { item, error } }; return {
committed,
failedProviders,
failedItems,
complete: false,
stopped: { item, error },
};
} }
} }
} }
return { committed, failedProviders }; return {
committed,
failedProviders,
failedItems,
complete: failedItems.length === 0 && plan.incompleteProviders.size === 0,
};
}
/** Index every planned unit inside a caller-owned transaction, failing as one snapshot. */
export function indexProviderPlanStrict({
db,
plan,
onCommitted = () => {},
}: {
db: SqliteDb;
plan: ProviderIndexPlan;
onCommitted?: (item: ProviderIndexItem, cursor: Cursor) => void;
}): ProviderIndexResult {
return indexProviderPlan({
db,
plan,
runTransaction: (_label, work) => work(),
onCommitted,
onError: (error, item) => {
throw new ProviderIndexFailure(error, item);
},
});
} }
export function writeProviderIndexMarkers( export function writeProviderIndexMarkers(
@@ -95,12 +212,26 @@ export function writeProviderIndexMarkers(
plan: ProviderIndexPlan, plan: ProviderIndexPlan,
result: ProviderIndexResult, result: ProviderIndexResult,
): void { ): void {
if (result.stopped !== undefined) return;
const retry = db.prepare('DELETE FROM index_state WHERE jsonl_path = ?');
const write = db.prepare( const write = db.prepare(
'INSERT OR REPLACE INTO index_state (jsonl_path, mtime, lines_processed) VALUES (?, ?, 0)', 'INSERT OR REPLACE INTO index_state (jsonl_path, mtime, lines_processed) VALUES (?, ?, 0)',
); );
for (const [provider, marker] of plan.pendingMarkers) { const committed = new Set(result.committed.map(
if (!result.failedProviders.has(provider) && result.stopped === undefined) { (item) => `${item.provider.name}\0${item.unit.key}`,
write.run(marker, Date.now()); ));
// A marker records that replay was scheduled. Per-unit cursors record which
// known sources completed it, so an incomplete inventory retries only the
// missing or failed sources instead of every readable sibling.
for (const [provider, keys] of plan.replayKeys) {
for (const key of keys) {
if (!committed.has(`${provider}\0${key}`)) retry.run(key);
} }
} }
for (const item of result.failedItems) {
if (plan.pendingMarkers.has(item.provider.name)) retry.run(item.unit.key);
}
for (const marker of plan.pendingMarkers.values()) {
write.run(marker, Date.now());
}
} }
+135
View File
@@ -0,0 +1,135 @@
import { existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { isAbsolute, join, normalize } from 'node:path';
import {
createBuiltinProviderRegistry,
type BuiltinProviderRoots,
} from './providers/builtins.ts';
import {
createProviderRegistry,
type ProviderRegistry,
} from './providers/registry.ts';
export type PersistedProviderSettings = Record<string, unknown> & {
providerRoots?: Record<string, unknown>;
};
export interface ProviderSettingsReadResult {
readonly ok: boolean;
readonly settings: PersistedProviderSettings;
readonly error?: string;
}
function configuredPath(value: unknown, homeDir: string): string | null {
if (typeof value !== 'string' || value.trim().length === 0) return null;
const trimmed = value.trim();
const expanded = trimmed === '~'
? homeDir
: trimmed.startsWith('~/') || trimmed.startsWith('~\\')
? join(homeDir, trimmed.slice(2))
: trimmed;
return isAbsolute(expanded) ? normalize(expanded) : null;
}
export function resolveProviderRoots(
registry: ProviderRegistry,
persisted: PersistedProviderSettings = {},
{ homeDir = homedir() }: { homeDir?: string } = {},
): Record<string, string> {
if (
persisted.providerRoots !== undefined
&& persisted.providerRoots !== null
&& (typeof persisted.providerRoots !== 'object' || Array.isArray(persisted.providerRoots))
) return {};
const configured = (
persisted.providerRoots !== null
&& typeof persisted.providerRoots === 'object'
&& !Array.isArray(persisted.providerRoots)
) ? persisted.providerRoots : {};
return Object.fromEntries(registry.catalog().flatMap((descriptor) => {
const modernKey = descriptor.id;
const legacyKey = `${descriptor.id}Dir`;
const hasModern = Object.prototype.hasOwnProperty.call(configured, modernKey)
&& configured[modernKey] !== null;
const hasLegacy = Object.prototype.hasOwnProperty.call(persisted, legacyKey)
&& persisted[legacyKey] !== null;
if (hasModern || hasLegacy) {
const explicit = configuredPath(
hasModern ? configured[modernKey] : persisted[legacyKey],
homeDir,
);
return explicit === null ? [] : [[descriptor.id, explicit]];
}
return descriptor.requiresExplicitRoot ? [] : [[descriptor.id, descriptor.defaultRoot]];
}));
}
export function readPersistedProviderSettings(
settingsPath = join(homedir(), '.obelisk', 'settings.json'),
): ProviderSettingsReadResult {
if (!existsSync(settingsPath)) return { ok: true, settings: {} };
try {
const parsed = JSON.parse(readFileSync(settingsPath, 'utf8')) as unknown;
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
return { ok: false, settings: {}, error: `Obelisk settings are not an object: ${settingsPath}` };
}
const roots = (parsed as PersistedProviderSettings).providerRoots;
if (
roots !== undefined
&& roots !== null
&& (typeof roots !== 'object' || Array.isArray(roots))
) {
return { ok: false, settings: {}, error: `Obelisk providerRoots are not an object: ${settingsPath}` };
}
return { ok: true, settings: parsed as PersistedProviderSettings };
} catch (error) {
return {
ok: false,
settings: {},
error: `Unable to read Obelisk settings at ${settingsPath}: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
export function createConfiguredBuiltinProviderRuntime(
persisted: PersistedProviderSettings = {},
{
homeDir = homedir(),
cwd = process.cwd(),
baseRoots = {},
}: {
homeDir?: string;
cwd?: string;
baseRoots?: BuiltinProviderRoots;
} = {},
): { roots: Record<string, string>; registry: ProviderRegistry } {
const defaults = createBuiltinProviderRegistry(baseRoots, { cwd });
const roots = resolveProviderRoots(defaults, persisted, { homeDir });
const configured = createBuiltinProviderRegistry({ ...baseRoots, ...roots }, { cwd });
return {
roots,
registry: createProviderRegistry(configured.list().map((provider) => {
if (roots[provider.name] !== undefined) return provider;
const reason = provider.descriptor.rootResolutionReason
?? `Configured ${provider.name} root must be absolute or start with ~`;
return {
...provider,
descriptor: {
...provider.descriptor,
requiresExplicitRoot: true,
rootResolutionReason: reason,
},
watchRoots: () => [],
discover: (ctx) => {
ctx.reportIncompleteInventory?.({
path: provider.descriptor.defaultRoot,
error: reason,
});
return [];
},
raw: () => null,
};
})),
};
}
+6 -1
View File
@@ -1,14 +1,19 @@
import { createClaudeProvider } from './claude.ts'; import { createClaudeProvider } from './claude.ts';
import { createCodexProvider } from './codex.ts'; import { createCodexProvider } from './codex.ts';
import { createKimiProvider } from './kimi.ts'; import { createKimiProvider } from './kimi.ts';
import { createPiProvider } from './pi.ts';
import { createProviderRegistry, type ProviderRegistry } from './registry.ts'; import { createProviderRegistry, type ProviderRegistry } from './registry.ts';
export type BuiltinProviderRoots = Readonly<Record<string, string | undefined>>; export type BuiltinProviderRoots = Readonly<Record<string, string | undefined>>;
export function createBuiltinProviderRegistry(roots: BuiltinProviderRoots = {}): ProviderRegistry { export function createBuiltinProviderRegistry(
roots: BuiltinProviderRoots = {},
{ cwd }: { cwd?: string } = {},
): ProviderRegistry {
return createProviderRegistry([ return createProviderRegistry([
createClaudeProvider({ rootDir: roots['claude'] }), createClaudeProvider({ rootDir: roots['claude'] }),
createCodexProvider({ rootDir: roots['codex'] }), createCodexProvider({ rootDir: roots['codex'] }),
createKimiProvider({ rootDir: roots['kimi'] }), createKimiProvider({ rootDir: roots['kimi'] }),
createPiProvider({ rootDir: roots['pi'], cwd }),
]); ]);
} }
+17 -5
View File
@@ -12,7 +12,7 @@ import { dirname, isAbsolute, join, normalize, relative } from 'node:path';
import { import {
extractText, extractContentType, extractMessageIsMeta, isSkillInstructions, extractText, extractContentType, extractMessageIsMeta, isSkillInstructions,
filePath, trunc, truncJson, readLines, discoverJsonlFiles, isDir, filePath, trunc, truncJson, readLines, discoverJsonlFiles, isDir, sourceInventoryIssue,
} from '../parsing.ts'; } from '../parsing.ts';
import type { import type {
@@ -61,6 +61,9 @@ function totalInputTokens(usage: Record<string, unknown>): number | null {
function discoverAt(rootDir: string, ctx: DiscoverContext): IndexUnit[] { function discoverAt(rootDir: string, ctx: DiscoverContext): IndexUnit[] {
const projectsDir = join(rootDir, 'projects'); const projectsDir = join(rootDir, 'projects');
if (!existsSync(projectsDir) && (ctx.indexedSessions?.().length ?? 0) > 0) {
ctx.reportIncompleteInventory?.({ path: projectsDir, error: 'Source folder is unavailable' });
}
const historyPath = normalize(join(rootDir, 'history.jsonl')); const historyPath = normalize(join(rootDir, 'history.jsonl'));
const historyTitles = new Map<string, string>(); const historyTitles = new Map<string, string>();
if (existsSync(historyPath)) { if (existsSync(historyPath)) {
@@ -95,7 +98,7 @@ function discoverAt(rootDir: string, ctx: DiscoverContext): IndexUnit[] {
changedWorkflowPaths.add(absolute); changedWorkflowPaths.add(absolute);
} }
} }
const transcriptUnits = discoverJsonlFiles(projectsDir).filter((file) => { const transcriptUnits = discoverJsonlFiles(projectsDir, ctx.reportIncompleteInventory).filter((file) => {
const normalizedPath = normalize(file.path); const normalizedPath = normalize(file.path);
if (ctx.changedPaths !== undefined && !historyChanged && !changedTranscriptPaths.has(normalizedPath)) return false; if (ctx.changedPaths !== undefined && !historyChanged && !changedTranscriptPaths.has(normalizedPath)) return false;
const cursor = ctx.lastCursor(file.path); const cursor = ctx.lastCursor(file.path);
@@ -118,18 +121,27 @@ function discoverAt(rootDir: string, ctx: DiscoverContext): IndexUnit[] {
const workflowUnits: IndexUnit[] = []; const workflowUnits: IndexUnit[] = [];
if (!existsSync(projectsDir)) return transcriptUnits; if (!existsSync(projectsDir)) return transcriptUnits;
let projects: string[]; let projects: string[];
try { projects = readdirSync(projectsDir); } catch { return transcriptUnits; } try { projects = readdirSync(projectsDir); } catch (error) {
ctx.reportIncompleteInventory?.(sourceInventoryIssue(projectsDir, error));
return transcriptUnits;
}
for (const project of projects) { for (const project of projects) {
const projectPath = join(projectsDir, project); const projectPath = join(projectsDir, project);
if (!isDir(projectPath)) continue; if (!isDir(projectPath)) continue;
let sessionIds: string[]; let sessionIds: string[];
try { sessionIds = readdirSync(projectPath); } catch { continue; } try { sessionIds = readdirSync(projectPath); } catch (error) {
ctx.reportIncompleteInventory?.(sourceInventoryIssue(projectPath, error));
continue;
}
for (const sessionId of sessionIds) { for (const sessionId of sessionIds) {
const workflowDir = join(projectPath, sessionId, 'workflows'); const workflowDir = join(projectPath, sessionId, 'workflows');
if (!isDir(workflowDir)) continue; if (!isDir(workflowDir)) continue;
const mainTranscriptPath = join(projectPath, `${sessionId}.jsonl`); const mainTranscriptPath = join(projectPath, `${sessionId}.jsonl`);
let files: string[]; let files: string[];
try { files = readdirSync(workflowDir); } catch { continue; } try { files = readdirSync(workflowDir); } catch (error) {
ctx.reportIncompleteInventory?.(sourceInventoryIssue(workflowDir, error));
continue;
}
for (const file of files) { for (const file of files) {
if (!file.endsWith('.json')) continue; if (!file.endsWith('.json')) continue;
const workflowPath = join(workflowDir, file); const workflowPath = join(workflowDir, file);
+5 -2
View File
@@ -47,6 +47,9 @@ function messageVisibility(role: string, text: string | null): 'visible' | 'hidd
function discoverAt(rootDir: string, ctx: DiscoverContext): IndexUnit[] { function discoverAt(rootDir: string, ctx: DiscoverContext): IndexUnit[] {
const sessionsDir = join(rootDir, 'sessions'); const sessionsDir = join(rootDir, 'sessions');
if (!existsSync(sessionsDir) && (ctx.indexedSessions?.().length ?? 0) > 0) {
ctx.reportIncompleteInventory?.({ path: sessionsDir, error: 'Source folder is unavailable' });
}
const sessionIndexPath = normalize(join(rootDir, 'session_index.jsonl')); const sessionIndexPath = normalize(join(rootDir, 'session_index.jsonl'));
const sessionIndex = new Map<string, { title: string; updatedAt: string | null }>(); const sessionIndex = new Map<string, { title: string; updatedAt: string | null }>();
if (existsSync(sessionIndexPath)) { if (existsSync(sessionIndexPath)) {
@@ -76,7 +79,7 @@ function discoverAt(rootDir: string, ctx: DiscoverContext): IndexUnit[] {
if (!inside || inside.startsWith('..') || isAbsolute(inside)) continue; if (!inside || inside.startsWith('..') || isAbsolute(inside)) continue;
if (absolute.toLowerCase().endsWith('.jsonl')) changedFiles.add(absolute); if (absolute.toLowerCase().endsWith('.jsonl')) changedFiles.add(absolute);
} }
return discoverCodexJsonlFiles(sessionsDir).flatMap((file) => { return discoverCodexJsonlFiles(sessionsDir, ctx.reportIncompleteInventory).flatMap((file) => {
if (ctx.changedPaths !== undefined && !sessionIndexChanged && !changedFiles.has(normalize(file.path))) return []; if (ctx.changedPaths !== undefined && !sessionIndexChanged && !changedFiles.has(normalize(file.path))) return [];
const cursor = ctx.lastCursor(file.path); const cursor = ctx.lastCursor(file.path);
const guardian = readCodexGuardianThreadInfo(file.path); const guardian = readCodexGuardianThreadInfo(file.path);
@@ -187,7 +190,7 @@ export function* parse(unit: IndexUnit, _cursor: Cursor): Generator<TranscriptRe
out.push(rec); out.push(rec);
msgByUuid.set(uuid, rec); msgByUuid.set(uuid, rec);
sm.lastMessageUuid = uuid; sm.lastMessageUuid = uuid;
if (!agentId) sm.n++; if (!agentId && visibility === 'visible') sm.n++;
if (type === 'assistant' && contentType === 'text') sm.lastTextAssistantUuid = uuid; if (type === 'assistant' && contentType === 'text') sm.lastTextAssistantUuid = uuid;
updateBounds(timestamp); updateBounds(timestamp);
return uuid; return uuid;
+27 -5
View File
@@ -7,7 +7,7 @@ import {
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { basename, dirname, isAbsolute, join, normalize, relative, sep } from 'node:path'; import { basename, dirname, isAbsolute, join, normalize, relative, sep } from 'node:path';
import { filePath, projectSlugFromPath, trunc, truncJson } from '../parsing.ts'; import { filePath, projectSlugFromPath, sourceInventoryIssue, trunc, truncJson } from '../parsing.ts';
import type { import type {
Cursor, Cursor,
DiscoverContext, DiscoverContext,
@@ -17,6 +17,7 @@ import type {
ProviderAdapter, ProviderAdapter,
RawLookup, RawLookup,
RawRecord, RawRecord,
InventoryIssue,
SubagentRecord, SubagentRecord,
SummaryRecord, SummaryRecord,
ToolCallRecord, ToolCallRecord,
@@ -572,14 +573,31 @@ function projectSession(meta: KimiSessionUnitMeta, sessionId: string, state: Jso
}; };
} }
function sessionDirectories(rootDir: string): string[] { function sessionDirectories(
rootDir: string,
reportIssue?: (issue: InventoryIssue) => void,
): string[] {
const sessionsDir = join(rootDir, 'sessions'); const sessionsDir = join(rootDir, 'sessions');
if (!existsSync(sessionsDir)) return []; if (!existsSync(sessionsDir)) return [];
const result: string[] = []; const result: string[] = [];
for (const workspace of readdirSync(sessionsDir, { withFileTypes: true })) { let workspaces;
try {
workspaces = readdirSync(sessionsDir, { withFileTypes: true });
} catch (error) {
reportIssue?.(sourceInventoryIssue(sessionsDir, error));
return result;
}
for (const workspace of workspaces) {
if (!workspace.isDirectory()) continue; if (!workspace.isDirectory()) continue;
const workspaceDir = join(sessionsDir, workspace.name); const workspaceDir = join(sessionsDir, workspace.name);
for (const session of readdirSync(workspaceDir, { withFileTypes: true })) { let sessions;
try {
sessions = readdirSync(workspaceDir, { withFileTypes: true });
} catch (error) {
reportIssue?.(sourceInventoryIssue(workspaceDir, error));
continue;
}
for (const session of sessions) {
if (session.isDirectory()) result.push(join(workspaceDir, session.name)); if (session.isDirectory()) result.push(join(workspaceDir, session.name));
} }
} }
@@ -649,10 +667,14 @@ export function createKimiProvider({ rootDir = defaultKimiRoot() }: { rootDir?:
watchRoots: (configuredRoot) => [join(configuredRoot, 'sessions'), join(configuredRoot, 'session_index.jsonl')], watchRoots: (configuredRoot) => [join(configuredRoot, 'sessions'), join(configuredRoot, 'session_index.jsonl')],
discover(ctx: DiscoverContext): IndexUnit[] { discover(ctx: DiscoverContext): IndexUnit[] {
const units: IndexUnit[] = []; const units: IndexUnit[] = [];
const sessionsDir = join(rootDir, 'sessions');
if (!existsSync(sessionsDir) && (ctx.indexedSessions?.().length ?? 0) > 0) {
ctx.reportIncompleteInventory?.({ path: sessionsDir, error: 'Source folder is unavailable' });
}
const changedSessions = ctx.changedPaths === undefined const changedSessions = ctx.changedPaths === undefined
? null ? null
: changedSessionDirectories(rootDir, ctx.changedPaths); : changedSessionDirectories(rootDir, ctx.changedPaths);
for (const sessionDir of sessionDirectories(rootDir)) { for (const sessionDir of sessionDirectories(rootDir, ctx.reportIncompleteInventory)) {
if (changedSessions !== null && !changedSessions.has(sessionDir)) continue; if (changedSessions !== null && !changedSessions.has(sessionDir)) continue;
const statePath = join(sessionDir, 'state.json'); const statePath = join(sessionDir, 'state.json');
const wireFiles = listWireFiles(sessionDir); const wireFiles = listWireFiles(sessionDir);
File diff suppressed because it is too large Load Diff
+36 -3
View File
@@ -37,6 +37,20 @@ export interface IndexUnit {
agentId?: string; agentId?: string;
/** Adapter-private payload, opaque to the orchestration. */ /** Adapter-private payload, opaque to the orchestration. */
meta?: unknown; meta?: unknown;
/** Previously indexed sessions this unit atomically supersedes or retracts. */
retractSessionIds?: readonly string[];
}
/** Read-only source provenance exposed to one provider during discovery. */
export interface IndexedSession {
sessionId: string;
jsonlPath: string;
}
/** One source location that prevented a provider from certifying its inventory. */
export interface InventoryIssue {
readonly path: string;
readonly error: string;
} }
/** Context the orchestration provides to discovery. */ /** Context the orchestration provides to discovery. */
@@ -45,6 +59,10 @@ export interface DiscoverContext {
lastCursor(key: string): Cursor; lastCursor(key: string): Cursor;
/** When set (daemon changed-path mode), restrict discovery to these paths. */ /** When set (daemon changed-path mode), restrict discovery to these paths. */
changedPaths?: string[]; changedPaths?: string[];
/** Sessions already indexed for this provider, keyed by their canonical source path. */
indexedSessions?(): readonly IndexedSession[];
/** Report that the source inventory could not be enumerated completely. */
reportIncompleteInventory?(issue?: InventoryIssue): void;
} }
/** Canonical language emitted by every provider adapter. Persist serializes it; /** Canonical language emitted by every provider adapter. Persist serializes it;
@@ -62,7 +80,7 @@ export type TranscriptRecord =
| MessageTurnDurationRecord | MessageTurnDurationRecord
| DeleteSessionRecord; | DeleteSessionRecord;
export type MessageVisibility = 'visible' | 'hidden'; export type MessageVisibility = 'visible' | 'inactive' | 'hidden';
export interface MessageRecord { export interface MessageRecord {
kind: 'message'; kind: 'message';
@@ -75,7 +93,10 @@ export interface MessageRecord {
text: string | null; text: string | null;
content_type: string | null; content_type: string | null;
is_meta: 0 | 1; is_meta: 0 | 1;
/** Provider-normalized display eligibility. Assemblers never infer this from text. */ /**
* Provider-attested evidence state. `inactive` requires an explicit source
* supersession signal; assemblers never infer it from text or tree shape.
*/
visibility: MessageVisibility; visibility: MessageVisibility;
model: string | null; model: string | null;
is_sidechain: 0 | 1; is_sidechain: 0 | 1;
@@ -116,6 +137,11 @@ export interface SummaryRecord {
timestamp: string | null; timestamp: string | null;
source: string; source: string;
content: string; content: string;
/** Provider-attested evidence state. Aggregate accounting may include non-visible usage. */
visibility?: MessageVisibility;
/** Provider-normalized total input, including provider-reported cached input. */
input_tokens?: number | null;
output_tokens?: number | null;
} }
// One codex subagent. Like workflow_agent, a row can be contributed by more than // One codex subagent. Like workflow_agent, a row can be contributed by more than
@@ -202,7 +228,8 @@ export interface DeleteSessionRecord {
// a line-incremental adapter (claude) yields only new messages ('delta', persist // a line-incremental adapter (claude) yields only new messages ('delta', persist
// accumulates onto the existing row); a full-reparse adapter (codex) yields every // accumulates onto the existing row); a full-reparse adapter (codex) yields every
// message each run ('total', persist replaces). A 'delta' parse from an empty // message each run ('total', persist replaces). A 'delta' parse from an empty
// cursor is equivalent to 'total'. // cursor is equivalent to 'total'. The count describes the standard visible
// transcript surface; records behind inactive/hidden visibility do not contribute.
export interface SessionRecord { export interface SessionRecord {
kind: 'session'; kind: 'session';
id: string; id: string;
@@ -238,6 +265,10 @@ export interface ProviderDescriptor {
readonly vendor: string; readonly vendor: string;
readonly defaultRoot: string; readonly defaultRoot: string;
readonly color: string; readonly color: string;
/** The automatic root is ambiguous; callers must preserve omission until the user chooses one. */
readonly requiresExplicitRoot?: boolean;
/** User-facing explanation for an unavailable automatic root. */
readonly rootResolutionReason?: string;
} }
export interface RawLookup { export interface RawLookup {
@@ -245,6 +276,8 @@ export interface RawLookup {
readonly messageUuid: string; readonly messageUuid: string;
readonly session: Record<string, unknown> | null; readonly session: Record<string, unknown> | null;
readonly agentId: string | null; readonly agentId: string | null;
/** Cursor committed for this session's source unit, when available. */
readonly cursor?: Cursor;
readonly subagent?: Record<string, unknown> | null; readonly subagent?: Record<string, unknown> | null;
readonly workflowAgent?: Record<string, unknown> | null; readonly workflowAgent?: Record<string, unknown> | null;
} }
+153 -24
View File
@@ -18,6 +18,7 @@ interface QueryOptions extends Record<string, any> {
branch?: string; branch?: string;
source?: string; source?: string;
includeMeta?: boolean; includeMeta?: boolean;
includeInactive?: boolean;
query?: string; query?: string;
projectLimit?: number; projectLimit?: number;
memoryLimit?: number; memoryLimit?: number;
@@ -74,6 +75,34 @@ function buildWhere(opts: QueryOptions, aliases: ColumnAliases) {
const BASH_EXIT_PAT = 'Exit code %'; const BASH_EXIT_PAT = 'Exit code %';
type QueryVisibility = 'visible' | 'inactive' | 'hidden';
function normalizedVisibility(value: unknown): QueryVisibility {
if (value === null || value === undefined || value === 'visible') return 'visible';
if (value === 'inactive') return 'inactive';
return 'hidden';
}
function withVisibility(row: DbRow): DbRow {
return { ...row, visibility: normalizedVisibility(row.visibility) };
}
function isQueryableMessage(
row: DbRow | undefined,
includeInactive = false,
): row is DbRow {
if (row === undefined) return false;
const visibility = normalizedVisibility(row.visibility);
return visibility === 'visible' || (includeInactive && visibility === 'inactive');
}
function visibilitySql(alias: string, includeInactive = false): string {
const column = `${alias}.visibility`;
return includeInactive
? `COALESCE(${column},'visible') IN ('visible','inactive')`
: `COALESCE(${column},'visible')='visible'`;
}
function assertReadOnlySql(sql: unknown): void { function assertReadOnlySql(sql: unknown): void {
const text = String(sql || '').trim(); const text = String(sql || '').trim();
if (!/^(SELECT|WITH)\b/i.test(text)) { if (!/^(SELECT|WITH)\b/i.test(text)) {
@@ -120,7 +149,17 @@ function createQueryApi(
}; };
const search = (text: string, opts: QueryOptions = {}) => { const search = (text: string, opts: QueryOptions = {}) => {
const { limit = 20, sessionId, project, after, before, cwd, source, includeMeta = false } = opts; const {
limit = 20,
sessionId,
project,
after,
before,
cwd,
source,
includeMeta = false,
includeInactive = false,
} = opts;
let where = 'WHERE mf.text MATCH ?'; let where = 'WHERE mf.text MATCH ?';
const filterParams: any[] = []; const filterParams: any[] = [];
if (sessionId) { where += ' AND mf.session_id=?'; filterParams.push(sessionId); } if (sessionId) { where += ' AND mf.session_id=?'; filterParams.push(sessionId); }
@@ -130,8 +169,10 @@ function createQueryApi(
if (cwd) { where += ' AND m.cwd LIKE ?'; filterParams.push(cwd); } if (cwd) { where += ' AND m.cwd LIKE ?'; filterParams.push(cwd); }
if (source && source !== 'all') { where += " AND COALESCE(m.source, s.source, 'claude')=?"; filterParams.push(source); } if (source && source !== 'all') { where += " AND COALESCE(m.source, s.source, 'claude')=?"; filterParams.push(source); }
if (!includeMeta) where += ' AND COALESCE(m.is_meta,0)=0'; if (!includeMeta) where += ' AND COALESCE(m.is_meta,0)=0';
where += ` AND ${visibilitySql('m', includeInactive)}`;
const stmt = db.prepare(` const stmt = db.prepare(`
SELECT m.uuid,m.session_id,m.text,m.content_type,m.is_meta,m.role,m.timestamp,m.model,m.cwd,m.source as m_source, SELECT m.uuid,m.session_id,m.text,m.content_type,m.is_meta,m.role,m.timestamp,m.model,m.cwd,
COALESCE(m.visibility,'visible') AS visibility,m.source as m_source,
s.id as s_id,s.title as s_title,s.project as s_project,s.started_at as s_started, s.id as s_id,s.title as s_title,s.project as s_project,s.started_at as s_started,
s.source as s_source, s.source as s_source,
rank rank
@@ -151,11 +192,31 @@ function createQueryApi(
return rows.map((r: DbRow) => { return rows.map((r: DbRow) => {
const metaClause = includeMeta ? '' : 'AND COALESCE(is_meta,0)=0'; const metaClause = includeMeta ? '' : 'AND COALESCE(is_meta,0)=0';
const ctx = db.prepare( const ctx = db.prepare(
`SELECT uuid,text,content_type,is_meta,role,timestamp,model,COALESCE(source, 'claude') as source FROM messages WHERE session_id=? AND uuid!=? ${metaClause} ORDER BY ABS(JULIANDAY(timestamp)-JULIANDAY(?)) LIMIT 6` `SELECT uuid,text,content_type,is_meta,role,timestamp,model,
).all(r.session_id, r.uuid, r.timestamp).sort((a: DbRow, b: DbRow) => a.timestamp < b.timestamp ? -1 : 1); COALESCE(visibility,'visible') AS visibility,
COALESCE(source, 'claude') as source
FROM messages
WHERE session_id=? AND uuid!=? ${metaClause}
AND ${visibilitySql('messages', includeInactive)}
ORDER BY ABS(JULIANDAY(timestamp)-JULIANDAY(?))
LIMIT 6`
).all(r.session_id, r.uuid, r.timestamp)
.map(withVisibility)
.sort((a: DbRow, b: DbRow) => a.timestamp < b.timestamp ? -1 : 1);
const sourceValue = r.m_source || r.s_source || 'claude'; const sourceValue = r.m_source || r.s_source || 'claude';
return { return {
message: { uuid: r.uuid, text: r.text, content_type: r.content_type, is_meta: r.is_meta || 0, role: r.role, timestamp: r.timestamp, model: r.model, cwd: r.cwd, source: sourceValue }, message: {
uuid: r.uuid,
text: r.text,
content_type: r.content_type,
is_meta: r.is_meta || 0,
role: r.role,
timestamp: r.timestamp,
model: r.model,
cwd: r.cwd,
visibility: normalizedVisibility(r.visibility),
source: sourceValue,
},
session: { id: r.s_id, title: r.s_title, project: r.s_project, started_at: r.s_started, source: r.s_source || sourceValue }, session: { id: r.s_id, title: r.s_title, project: r.s_project, started_at: r.s_started, source: r.s_source || sourceValue },
rank: r.rank, rank: r.rank,
context: ctx, context: ctx,
@@ -163,33 +224,49 @@ function createQueryApi(
}); });
}; };
const context = (uuid: string) => { const context = (uuid: string, opts: QueryOptions = {}) => {
const includeInactive = opts.includeInactive === true;
const msg = db.prepare('SELECT * FROM messages WHERE uuid=?').get(uuid); const msg = db.prepare('SELECT * FROM messages WHERE uuid=?').get(uuid);
if (!msg) return null; if (!isQueryableMessage(msg, includeInactive)) return null;
const message = withVisibility(msg);
const session = db.prepare('SELECT * FROM sessions WHERE id=?').get(msg.session_id); const session = db.prepare('SELECT * FROM sessions WHERE id=?').get(msg.session_id);
const chain: DbRow[] = []; const chain: DbRow[] = [];
let cur: DbRow | undefined = msg; let cur: DbRow | undefined = msg;
while (cur?.parent_uuid) { cur = db.prepare('SELECT * FROM messages WHERE uuid=?').get(cur.parent_uuid); if (cur) chain.unshift(cur); } while (cur?.parent_uuid) {
cur = db.prepare('SELECT * FROM messages WHERE uuid=?').get(cur.parent_uuid);
if (isQueryableMessage(cur, includeInactive)) chain.unshift(withVisibility(cur));
}
const subagent = msg.agent_id ? db.prepare('SELECT * FROM subagents WHERE agent_id=?').get(msg.agent_id) : null; const subagent = msg.agent_id ? db.prepare('SELECT * FROM subagents WHERE agent_id=?').get(msg.agent_id) : null;
let workflow = null; let workflow = null;
if (msg.agent_id) { if (msg.agent_id) {
const wa = db.prepare('SELECT * FROM workflow_agents WHERE agent_id=?').get(msg.agent_id); const wa = db.prepare('SELECT * FROM workflow_agents WHERE agent_id=?').get(msg.agent_id);
if (wa) workflow = db.prepare('SELECT * FROM workflows WHERE run_id=?').get(wa.run_id); if (wa) workflow = db.prepare('SELECT * FROM workflows WHERE run_id=?').get(wa.run_id);
} }
return { message: msg, parentChain: chain, session, subagent, workflow }; return { message, parentChain: chain, session, subagent, workflow };
}; };
const trace = (uuid: string) => { const trace = (uuid: string, opts: QueryOptions = {}) => {
const includeInactive = opts.includeInactive === true;
const chain: DbRow[] = []; const chain: DbRow[] = [];
let cur = db.prepare('SELECT * FROM messages WHERE uuid=?').get(uuid); let cur = db.prepare('SELECT * FROM messages WHERE uuid=?').get(uuid);
while (cur) { chain.unshift(cur); cur = cur.parent_uuid ? db.prepare('SELECT * FROM messages WHERE uuid=?').get(cur.parent_uuid) : undefined; } if (!isQueryableMessage(cur, includeInactive)) return chain;
while (cur) {
if (isQueryableMessage(cur, includeInactive)) chain.unshift(withVisibility(cur));
cur = cur.parent_uuid ? db.prepare('SELECT * FROM messages WHERE uuid=?').get(cur.parent_uuid) : undefined;
}
return chain; return chain;
}; };
const thread = (sid: string, opts: QueryOptions = {}) => { const thread = (sid: string, opts: QueryOptions = {}) => {
const includeMeta = opts?.includeMeta === true; const includeMeta = opts?.includeMeta === true;
const includeInactive = opts?.includeInactive === true;
const metaClause = includeMeta ? '' : 'AND COALESCE(is_meta,0)=0'; const metaClause = includeMeta ? '' : 'AND COALESCE(is_meta,0)=0';
return db.prepare(`SELECT * FROM messages WHERE session_id=? ${metaClause} ORDER BY timestamp`).all(sid); return db.prepare(`
SELECT * FROM messages
WHERE session_id=? ${metaClause}
AND ${visibilitySql('messages', includeInactive)}
ORDER BY timestamp
`).all(sid).map(withVisibility);
}; };
const subagents = (optsOrSid?: QueryOptions | string) => { const subagents = (optsOrSid?: QueryOptions | string) => {
@@ -228,37 +305,73 @@ function createQueryApi(
}; };
const fileHistory = (fp: string, opts: QueryOptions = {}) => { const fileHistory = (fp: string, opts: QueryOptions = {}) => {
const { limit = 200, after, before, source } = opts; const { limit = 200, after, before, source, includeInactive = false } = opts;
let where = 'tc.file_path=?'; let where = `tc.file_path=? AND ${visibilitySql('m', includeInactive)}`;
const params: any[] = [fp]; const params: any[] = [fp];
if (after) { where += ' AND m.timestamp > ?'; params.push(after); } if (after) { where += ' AND m.timestamp > ?'; params.push(after); }
if (before) { where += ' AND m.timestamp < ?'; params.push(before); } if (before) { where += ' AND m.timestamp < ?'; params.push(before); }
if (source && source !== 'all') { where += " AND COALESCE(s.source, 'claude') = ?"; params.push(source); } if (source && source !== 'all') { where += " AND COALESCE(s.source, 'claude') = ?"; params.push(source); }
params.push(limit); params.push(limit);
return db.prepare( return db.prepare(
`SELECT tc.*,s.title as s_title,s.project as s_project,m.timestamp as ts FROM tool_calls tc LEFT JOIN sessions s ON s.id=tc.session_id LEFT JOIN messages m ON m.uuid=tc.message_uuid WHERE ${where} ORDER BY m.timestamp LIMIT ?` `SELECT tc.*,s.title as s_title,s.project as s_project,m.timestamp as ts,
COALESCE(m.visibility,'visible') AS visibility
FROM tool_calls tc
LEFT JOIN sessions s ON s.id=tc.session_id
LEFT JOIN messages m ON m.uuid=tc.message_uuid
WHERE ${where}
ORDER BY m.timestamp
LIMIT ?`
).all(...params).map((r: DbRow) => ({ ).all(...params).map((r: DbRow) => ({
toolCall: { id: r.id, message_uuid: r.message_uuid, name: r.name, input_json: r.input_json }, toolCall: { id: r.id, message_uuid: r.message_uuid, name: r.name, input_json: r.input_json },
session: { id: r.session_id, title: r.s_title, project: r.s_project }, session: { id: r.session_id, title: r.s_title, project: r.s_project },
timestamp: r.ts, timestamp: r.ts,
visibility: normalizedVisibility(r.visibility),
})); }));
}; };
const failures = (optsOrSid?: QueryOptions | string) => { const failures = (optsOrSid?: QueryOptions | string) => {
const opts = normalizeOpts(optsOrSid); const opts = normalizeOpts(optsOrSid);
const { limit = 50 } = opts; const { limit = 50 } = opts;
const includeInactive = opts.includeInactive === true;
const needsJoin = opts.project || opts.branch || opts.source; const needsJoin = opts.project || opts.branch || opts.source;
const { where, params: filterParams } = buildWhere(opts, { sessionId: 'tr.session_id', project: 's.project', timestamp: 'rm.timestamp', branch: 's.git_branch', source: 's.source' }); const { where, params: filterParams } = buildWhere(opts, { sessionId: 'tr.session_id', project: 's.project', timestamp: 'rm.timestamp', branch: 's.git_branch', source: 's.source' });
const join = needsJoin ? 'LEFT JOIN sessions s ON s.id=tr.session_id' : ''; const join = needsJoin ? 'LEFT JOIN sessions s ON s.id=tr.session_id' : '';
const errorCond = `(tr.is_error = 1 OR tr.content LIKE '${BASH_EXIT_PAT}')`; const errorCond = [
`(tr.is_error = 1 OR tr.content LIKE '${BASH_EXIT_PAT}')`,
visibilitySql('rm', includeInactive),
visibilitySql('cm', includeInactive),
].join(' AND ');
const allParams = [...filterParams, limit]; const allParams = [...filterParams, limit];
const rows = db.prepare(`SELECT tr.* FROM tool_results tr ${join} LEFT JOIN messages rm ON rm.uuid=tr.message_uuid WHERE ${errorCond} AND ${where} ORDER BY rm.timestamp DESC LIMIT ?`).all(...allParams); const rows = db.prepare(`
SELECT tr.*, COALESCE(rm.visibility,'visible') AS visibility
FROM tool_results tr
JOIN messages rm ON rm.uuid=tr.message_uuid
JOIN tool_calls tc ON tc.id=tr.tool_use_id
JOIN messages cm ON cm.uuid=tc.message_uuid
${join}
WHERE ${errorCond} AND ${where}
ORDER BY rm.timestamp DESC
LIMIT ?
`).all(...allParams);
return rows.map((r: DbRow) => { return rows.map((r: DbRow) => {
const tc = db.prepare('SELECT * FROM tool_calls WHERE id=?').get(r.tool_use_id); const tc = db.prepare('SELECT * FROM tool_calls WHERE id=?').get(r.tool_use_id);
const session = db.prepare('SELECT * FROM sessions WHERE id=?').get(r.session_id); const session = db.prepare('SELECT * FROM sessions WHERE id=?').get(r.session_id);
const rm = db.prepare('SELECT * FROM messages WHERE uuid=?').get(r.message_uuid); const rmRow = db.prepare('SELECT * FROM messages WHERE uuid=?').get(r.message_uuid);
const next = rm?.timestamp ? db.prepare('SELECT * FROM messages WHERE session_id=? AND timestamp>? ORDER BY timestamp LIMIT 3').all(r.session_id, rm.timestamp) : []; const rm = rmRow === undefined ? undefined : withVisibility(rmRow);
return { toolCall: tc, result: r, session, nextMessages: next }; const next = rm?.timestamp ? db.prepare(`
SELECT * FROM messages
WHERE session_id=? AND timestamp>?
AND ${visibilitySql('messages', includeInactive)}
ORDER BY timestamp
LIMIT 3
`).all(r.session_id, rm.timestamp).map(withVisibility) : [];
return {
toolCall: tc,
result: withVisibility(r),
session,
nextMessages: next,
visibility: normalizedVisibility(r.visibility),
};
}); });
}; };
@@ -275,9 +388,17 @@ function createQueryApi(
const summaries = (optsOrSid?: QueryOptions | string) => { const summaries = (optsOrSid?: QueryOptions | string) => {
const opts = normalizeOpts(optsOrSid); const opts = normalizeOpts(optsOrSid);
const { limit = 100 } = opts; const { limit = 100 } = opts;
const includeInactive = opts.includeInactive === true;
const { where, params } = buildWhere(opts, { sessionId: 'su.session_id', project: 's.project', timestamp: 'su.timestamp', branch: 's.git_branch', source: 's.source' }); const { where, params } = buildWhere(opts, { sessionId: 'su.session_id', project: 's.project', timestamp: 'su.timestamp', branch: 's.git_branch', source: 's.source' });
params.push(limit); params.push(limit);
return db.prepare(`SELECT su.*, s.title as session_title, s.project FROM summaries su LEFT JOIN sessions s ON s.id=su.session_id WHERE ${where} ORDER BY su.timestamp DESC LIMIT ?`).all(...params); return db.prepare(`
SELECT su.*, s.title as session_title, s.project
FROM summaries su
LEFT JOIN sessions s ON s.id=su.session_id
WHERE ${where} AND ${visibilitySql('su', includeInactive)}
ORDER BY su.timestamp DESC
LIMIT ?
`).all(...params).map(withVisibility);
}; };
const overview = (optsOrScalar?: QueryOptions | string | number) => { const overview = (optsOrScalar?: QueryOptions | string | number) => {
@@ -456,10 +577,13 @@ function createQueryApi(
}; };
}; };
const raw = (messageUuid: string, opts: { offset?: number; limit?: number } = {}) => { const raw = (
const { offset = 0, limit = 10000 } = opts; messageUuid: string,
opts: { offset?: number; limit?: number; includeInactive?: boolean } = {},
) => {
const { offset = 0, limit = 10000, includeInactive = false } = opts;
const message = db.prepare('SELECT * FROM messages WHERE uuid=?').get(messageUuid); const message = db.prepare('SELECT * FROM messages WHERE uuid=?').get(messageUuid);
if (!message) return null; if (!isQueryableMessage(message, includeInactive)) return null;
const session = db.prepare('SELECT * FROM sessions WHERE id=?').get(message.session_id) ?? null; const session = db.prepare('SELECT * FROM sessions WHERE id=?').get(message.session_id) ?? null;
const subagent = message.agent_id const subagent = message.agent_id
? db.prepare('SELECT * FROM subagents WHERE agent_id=?').get(message.agent_id) ?? null ? db.prepare('SELECT * FROM subagents WHERE agent_id=?').get(message.agent_id) ?? null
@@ -468,11 +592,15 @@ function createQueryApi(
? db.prepare('SELECT * FROM workflow_agents WHERE agent_id=?').get(message.agent_id) ?? null ? db.prepare('SELECT * FROM workflow_agents WHERE agent_id=?').get(message.agent_id) ?? null
: null; : null;
const source = message.source || session?.source || 'claude'; const source = message.source || session?.source || 'claude';
const cursorRow = typeof session?.jsonl_path === 'string'
? db.prepare('SELECT cursor FROM index_state WHERE jsonl_path=?').get(session.jsonl_path)
: undefined;
const record = providerRegistry.raw({ const record = providerRegistry.raw({
source, source,
messageUuid, messageUuid,
session, session,
agentId: message.agent_id || null, agentId: message.agent_id || null,
cursor: typeof cursorRow?.cursor === 'string' ? cursorRow.cursor : null,
subagent, subagent,
workflowAgent, workflowAgent,
}); });
@@ -484,6 +612,7 @@ function createQueryApi(
offset, offset,
limit, limit,
hasMore: offset + limit < totalLength, hasMore: offset + limit < totalLength,
visibility: normalizedVisibility(message.visibility),
}; };
}; };
+4
View File
@@ -8,6 +8,10 @@ const COLUMN_MIGRATIONS = [
['messages', 'source', "TEXT DEFAULT 'claude'"], ['messages', 'source', "TEXT DEFAULT 'claude'"],
['tool_calls', 'presentation', "TEXT DEFAULT 'default'"], ['tool_calls', 'presentation', "TEXT DEFAULT 'default'"],
['workflows', 'parent_tool_use_id', 'TEXT'], ['workflows', 'parent_tool_use_id', 'TEXT'],
['index_state', 'cursor', 'TEXT'],
['summaries', 'visibility', "TEXT DEFAULT 'visible'"],
['summaries', 'input_tokens', 'INTEGER'],
['summaries', 'output_tokens', 'INTEGER'],
['memories', 'anchors', 'TEXT'], ['memories', 'anchors', 'TEXT'],
['memories', 'deleted_at', 'TEXT'], ['memories', 'deleted_at', 'TEXT'],
['memories', 'deleted_reason', 'TEXT'], ['memories', 'deleted_reason', 'TEXT'],
+3 -2
View File
@@ -30,10 +30,11 @@ CREATE TABLE IF NOT EXISTS workflow_agents (
phase TEXT, label TEXT, model TEXT, state TEXT, phase TEXT, label TEXT, model TEXT, state TEXT,
duration_ms INTEGER, tokens INTEGER, tool_calls INTEGER); duration_ms INTEGER, tokens INTEGER, tool_calls INTEGER);
CREATE TABLE IF NOT EXISTS index_state ( CREATE TABLE IF NOT EXISTS index_state (
jsonl_path TEXT PRIMARY KEY, mtime REAL, lines_processed INTEGER); jsonl_path TEXT PRIMARY KEY, mtime REAL, lines_processed INTEGER, cursor TEXT);
CREATE TABLE IF NOT EXISTS summaries ( CREATE TABLE IF NOT EXISTS summaries (
id TEXT PRIMARY KEY, session_id TEXT, timestamp TEXT, id TEXT PRIMARY KEY, session_id TEXT, timestamp TEXT,
source TEXT, content TEXT); source TEXT, content TEXT, visibility TEXT DEFAULT 'visible',
input_tokens INTEGER, output_tokens INTEGER);
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
uuid UNINDEXED, session_id UNINDEXED, text, content=messages, content_rowid=rowid); uuid UNINDEXED, session_id UNINDEXED, text, content=messages, content_rowid=rowid);
CREATE TRIGGER IF NOT EXISTS messages_fts_ai AFTER INSERT ON messages BEGIN CREATE TRIGGER IF NOT EXISTS messages_fts_ai AFTER INSERT ON messages BEGIN
+41 -7
View File
@@ -1,5 +1,6 @@
import type { import type {
TranscriptRecord, TranscriptRecord,
MessageVisibility,
MessageRecord, MessageRecord,
SessionRecord, SessionRecord,
SummaryRecord, SummaryRecord,
@@ -163,6 +164,9 @@ export interface SessionSummaryRow {
[key: string]: unknown; [key: string]: unknown;
id: string | number; id: string | number;
session_id?: string | null; session_id?: string | null;
visibility?: string | null;
input_tokens?: number | null;
output_tokens?: number | null;
} }
export interface SessionDetailRows { export interface SessionDetailRows {
@@ -180,6 +184,12 @@ function withoutKind<T extends { kind: string }>(record: T): WithoutKind<T> {
return value; return value;
} }
function canonicalVisibility(value: unknown): MessageVisibility {
if (value === null || value === undefined || value === 'visible') return 'visible';
if (value === 'inactive') return 'inactive';
return 'hidden';
}
function assembleMessages( function assembleMessages(
messages: SessionDetailMessage[], messages: SessionDetailMessage[],
toolCalls: ToolCallRecord[], toolCalls: ToolCallRecord[],
@@ -187,8 +197,19 @@ function assembleMessages(
subagents: Extract<TranscriptRecord, { kind: 'subagent' }>[], subagents: Extract<TranscriptRecord, { kind: 'subagent' }>[],
workflows: SessionDetailWorkflow[], workflows: SessionDetailWorkflow[],
): AssembledMessage[] { ): AssembledMessage[] {
const visibleMessageUuids = new Set(messages.map((message) => message.uuid));
const visibleToolCalls = toolCalls.filter((toolCall) => visibleMessageUuids.has(toolCall.message_uuid));
const visibleToolResults = toolResults.filter((result) => visibleMessageUuids.has(result.message_uuid));
const visibleCallIds = new Set(visibleToolCalls.map((toolCall) => toolCall.id));
const attachedResultMessageUuids = new Set(
visibleToolResults
.filter((result) => visibleCallIds.has(result.tool_use_id))
.map((result) => result.message_uuid),
);
const resultsByCallId = new Map<string, SessionDetailToolResult>(); const resultsByCallId = new Map<string, SessionDetailToolResult>();
for (const result of toolResults) resultsByCallId.set(result.tool_use_id, withoutKind(result)); for (const result of visibleToolResults) {
resultsByCallId.set(result.tool_use_id, withoutKind(result));
}
const subagentsByCallId = new Map<string, Extract<TranscriptRecord, { kind: 'subagent' }>>(); const subagentsByCallId = new Map<string, Extract<TranscriptRecord, { kind: 'subagent' }>>();
for (const subagent of subagents) { for (const subagent of subagents) {
@@ -201,7 +222,7 @@ function assembleMessages(
.filter((workflow) => workflow.parent_tool_use_id) .filter((workflow) => workflow.parent_tool_use_id)
.map((workflow) => [workflow.parent_tool_use_id as string, workflow]), .map((workflow) => [workflow.parent_tool_use_id as string, workflow]),
); );
for (const toolCall of toolCalls) { for (const toolCall of visibleToolCalls) {
const call: AssembledToolCall = { const call: AssembledToolCall = {
id: toolCall.id, id: toolCall.id,
name: toolCall.name, name: toolCall.name,
@@ -234,7 +255,10 @@ function assembleMessages(
const output: AssembledMessage[] = []; const output: AssembledMessage[] = [];
for (let index = 0; index < raw.length; index++) { for (let index = 0; index < raw.length; index++) {
const message = raw[index]; const message = raw[index];
if (message.content_type === 'tool_result') continue; if (
message.content_type === 'tool_result'
&& attachedResultMessageUuids.has(message.uuid)
) continue;
if (message.type === 'assistant' && message.content_type === 'thinking') { if (message.type === 'assistant' && message.content_type === 'thinking') {
const thinkingParts = [message.text ?? '']; const thinkingParts = [message.text ?? ''];
@@ -272,7 +296,10 @@ function assembleMessages(
let nextIndex = index + 1; let nextIndex = index + 1;
while (nextIndex < raw.length) { while (nextIndex < raw.length) {
const next = raw[nextIndex]; const next = raw[nextIndex];
if (next.content_type === 'tool_result') { if (
next.content_type === 'tool_result'
&& attachedResultMessageUuids.has(next.uuid)
) {
nextIndex++; nextIndex++;
continue; continue;
} }
@@ -303,7 +330,10 @@ function assembleMessages(
let nextIndex = index + 1; let nextIndex = index + 1;
while (nextIndex < raw.length) { while (nextIndex < raw.length) {
const next = raw[nextIndex]; const next = raw[nextIndex];
if (next.content_type === 'tool_result') { if (
next.content_type === 'tool_result'
&& attachedResultMessageUuids.has(next.uuid)
) {
nextIndex++; nextIndex++;
continue; continue;
} }
@@ -359,7 +389,7 @@ function assembleTranscriptRecords(records: Iterable<TranscriptRecord>): Session
}; };
break; break;
case 'message': { case 'message': {
if (record.visibility === 'hidden') break; if (canonicalVisibility(record.visibility) !== 'visible') break;
const message: SessionDetailMessage = { const message: SessionDetailMessage = {
uuid: record.uuid, uuid: record.uuid,
type: record.type || record.role, type: record.type || record.role,
@@ -401,6 +431,7 @@ function assembleTranscriptRecords(records: Iterable<TranscriptRecord>): Session
} as WorkflowAgentRecord); } as WorkflowAgentRecord);
break; break;
case 'summary': case 'summary':
if (canonicalVisibility(record.visibility) !== 'visible') break;
summaries.push(withoutKind(record)); summaries.push(withoutKind(record));
break; break;
case 'message-turn-duration': { case 'message-turn-duration': {
@@ -487,7 +518,7 @@ function sessionDetailRecordsFromRows(input: SessionDetailRows): TranscriptRecor
text: typeof message.text === 'string' ? message.text : null, text: typeof message.text === 'string' ? message.text : null,
content_type: typeof message.content_type === 'string' ? message.content_type : null, content_type: typeof message.content_type === 'string' ? message.content_type : null,
is_meta: message.is_meta ? 1 : 0, is_meta: message.is_meta ? 1 : 0,
visibility: message.visibility === 'hidden' ? 'hidden' : 'visible', visibility: canonicalVisibility(message.visibility),
model: typeof message.model === 'string' ? message.model : null, model: typeof message.model === 'string' ? message.model : null,
is_sidechain: message.is_sidechain ? 1 : 0, is_sidechain: message.is_sidechain ? 1 : 0,
agent_id: typeof message.agent_id === 'string' ? message.agent_id : null, agent_id: typeof message.agent_id === 'string' ? message.agent_id : null,
@@ -583,6 +614,9 @@ function sessionDetailRecordsFromRows(input: SessionDetailRows): TranscriptRecor
timestamp: typeof summary.timestamp === 'string' ? summary.timestamp : null, timestamp: typeof summary.timestamp === 'string' ? summary.timestamp : null,
source: typeof summary.source === 'string' ? summary.source : '', source: typeof summary.source === 'string' ? summary.source : '',
content: typeof summary.content === 'string' ? summary.content : '', content: typeof summary.content === 'string' ? summary.content : '',
visibility: canonicalVisibility(summary.visibility),
input_tokens: typeof summary.input_tokens === 'number' ? summary.input_tokens : null,
output_tokens: typeof summary.output_tokens === 'number' ? summary.output_tokens : null,
}); });
} }
return records; return records;
+1 -1
View File
@@ -1,7 +1,7 @@
# Obelisk Skill # Obelisk Skill
Explicit memory infrastructure for coding agents — a queryable SQLite evidence Explicit memory infrastructure for coding agents — a queryable SQLite evidence
layer over local Claude Code and Codex session history. layer over local Claude Code, Codex, Kimi Code, and Pi session history.
## Install with your agent (recommended) ## Install with your agent (recommended)
+36 -22
View File
@@ -1,7 +1,7 @@
--- ---
name: obelisk name: obelisk
description: > description: >
Search and query past Claude Code and Codex session history. Search and query past Claude Code, Codex, Kimi Code, and Pi session history.
Reactive: when the user asks "how did I fix X", "what did we do last time", "find the session where", "上次怎么修的", "之前的session", "历史记录". Reactive: when the user asks "how did I fix X", "what did we do last time", "find the session where", "上次怎么修的", "之前的session", "历史记录".
Proactive: when the user references past work you lack context for, when you're about to modify a file with complex edit history, when the user says "继续之前的" or "continue where we left off", or when understanding prior decisions would improve your current response. Proactive: when the user references past work you lack context for, when you're about to modify a file with complex edit history, when the user says "继续之前的" or "continue where we left off", or when understanding prior decisions would improve your current response.
Memory: when the user says "记住这个", "remember this", "写入记忆", "save this conclusion", or when you determine a retrieval result contains a conclusion worth persisting. Memory: when the user says "记住这个", "remember this", "写入记忆", "save this conclusion", or when you determine a retrieval result contains a conclusion worth persisting.
@@ -13,18 +13,17 @@ allowed-tools:
# obelisk # obelisk
Search and query Claude Code and Codex session history stored in `~/.claude/` Search and query local Claude Code, Codex, Kimi Code, and Pi session history.
and `~/.codex/`.
Obelisk indexes sessions, messages, tool calls, tool results, summaries, Obelisk indexes sessions, messages, tool calls, tool results, summaries,
subagents, workflows, workflow agents, parent chains, and raw JSONL lines into subagents, workflows, workflow agents, parent chains, and raw JSONL lines into
SQLite + FTS5. SQLite + FTS5.
Obelisk has two transcript sources. Treat both as ordinary sessions by default: Obelisk has four transcript sources. Treat all of them as ordinary sessions by
Claude rows use `source='claude'`; Codex rows use `source='codex'` and IDs default: Claude rows use `source='claude'`, Codex rows use `source='codex'`,
prefixed with `codex:`. Use `source` only when provenance matters or the user Kimi Code rows use `source='kimi'`, and Pi rows use `source='pi'`. Use `source`
asks to scope to one provider. Codex subagent child threads are mapped to the only when provenance matters or the user asks to scope to one provider.
same `subagents` table; Codex workflow rows may be absent because Codex does not Provider-specific records are projected into the same canonical tables; some
emit Claude-style workflow metadata. providers may not emit every kind of subagent or workflow metadata.
Obelisk is a CodeAct memory layer: write a small JS query, run it locally, read Obelisk is a CodeAct memory layer: write a small JS query, run it locally, read
the JSON, then answer. Do not turn history into a flat document or browse entire the JSON, then answer. Do not turn history into a flat document or browse entire
@@ -148,7 +147,7 @@ messages.
Returns: Returns:
```js ```js
[{ message: { uuid, text, content_type, is_meta, role, timestamp, model, cwd, source }, [{ message: { uuid, text, content_type, is_meta, role, timestamp, model, cwd, visibility, source },
session: { id, title, project, started_at, source }, session: { id, title, project, started_at, source },
rank, rank,
context }] context }]
@@ -171,19 +170,30 @@ conversation evidence. `is_meta=1` marks injected caveats, command envelopes, or
other messages that entered the transcript as user-role content but should not other messages that entered the transcript as user-role content but should not
be treated as the user's request by default. `search()` and `thread()` omit meta be treated as the user's request by default. `search()` and `thread()` omit meta
messages unless `includeMeta: true` is passed; `context()` and `trace()` preserve messages unless `includeMeta: true` is passed; `context()` and `trace()` preserve
the original chain and expose `is_meta` on rows. the current causal chain and expose `is_meta` on returned rows.
Opts: `{ limit, sessionId, project, after, before, cwd, source, includeMeta }`. Pi can preserve a branch that was tried and later superseded as
`visibility='inactive'`. Default helpers return only `visible` evidence. Pass
`includeInactive: true` to `search()`, `context()`, `trace()`, `thread()`,
`summaries()`, `raw()`, `fileHistory()`, or `failures()` only when the abandoned
path matters. Every returned message or evidence row is labeled with
`visibility`; describe inactive evidence as something tried and then
superseded, never as the final decision. `hidden` is reserved for
display-suppressed or transport-only records and is never returned by these
helpers, even with the option enabled.
Opts:
`{ limit, sessionId, project, after, before, cwd, source, includeMeta, includeInactive }`.
`project` is a SQL `LIKE` filter over `sessions.project`, not an exact project `project` is a SQL `LIKE` filter over `sessions.project`, not an exact project
identity. Results are already ordered by FTS5 rank; lower rank sorts earlier. identity. Results are already ordered by FTS5 rank; lower rank sorts earlier.
Prefer returned order over manually interpreting numeric rank unless you are Prefer returned order over manually interpreting numeric rank unless you are
deliberately using FTS5 semantics. deliberately using FTS5 semantics.
`source` can be `'claude'`, `'codex'`, or omitted. Omitted means search all `source` can be `'claude'`, `'codex'`, `'kimi'`, `'pi'`, or omitted. Omitted
indexed sources. means search all indexed sources.
### `context(uuid)` ### `context(uuid, opts?)`
Returns the full story around one indexed message: Returns the full story around one indexed message:
@@ -193,6 +203,8 @@ Returns the full story around one indexed message:
Use this after `search()` finds a promising message. It is the usual way to Use this after `search()` finds a promising message. It is the usual way to
expand vertically from one evidence point without dumping the whole session. expand vertically from one evidence point without dumping the whole session.
The target and returned ancestors must be visible by default. Pass
`{ includeInactive: true }` to follow an explicitly superseded Pi path.
### `sql(query, ...params)` ### `sql(query, ...params)`
@@ -224,17 +236,17 @@ All list helpers accept a bounded `limit`. Many also accept:
filters or return fields. filters or return fields.
- `overview(opts?)` -- compact orientation map. Returns current cwd/project if knowable, global project/source counts, and current-project recent sessions plus memory records. It is a map, not evidence. - `overview(opts?)` -- compact orientation map. Returns current cwd/project if knowable, global project/source counts, and current-project recent sessions plus memory records. It is a map, not evidence.
- `sessions(opts?)` -- session rows, newest first. `project` is a SQL `LIKE` pattern. - `sessions(opts?)` -- session rows, newest first. `project` is a SQL `LIKE` pattern. `message_count` counts the visible canonical transcript; inactive and hidden records are excluded.
- `recent(n?)` -- shorthand for recent sessions. - `recent(n?)` -- shorthand for recent sessions.
- `summaries(opts?)` -- summary rows, newest first: `{ id, session_id, timestamp, source, content, session_title, project }`; here `source` is the summary kind, not the transcript provider. - `summaries(opts?)` -- summary rows, newest first: `{ id, session_id, timestamp, source, content, visibility, session_title, project }`; inactive rows require `includeInactive: true`, hidden rows are never returned, and `source` is the summary kind rather than the transcript provider.
- `subagents(opts?)` -- subagent metadata plus `messageCount`. - `subagents(opts?)` -- subagent metadata plus `messageCount`.
- `workflows(opts?)` -- workflow runs, newest first. - `workflows(opts?)` -- workflow runs, newest first.
- `workflowTree(runId)` -- workflow row plus parsed `result` and `agents`; may include bulky `script` and `result_json`, so project compact fields. - `workflowTree(runId)` -- workflow row plus parsed `result` and `agents`; may include bulky `script` and `result_json`, so project compact fields.
- `fileHistory(filePath, opts?)` -- Read/Edit/Write tool calls for a file, oldest first; includes many `Read` rows. - `fileHistory(filePath, opts?)` -- Read/Edit/Write tool calls for a file, oldest first; includes many `Read` rows and labels each result with `visibility`.
- `failures(opts?)` -- failed tool results with tool/session context, newest first. - `failures(opts?)` -- failed tool results with tool/session context and `visibility`, newest first.
- `trace(uuid)` -- parent chain from root to message. - `trace(uuid, opts?)` -- parent chain from root to message.
- `thread(sessionId, opts?)` -- session messages ordered by timestamp, omitting meta messages by default. Pass `{ includeMeta: true }` when investigating injected context or command envelopes. - `thread(sessionId, opts?)` -- session messages ordered by timestamp, omitting meta messages by default. Pass `{ includeMeta: true }` for injected context or `{ includeInactive: true }` for superseded Pi history.
- `raw(uuid, opts?)` -- windowed access to the original JSONL line. - `raw(uuid, opts?)` -- windowed source access for one visible message. Pi returns the selected source-message container whether it was stored directly or inside a retained tail. Inactive targets require `includeInactive: true`; hidden targets return `null`.
- `memories(opts?)` -- recall memory layer. opts: `{ query, project, sessionId, sessions, after, before, branch, limit }`. Without `query`, returns active memory records newest first. With `query`, searches `summary`/`path` through safe FTS5 tokenization and returns `rank`; lower rank sorts earlier. Records may include nullable JSON `anchors` for explicit recall surfaces such as files. Read the file at `path` for full content. - `memories(opts?)` -- recall memory layer. opts: `{ query, project, sessionId, sessions, after, before, branch, limit }`. Without `query`, returns active memory records newest first. With `query`, searches `summary`/`path` through safe FTS5 tokenization and returns `rank`; lower rank sorts earlier. Records may include nullable JSON `anchors` for explicit recall surfaces such as files. Read the file at `path` for full content.
## Retrieval Contract ## Retrieval Contract
@@ -248,6 +260,7 @@ Keep queries scoped, bounded, and structural.
- Structure Before Text: compute counts, joins, grouping, dedupe, and projection in SQL or JS; keep runtime JSON compact, ideally under 10k-12k chars for synthesis tasks. - Structure Before Text: compute counts, joins, grouping, dedupe, and projection in SQL or JS; keep runtime JSON compact, ideally under 10k-12k chars for synthesis tasks.
- Evidence Before Conclusion: return compact evidence with stable IDs (`session_id`, `uuid`, `tool_call_id`, `run_id`, `agent_id`) and short snippets, then synthesize in the final answer. - Evidence Before Conclusion: return compact evidence with stable IDs (`session_id`, `uuid`, `tool_call_id`, `run_id`, `agent_id`) and short snippets, then synthesize in the final answer.
- Exclude Meta By Default: `is_meta=1` rows are injected/control-plane transcript material. Helpers hide them by default; raw SQL for ordinary conversation evidence should include `COALESCE(m.is_meta,0)=0` unless meta rows are the investigation target. - Exclude Meta By Default: `is_meta=1` rows are injected/control-plane transcript material. Helpers hide them by default; raw SQL for ordinary conversation evidence should include `COALESCE(m.is_meta,0)=0` unless meta rows are the investigation target.
- Exclude Superseded Paths By Default: ordinary evidence must use exact visible-only filtering. Opt into inactive Pi history only to explain an abandoned path, and label it as tried then superseded.
- Persist Durable Conclusions: after answering, if retrieval produced a durable conclusion that future sessions are likely to reuse and `memories()` does not already cover it, explicitly offer to write a memory. Keep the offer brief. Do not write the markdown file or run `--attune` until the user approves. - Persist Durable Conclusions: after answering, if retrieval produced a durable conclusion that future sessions are likely to reuse and `memories()` does not already cover it, explicitly offer to write a memory. Keep the offer brief. Do not write the markdown file or run `--attune` until the user approves.
If field, context, ordering, FTS, or helper semantics affect the query, read If field, context, ordering, FTS, or helper semantics affect the query, read
@@ -390,6 +403,7 @@ return sql(
`SELECT uuid, role, timestamp, substr(text,1,240) AS snippet `SELECT uuid, role, timestamp, substr(text,1,240) AS snippet
FROM messages FROM messages
WHERE session_id=? AND timestamp>=? WHERE session_id=? AND timestamp>=?
AND COALESCE(visibility, 'visible') = 'visible'
ORDER BY timestamp LIMIT 6`, ORDER BY timestamp LIMIT 6`,
hit.session.id, hit.session.id,
hit.message.timestamp hit.message.timestamp
+37 -17
View File
@@ -57,14 +57,15 @@ Full-text search across all indexed message text using FTS5.
| `opts.after` | `string` | ISO lower bound on message timestamp | | `opts.after` | `string` | ISO lower bound on message timestamp |
| `opts.before` | `string` | ISO upper bound on message timestamp | | `opts.before` | `string` | ISO upper bound on message timestamp |
| `opts.cwd` | `string` | SQL `LIKE` filter over `messages.cwd` | | `opts.cwd` | `string` | SQL `LIKE` filter over `messages.cwd` |
| `opts.source` | `string` | `"claude"`, `"codex"`, or omitted/all | | `opts.source` | `string` | Provider ID such as `"claude"`, `"codex"`, `"kimi"`, or `"pi"` |
| `opts.includeMeta` | `boolean` | Include `is_meta=1` rows, default false | | `opts.includeMeta` | `boolean` | Include `is_meta=1` rows, default false |
| `opts.includeInactive` | `boolean` | Include provider-attested superseded rows, default false |
Returns: Returns:
```js ```js
Array<{ Array<{
message: { uuid, text, content_type, is_meta, role, timestamp, model, cwd, source }, message: { uuid, text, content_type, is_meta, role, timestamp, model, cwd, visibility, source },
session: { id, title, project, started_at, source }, session: { id, title, project, started_at, source },
rank, rank,
context context
@@ -72,6 +73,7 @@ Array<{
``` ```
`context` is temporal neighbor context in the same session, not a parent chain. `context` is temporal neighbor context in the same session, not a parent chain.
Hits and neighbors carry `visibility`.
Use `context(uuid)` or `trace(uuid)` for causal/parent-chain expansion. Lower Use `context(uuid)` or `trace(uuid)` for causal/parent-chain expansion. Lower
FTS rank sorts earlier; prefer returned order unless deliberately inspecting FTS rank sorts earlier; prefer returned order unless deliberately inspecting
FTS ranking. FTS ranking.
@@ -81,13 +83,14 @@ malformed (for example a hyphenated term like `foo-bar`) does not error: it
falls back to safe per-token quoting — the same tokenization `memories()` uses — falls back to safe per-token quoting — the same tokenization `memories()` uses —
so ordinary text never crashes the query. so ordinary text never crashes the query.
#### `context(uuid)` #### `context(uuid, opts?)`
Full indexed context around one message. Full indexed context around one message.
| Param | Type | Description | | Param | Type | Description |
| --- | --- | --- | | --- | --- | --- |
| `uuid` | `string` | Message UUID | | `uuid` | `string` | Message UUID |
| `opts.includeInactive` | `boolean` | Include a superseded target and ancestors, default false |
Returns: Returns:
@@ -95,9 +98,11 @@ Returns:
{ message, parentChain, session, subagent, workflow } | null { message, parentChain, session, subagent, workflow } | null
``` ```
`parentChain` contains ancestors, not temporal neighbors. If the message belongs `message` and every returned ancestor carry `visibility`. `parentChain`
contains ancestors, not temporal neighbors. If the message belongs
to a subagent or workflow agent, `subagent` or `workflow` is populated when the to a subagent or workflow agent, `subagent` or `workflow` is populated when the
metadata exists. metadata exists. Hidden targets always return `null`, and hidden ancestors are
always omitted.
#### `sql(query, ...params)` #### `sql(query, ...params)`
@@ -176,7 +181,7 @@ Returns:
projects, projects,
sessions, sessions,
memories, memories,
sources: [{ source: 'claude' | 'codex', session_count, last_session_at }] sources: [{ source: 'claude' | 'codex' | 'kimi' | 'pi', session_count, last_session_at }]
} }
} }
``` ```
@@ -195,11 +200,13 @@ Session rows ordered by `ended_at` descending. Passing a number is treated as
| `opts.before` | `string` | ISO upper bound on `started_at` | | `opts.before` | `string` | ISO upper bound on `started_at` |
| `opts.limit` | `number` | Max rows, default 50 | | `opts.limit` | `number` | Max rows, default 50 |
| `opts.branch` | `string` | Exact git branch | | `opts.branch` | `string` | Exact git branch |
| `opts.source` | `string` | `"claude"`, `"codex"`, or omitted/all | | `opts.source` | `string` | Provider ID such as `"claude"`, `"codex"`, `"kimi"`, or `"pi"`; omit for all |
| `opts.sessionId` | `string` | Exact session ID | | `opts.sessionId` | `string` | Exact session ID |
| `opts.sessions` | `string[]` | Restrict to session IDs | | `opts.sessions` | `string[]` | Restrict to session IDs |
Returns `Array<session_row>`. Returns `Array<session_row>`.
`message_count` describes the visible canonical transcript; inactive and hidden
records do not increase it.
#### `recent(n?)` #### `recent(n?)`
@@ -222,6 +229,7 @@ is treated as `sessionId`; passing a number is treated as `limit`.
| `opts.branch` | `string` | Exact source session branch | | `opts.branch` | `string` | Exact source session branch |
| `opts.source` | `string` | Provider filter through joined session | | `opts.source` | `string` | Provider filter through joined session |
| `opts.limit` | `number` | Max rows, default 100 | | `opts.limit` | `number` | Max rows, default 100 |
| `opts.includeInactive` | `boolean` | Include superseded summaries, default false |
Returns: Returns:
@@ -230,7 +238,10 @@ Array<summary_row & { session_title, project }>
``` ```
`summaries.source` is the summary kind, such as `away_summary`; it is not the `summaries.source` is the summary kind, such as `away_summary`; it is not the
provider source. provider source. `input_tokens` and `output_tokens` contain normalized usage
when the provider performed a separate model call for that summary.
Rows carry `visibility`. Inactive summaries describe work that was tried and
then superseded. Hidden summaries are never returned.
#### `memories(opts?)` #### `memories(opts?)`
@@ -265,11 +276,13 @@ full content.
## Structural Expansion Helpers ## Structural Expansion Helpers
#### `trace(uuid)` #### `trace(uuid, opts?)`
Walk the `parent_uuid` chain from a message to the conversation root. Walk the `parent_uuid` chain from a message to the conversation root.
Returns `Array<message>` ordered root-first. Pass `{ includeInactive: true }` to follow a superseded path. Returns labeled
messages ordered root-first. A hidden target returns an empty array, and hidden
ancestors are omitted.
#### `thread(sessionId, opts?)` #### `thread(sessionId, opts?)`
@@ -279,30 +292,34 @@ Messages in a session ordered by timestamp.
| --- | --- | --- | | --- | --- | --- |
| `sessionId` | `string` | Session ID | | `sessionId` | `string` | Session ID |
| `opts.includeMeta` | `boolean` | Include injected/control-plane rows, default false | | `opts.includeMeta` | `boolean` | Include injected/control-plane rows, default false |
| `opts.includeInactive` | `boolean` | Include superseded messages, default false |
Returns `Array<message>`. Use `thread()` as a last resort; prefer targeted Returns `Array<message>`. Use `thread()` as a last resort; prefer targeted
search/context or compact SQL projections. search/context or compact SQL projections.
#### `raw(uuid, opts?)` #### `raw(uuid, opts?)`
Windowed access to the original JSONL line for one indexed message. Use this Windowed access to the source record for one indexed message, normally its
when indexed text, tool inputs, or tool results were truncated and you need the original JSONL line. Use this when indexed text, tool inputs, or tool results
raw source. were truncated and you need the raw source. Pi returns the selected source
message object for both direct and retained-tail storage, so one physical
compaction line never exposes other retained messages.
| Param | Type | Description | | Param | Type | Description |
| --- | --- | --- | | --- | --- | --- |
| `uuid` | `string` | Message UUID | | `uuid` | `string` | Message UUID |
| `opts.offset` | `number` | Character offset into the JSONL line, default 0 | | `opts.offset` | `number` | Character offset into the JSONL line, default 0 |
| `opts.limit` | `number` | Max characters, default 10000 | | `opts.limit` | `number` | Max characters, default 10000 |
| `opts.includeInactive` | `boolean` | Allow a superseded target, default false |
Returns: Returns:
```js ```js
{ text, totalLength, offset, limit, hasMore } | null { text, totalLength, offset, limit, hasMore, visibility } | null
``` ```
`raw()` resolves main-session, subagent, workflow-agent, and Codex JSONL paths `raw()` resolves main-session, subagent, workflow-agent, and Codex JSONL paths
from indexed metadata. from indexed metadata. Hidden targets always return `null`.
--- ---
@@ -369,6 +386,7 @@ well as `Edit`/`Write`.
| `opts.before` | `string` | ISO upper bound | | `opts.before` | `string` | ISO upper bound |
| `opts.source` | `string` | Provider filter | | `opts.source` | `string` | Provider filter |
| `opts.limit` | `number` | Max rows, default 200 | | `opts.limit` | `number` | Max rows, default 200 |
| `opts.includeInactive` | `boolean` | Include superseded tool evidence, default false |
Returns: Returns:
@@ -376,7 +394,8 @@ Returns:
Array<{ Array<{
toolCall: { id, message_uuid, name, input_json }, toolCall: { id, message_uuid, name, input_json },
session: { id, title, project }, session: { id, title, project },
timestamp timestamp,
visibility
}> }>
``` ```
@@ -396,11 +415,12 @@ the failure. Passing a string is treated as `sessionId`.
| `opts.before` | `string` | ISO upper bound on result message timestamp | | `opts.before` | `string` | ISO upper bound on result message timestamp |
| `opts.source` | `string` | Provider filter | | `opts.source` | `string` | Provider filter |
| `opts.limit` | `number` | Max rows, default 50 | | `opts.limit` | `number` | Max rows, default 50 |
| `opts.includeInactive` | `boolean` | Include superseded failures and neighbors, default false |
Returns: Returns:
```js ```js
Array<{ toolCall, result, session, nextMessages }> Array<{ toolCall, result, session, nextMessages, visibility }>
``` ```
Use SQL for precise counts and grouping; treat `failures()` as compact evidence, Use SQL for precise counts and grouping; treat `failures()` as compact evidence,
+1
View File
@@ -39,6 +39,7 @@ sql(`
JOIN sessions s ON s.id = m.session_id JOIN sessions s ON s.id = m.session_id
WHERE s.project LIKE ? WHERE s.project LIKE ?
AND m.text LIKE ? AND m.text LIKE ?
AND COALESCE(m.visibility, 'visible') = 'visible'
ORDER BY m.timestamp DESC ORDER BY m.timestamp DESC
LIMIT 10 LIMIT 10
`, '%quiet-zero%', '%workflow-script%') `, '%quiet-zero%', '%workflow-script%')
+8
View File
@@ -349,6 +349,7 @@ for (const { facet, terms } of learnedFacets) {
JOIN sessions s ON s.id = m.session_id JOIN sessions s ON s.id = m.session_id
WHERE m.session_id IN (${sessionIds.map(() => '?').join(',')}) WHERE m.session_id IN (${sessionIds.map(() => '?').join(',')})
AND m.text IS NOT NULL AND m.text IS NOT NULL
AND COALESCE(m.visibility, 'visible') = 'visible'
AND (${clauses}) AND (${clauses})
ORDER BY m.timestamp ORDER BY m.timestamp
LIMIT 3 LIMIT 3
@@ -448,6 +449,7 @@ const before = sql(
`SELECT uuid, role, timestamp, substr(text,1,200) AS snippet `SELECT uuid, role, timestamp, substr(text,1,200) AS snippet
FROM messages FROM messages
WHERE session_id=? AND timestamp<? WHERE session_id=? AND timestamp<?
AND COALESCE(visibility, 'visible') = 'visible'
ORDER BY timestamp DESC LIMIT 3`, ORDER BY timestamp DESC LIMIT 3`,
s.session_id, s.session_id,
s.timestamp s.timestamp
@@ -456,6 +458,7 @@ const after = sql(
`SELECT uuid, role, timestamp, substr(text,1,200) AS snippet `SELECT uuid, role, timestamp, substr(text,1,200) AS snippet
FROM messages FROM messages
WHERE session_id=? AND timestamp>? WHERE session_id=? AND timestamp>?
AND COALESCE(visibility, 'visible') = 'visible'
ORDER BY timestamp ASC LIMIT 3`, ORDER BY timestamp ASC LIMIT 3`,
s.session_id, s.session_id,
s.timestamp s.timestamp
@@ -532,6 +535,7 @@ const counts = sql(`
JOIN messages m ON m.uuid = tr.message_uuid JOIN messages m ON m.uuid = tr.message_uuid
JOIN sessions s ON s.id = tr.session_id JOIN sessions s ON s.id = tr.session_id
WHERE tr.is_error = 1 WHERE tr.is_error = 1
AND COALESCE(m.visibility, 'visible') = 'visible'
AND s.project LIKE ? AND s.project LIKE ?
GROUP BY tc.name GROUP BY tc.name
ORDER BY failure_count DESC, last_failure_at DESC ORDER BY failure_count DESC, last_failure_at DESC
@@ -551,6 +555,7 @@ const examples = sql(`
JOIN messages m ON m.uuid = tr.message_uuid JOIN messages m ON m.uuid = tr.message_uuid
JOIN sessions s ON s.id = tr.session_id JOIN sessions s ON s.id = tr.session_id
WHERE tr.is_error = 1 WHERE tr.is_error = 1
AND COALESCE(m.visibility, 'visible') = 'visible'
AND s.project LIKE ? AND s.project LIKE ?
ORDER BY m.timestamp DESC ORDER BY m.timestamp DESC
LIMIT 8 LIMIT 8
@@ -580,6 +585,7 @@ const groups = sql(`
JOIN messages m ON m.uuid = tr.message_uuid JOIN messages m ON m.uuid = tr.message_uuid
JOIN sessions s ON s.id = tr.session_id JOIN sessions s ON s.id = tr.session_id
WHERE tr.is_error = 1 WHERE tr.is_error = 1
AND COALESCE(m.visibility, 'visible') = 'visible'
AND s.project LIKE ? AND s.project LIKE ?
GROUP BY s.id GROUP BY s.id
ORDER BY last_failure_at DESC ORDER BY last_failure_at DESC
@@ -598,6 +604,7 @@ const examples = sql(`
JOIN messages m ON m.uuid = tr.message_uuid JOIN messages m ON m.uuid = tr.message_uuid
JOIN sessions s ON s.id = tr.session_id JOIN sessions s ON s.id = tr.session_id
WHERE tr.is_error = 1 WHERE tr.is_error = 1
AND COALESCE(m.visibility, 'visible') = 'visible'
AND s.project LIKE ? AND s.project LIKE ?
ORDER BY m.timestamp DESC ORDER BY m.timestamp DESC
LIMIT 12 LIMIT 12
@@ -694,6 +701,7 @@ const row = sql(`
SELECT uuid, length(text) AS indexed_len SELECT uuid, length(text) AS indexed_len
FROM messages FROM messages
WHERE length(text) >= 10000 WHERE length(text) >= 10000
AND COALESCE(visibility, 'visible') = 'visible'
LIMIT 1 LIMIT 1
`)[0]; `)[0];
if (!row) return null; if (!row) return null;
+4 -1
View File
@@ -37,7 +37,7 @@ Project-like fields are distinct:
- `memories.project`: stored project slug copied onto registered memory records. - `memories.project`: stored project slug copied onto registered memory records.
- `sessions.project_path`: absolute session path derived from message `cwd` when available; slug decoding is only a fallback. - `sessions.project_path`: absolute session path derived from message `cwd` when available; slug decoding is only a fallback.
- `messages.cwd`: working directory at message time. - `messages.cwd`: working directory at message time.
- `sessions.source` / `messages.source`: transcript provider, currently `claude` or `codex`. - `sessions.source` / `messages.source`: transcript provider: `claude`, `codex`, `kimi`, or `pi`.
- helper `project`: SQL `LIKE` over `sessions.project`, not exact membership. - helper `project`: SQL `LIKE` over `sessions.project`, not exact membership.
- helper `source`: optional provider filter. Omit it unless provenance matters. - helper `source`: optional provider filter. Omit it unless provenance matters.
@@ -85,6 +85,9 @@ Ordering and context are semantic:
- `fileHistory()` is oldest first. - `fileHistory()` is oldest first.
- `search().context` is temporal neighbors in one session, not causal context. - `search().context` is temporal neighbors in one session, not causal context.
- `context(uuid)` and `trace(uuid)` are for parent-chain/causal expansion. - `context(uuid)` and `trace(uuid)` are for parent-chain/causal expansion.
They return only current evidence by default. Use `includeInactive: true` for
a Pi path that was tried and then superseded; hidden records remain
unavailable.
### Evidence Before Conclusion ### Evidence Before Conclusion
+15 -6
View File
@@ -14,11 +14,14 @@ exist.
## Source Model ## Source Model
Obelisk stores Claude Code and Codex transcripts in the same schema. Obelisk stores Claude Code, Codex, Kimi Code, and Pi transcripts in the same
schema.
- Claude rows use `source='claude'`. - Claude rows use `source='claude'`.
- Codex rows use `source='codex'`; root session and message IDs are prefixed - Codex rows use `source='codex'`; root session and message IDs are prefixed
with `codex:`. with `codex:`.
- Kimi Code rows use `source='kimi'`.
- Pi rows use `source='pi'`; session IDs are prefixed with `pi:`.
- Omit `source` filters unless provider provenance matters. - Omit `source` filters unless provider provenance matters.
- Codex child threads are represented through `subagents`; Codex may not have - Codex child threads are represented through `subagents`; Codex may not have
Claude-style workflow rows. Claude-style workflow rows.
@@ -59,9 +62,9 @@ One row per root session.
| `started_at`, `ended_at` | ISO timestamps | | `started_at`, `ended_at` | ISO timestamps |
| `git_branch` | Branch at session time | | `git_branch` | Branch at session time |
| `version` | Provider CLI/app version | | `version` | Provider CLI/app version |
| `message_count` | Indexed user + assistant messages | | `message_count` | Visible canonical messages; inactive and hidden records are excluded |
| `jsonl_path` | Source JSONL path | | `jsonl_path` | Source JSONL path |
| `source` | `claude` or `codex` | | `source` | Provider ID: `claude`, `codex`, `kimi`, or `pi` |
### `messages` ### `messages`
@@ -77,6 +80,7 @@ Core evidence table.
| `text` | Extracted text, truncated to 10k chars | | `text` | Extracted text, truncated to 10k chars |
| `content_type` | `text`, `thinking`, `tool_use`, `tool_result`, or `unknown` | | `content_type` | `text`, `thinking`, `tool_use`, `tool_result`, or `unknown` |
| `is_meta` | 1 for injected/control-plane messages | | `is_meta` | 1 for injected/control-plane messages |
| `visibility` | `visible` for current evidence, `inactive` for provider-attested superseded history, `hidden` for display-suppressed or transport-only material |
| `model` | Assistant model name | | `model` | Assistant model name |
| `is_sidechain` | Retry/branch marker | | `is_sidechain` | Retry/branch marker |
| `agent_id` | Subagent/workflow agent ID | | `agent_id` | Subagent/workflow agent ID |
@@ -84,7 +88,7 @@ Core evidence table.
| `cwd` | Working directory at message time | | `cwd` | Working directory at message time |
| `skill` | Skill that generated the response, if known | | `skill` | Skill that generated the response, if known |
| `turn_duration_ms` | Wall-clock duration for the turn | | `turn_duration_ms` | Wall-clock duration for the turn |
| `source` | `claude` or `codex` | | `source` | Provider ID: `claude`, `codex`, `kimi`, or `pi` |
`content_type='tool_use'` is only a marker. Tool-call details live in `content_type='tool_use'` is only a marker. Tool-call details live in
`tool_calls`. `content_type='tool_result'` marks provider-emitted tool-result `tool_calls`. `content_type='tool_result'` marks provider-emitted tool-result
@@ -131,6 +135,8 @@ Session summary rows.
| `timestamp` | Summary timestamp | | `timestamp` | Summary timestamp |
| `source` | Summary kind, such as `away_summary`; not provider source | | `source` | Summary kind, such as `away_summary`; not provider source |
| `content` | Summary text | | `content` | Summary text |
| `visibility` | Same `visible` / `inactive` / `hidden` contract as messages |
| `input_tokens`, `output_tokens` | Model usage when summary generation was a separate provider call |
### `subagents` ### `subagents`
@@ -207,6 +213,7 @@ Indexer progress and sentinel state.
| `jsonl_path` | Source path or synthetic sentinel key | | `jsonl_path` | Source path or synthetic sentinel key |
| `mtime` | Last indexed mtime | | `mtime` | Last indexed mtime |
| `lines_processed` | Incremental line cursor | | `lines_processed` | Incremental line cursor |
| `cursor` | Exact opaque provider cursor; legacy rows fall back to `mtime:lines_processed` |
Sentinel keys include `__last_build__`, `__app_heartbeat__`, Sentinel keys include `__last_build__`, `__app_heartbeat__`,
`__app_last_successful_build__`, `__indexer_owner_app__`, and `__app_last_successful_build__`, `__indexer_owner_app__`, and
@@ -250,6 +257,7 @@ FROM tool_calls tc
JOIN messages m ON m.uuid = tc.message_uuid JOIN messages m ON m.uuid = tc.message_uuid
JOIN sessions s ON s.id = tc.session_id JOIN sessions s ON s.id = tc.session_id
WHERE s.project LIKE ? WHERE s.project LIKE ?
AND COALESCE(m.visibility, 'visible') = 'visible'
ORDER BY m.timestamp DESC ORDER BY m.timestamp DESC
LIMIT 20; LIMIT 20;
``` ```
@@ -262,6 +270,7 @@ FROM tool_results tr
JOIN tool_calls tc ON tc.id = tr.tool_use_id JOIN tool_calls tc ON tc.id = tr.tool_use_id
JOIN messages m ON m.uuid = tr.message_uuid JOIN messages m ON m.uuid = tr.message_uuid
WHERE tr.is_error = 1 WHERE tr.is_error = 1
AND COALESCE(m.visibility, 'visible') = 'visible'
ORDER BY m.timestamp DESC ORDER BY m.timestamp DESC
LIMIT 20; LIMIT 20;
``` ```
@@ -274,6 +283,7 @@ FROM messages m
JOIN sessions s ON s.id = m.session_id JOIN sessions s ON s.id = m.session_id
WHERE s.project LIKE ? WHERE s.project LIKE ?
AND COALESCE(m.is_meta, 0) = 0 AND COALESCE(m.is_meta, 0) = 0
AND COALESCE(m.visibility, 'visible') = 'visible'
ORDER BY m.timestamp DESC ORDER BY m.timestamp DESC
LIMIT 20; LIMIT 20;
``` ```
@@ -322,7 +332,6 @@ Common indexed filters:
absolute path when known; `messages.cwd` is per-message working directory. absolute path when known; `messages.cwd` is per-message working directory.
- Memory rows are archived with `deleted_at`; do not recall archived memories. - Memory rows are archived with `deleted_at`; do not recall archived memories.
- Indexed text and JSON fields are truncated to 10k chars. Use `raw()` from - Indexed text and JSON fields are truncated to 10k chars. Use `raw()` from
`references/api-reference.md` when a specific message needs the original JSONL `references/api-reference.md` when a specific message needs its source record.
line.
- Prefer SQL-side `COUNT`, `GROUP BY`, `MAX`, `ORDER BY`, and `LIMIT` over - Prefer SQL-side `COUNT`, `GROUP BY`, `MAX`, `ORDER BY`, and `LIMIT` over
returning large row sets and hand-counting in the final answer. returning large row sets and hand-counting in the final answer.
+131 -1
View File
@@ -1,7 +1,7 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
import { mkdtempSync } from 'node:fs'; import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
@@ -101,6 +101,33 @@ test('indexer service reschedules a writer-lease deferral without publishing a h
assert.equal(heartbeats, 1); assert.equal(heartbeats, 1);
}); });
test('a deferred full-inventory build stays full when retried', async () => {
const timers = manualTimers();
const calls = [];
const service = createIndexerService({
buildIndex: async (args) => {
calls.push(args);
return calls.length === 1 ? { deferred: true } : { deferred: false };
},
watchProjects: () => null,
writeHeartbeat: () => {},
timers,
stabilityMs: 0,
});
service.scheduleBuild('root-appeared');
timers.flush();
await service.idle();
service.scheduleBuild('ordinary-change', '/tmp/later.jsonl');
timers.flush();
await service.idle();
assert.deepEqual(calls, [
{ reason: 'root-appeared', changedPaths: undefined },
{ reason: 'ordinary-change', changedPaths: undefined },
]);
});
test('indexer service does not log a build cancelled by a service stop', async () => { test('indexer service does not log a build cancelled by a service stop', async () => {
const timers = manualTimers(); const timers = manualTimers();
const warnings = []; const warnings = [];
@@ -143,6 +170,59 @@ test('indexer service logs a build that fails while running', async () => {
assert.match(warnings[0], /Obelisk index build failed: disk on fire/); assert.match(warnings[0], /Obelisk index build failed: disk on fire/);
}); });
test('indexer service reports partial inventory paths on ordinary builds', async () => {
const warnings = [];
const service = createIndexerService({
buildIndex: async () => ({
deferred: false,
complete: false,
inventoryIssues: [{
provider: 'pi',
path: '/tmp/pi/locked',
error: 'EACCES: permission denied',
}],
}),
watchProjects: () => null,
writeHeartbeat: () => {},
logger: { warn: (msg) => warnings.push(msg) },
stabilityMs: 0,
});
await service.runBuildNow('startup');
assert.deepEqual(warnings, [
'Obelisk indexed a partial pi inventory at /tmp/pi/locked: EACCES: permission denied',
]);
});
test('indexer service reports partial inventory paths before a deferred retry', async () => {
const timers = manualTimers();
const warnings = [];
const service = createIndexerService({
buildIndex: async () => ({
deferred: true,
complete: false,
inventoryIssues: [{
provider: 'pi',
path: '/tmp/pi/locked',
error: 'EACCES: permission denied',
}],
}),
watchProjects: () => null,
writeHeartbeat: () => {},
logger: { warn: (msg) => warnings.push(msg) },
timers,
stabilityMs: 0,
});
await service.runBuildNow('startup');
service.stop();
assert.deepEqual(warnings, [
'Obelisk indexed a partial pi inventory at /tmp/pi/locked: EACCES: permission denied',
]);
});
test('indexer service waits for a stability window before building', async () => { test('indexer service waits for a stability window before building', async () => {
const timers = manualTimers(); const timers = manualTimers();
const calls = []; const calls = [];
@@ -345,3 +425,53 @@ test('indexer service watches Claude projects and Codex sessions for app-side in
join(codexSessionsDir, '2026/06/15/rollout-2026-06-15T00-00-00-codex.jsonl'), join(codexSessionsDir, '2026/06/15/rollout-2026-06-15T00-00-00-codex.jsonl'),
]); ]);
}); });
test('indexer service starts watching a configured root that appears after startup', async () => {
const existingRoot = mkdtempSync(join(tmpdir(), 'obelisk-watch-existing-'));
const parent = mkdtempSync(join(tmpdir(), 'obelisk-watch-late-parent-'));
const lateRoot = join(parent, 'nested', 'sessions');
const timers = manualTimers();
const calls = [];
const watchArgs = [];
const chokidar = {
watch(root) {
watchArgs.push(root);
const watcher = {
on() {
return watcher;
},
close() {},
};
return watcher;
},
};
const service = createIndexerService({
watchDirs: [existingRoot, lateRoot],
buildIndex: async (args) => calls.push(args),
chokidar,
writeHeartbeat: () => {},
timers,
stabilityMs: 0,
debounceMs: 0,
watchRetryMs: 0,
});
try {
service.start({ buildOnStart: false });
assert.deepEqual(watchArgs, [existingRoot]);
mkdirSync(lateRoot, { recursive: true });
writeFileSync(join(lateRoot, 'pre-existing.jsonl'), '{}\n');
timers.flush();
assert.deepEqual(watchArgs, [existingRoot, lateRoot]);
timers.flush();
await service.idle();
assert.deepEqual(calls, [{
reason: 'watch',
changedPaths: undefined,
}]);
} finally {
service.stop();
}
});
+168 -15
View File
@@ -117,12 +117,15 @@ function defaultIndexerWorkerClient() {
}; };
} }
async function loadMainForWindowFlags(flags) { async function loadMainForWindowFlags(flags, { settingsText } = {}) {
const originalArgv = process.argv; const originalArgv = process.argv;
const originalHome = process.env.HOME; const originalHome = process.env.HOME;
const home = join(tmpdir(), `obelisk-window-flags-${Date.now()}-${Math.random()}`); const home = join(tmpdir(), `obelisk-window-flags-${Date.now()}-${Math.random()}`);
mkdirSync(join(home, '.obelisk'), { recursive: true }); mkdirSync(join(home, '.obelisk'), { recursive: true });
writeFileSync(join(home, '.obelisk', 'obelisk.sqlite'), ''); writeFileSync(join(home, '.obelisk', 'obelisk.sqlite'), '');
if (settingsText !== undefined) {
writeFileSync(join(home, '.obelisk', 'settings.json'), settingsText);
}
process.env.HOME = home; process.env.HOME = home;
process.argv = [originalArgv[0] || 'node', originalArgv[1] || 'electron', ...flags]; process.argv = [originalArgv[0] || 'node', originalArgv[1] || 'electron', ...flags];
@@ -199,6 +202,11 @@ test('dev mode does not open DevTools unless explicitly requested', async () =>
assert.equal(devtoolsWindows[0].devToolsOpened, true); assert.equal(devtoolsWindows[0].devToolsOpened, true);
}); });
test('malformed settings keep the desktop recovery window available', async () => {
const windows = await loadMainForWindowFlags([], { settingsText: '{broken' });
assert.equal(windows.length, 1);
});
test('main process watches every root declared by the built-in provider registry', async () => { test('main process watches every root declared by the built-in provider registry', async () => {
const originalHome = process.env.HOME; const originalHome = process.env.HOME;
const home = join(tmpdir(), `obelisk-main-watch-dirs-${Date.now()}`); const home = join(tmpdir(), `obelisk-main-watch-dirs-${Date.now()}`);
@@ -212,6 +220,7 @@ test('main process watches every root declared by the built-in provider registry
process.env.HOME = home; process.env.HOME = home;
const serviceOptions = []; const serviceOptions = [];
const workerCalls = [];
class FakeDatabase { class FakeDatabase {
pragma() {} pragma() {}
@@ -251,7 +260,17 @@ test('main process watches every root declared by the built-in provider registry
}, },
}, },
}], }],
[INDEXER_WORKER_URL, { namedExports: defaultIndexerWorkerClient() }], [INDEXER_WORKER_URL, {
namedExports: {
createWorkerBuildIndex: () => ({
buildIndex: async (args) => {
workerCalls.push(args);
return { files: 0, affectedSessionIds: [], complete: true };
},
stop() {},
}),
},
}],
]); ]);
try { try {
@@ -265,8 +284,11 @@ test('main process watches every root declared by the built-in provider registry
join(codexDir, 'session_index.jsonl'), join(codexDir, 'session_index.jsonl'),
join(home, '.kimi-code', 'sessions'), join(home, '.kimi-code', 'sessions'),
join(home, '.kimi-code', 'session_index.jsonl'), join(home, '.kimi-code', 'session_index.jsonl'),
join(home, '.pi', 'agent', 'sessions'),
]); ]);
assert.equal(serviceOptions[0].watchDirs.includes(codexDir), false); assert.equal(serviceOptions[0].watchDirs.includes(codexDir), false);
await serviceOptions[0].buildIndex({ reason: 'settings-transfer' });
assert.deepEqual(workerCalls[0].providerSettings, {});
} finally { } finally {
restore(); restore();
process.env.HOME = originalHome; process.env.HOME = originalHome;
@@ -286,6 +308,7 @@ test('main process forwards committed IDs without reopening after a deferred bui
let databaseOpens = 0; let databaseOpens = 0;
let serviceOptions; let serviceOptions;
let notifications = 0; let notifications = 0;
const sent = [];
class FakeDatabase { class FakeDatabase {
constructor() { databaseOpens += 1; } constructor() { databaseOpens += 1; }
@@ -302,7 +325,16 @@ test('main process forwards committed IDs without reopening after a deferred bui
loadFile() {} loadFile() {}
loadURL() {} loadURL() {}
close() {} close() {}
static getAllWindows() { return [{ webContents: { send() { notifications += 1; } } }]; } static getAllWindows() {
return [{
webContents: {
send(channel, payload) {
notifications += 1;
sent.push({ channel, payload });
},
},
}];
}
static fromWebContents() { return null; } static fromWebContents() { return null; }
} }
@@ -322,7 +354,19 @@ test('main process forwards committed IDs without reopening after a deferred bui
[INDEXER_WORKER_URL, { [INDEXER_WORKER_URL, {
namedExports: { namedExports: {
createWorkerBuildIndex: () => ({ createWorkerBuildIndex: () => ({
buildIndex: async () => ({ deferred: true, reason: 'database_busy', affectedSessionIds: ['session-1'] }), buildIndex: async ({ reason }) => reason === 'inventory'
? {
deferred: true,
complete: false,
reason: 'database_busy',
affectedSessionIds: [],
inventoryIssues: [{
provider: 'pi',
path: '/tmp/pi/locked',
error: 'EACCES: permission denied',
}],
}
: { deferred: true, reason: 'database_busy', affectedSessionIds: ['session-1'] },
stop() {}, stop() {},
}), }),
}, },
@@ -338,6 +382,22 @@ test('main process forwards committed IDs without reopening after a deferred bui
assert.equal(result.deferred, true); assert.equal(result.deferred, true);
assert.equal(databaseOpens, opensBeforeBuild); assert.equal(databaseOpens, opensBeforeBuild);
assert.equal(notifications, notificationsBeforeBuild + 2); assert.equal(notifications, notificationsBeforeBuild + 2);
const beforeInventoryNotification = notifications;
await serviceOptions.buildIndex({ reason: 'inventory' });
assert.equal(databaseOpens, opensBeforeBuild);
assert.equal(notifications, beforeInventoryNotification + 1);
assert.deepEqual(sent.at(-1), {
channel: 'obelisk:index-updated',
payload: {
affectedSessionIds: [],
sourceIssues: [{
provider: 'pi',
path: '/tmp/pi/locked',
error: 'EACCES: permission denied',
}],
},
});
} finally { } finally {
restore(); restore();
process.env.HOME = originalHome; process.env.HOME = originalHome;
@@ -459,6 +519,39 @@ test('usage IPC aggregates normalized tokens across all indexed providers', asyn
input_tokens, output_tokens, source input_tokens, output_tokens, source
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run('codex-message', 'codex:session', 'assistant', '2026-07-10T11:00:00Z', 'assistant', 'ok', 100, 10, 'codex'); `).run('codex-message', 'codex:session', 'assistant', '2026-07-10T11:00:00Z', 'assistant', 'ok', 100, 10, 'codex');
setup.prepare('INSERT INTO sessions (id,source) VALUES (?,?)')
.run('pi:session', 'pi');
setup.prepare(`
INSERT INTO summaries (
id, session_id, timestamp, source, content, visibility, input_tokens, output_tokens
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run('pi-summary', 'pi:session', '2026-07-10T12:00:00Z', 'pi:compaction', 'summary', 'inactive', 30, 5);
setup.prepare(`
INSERT INTO messages (
uuid, session_id, type, role, text, timestamp, visibility, source
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run('pi-hidden-main', 'pi:session', 'assistant', 'assistant', 'inactive main', '2026-07-10T12:01:00Z', 'inactive', 'pi');
setup.prepare(`
INSERT INTO messages (
uuid, session_id, type, role, text, timestamp, visibility, source, agent_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run('pi-hidden-agent', 'pi:session', 'assistant', 'assistant', 'inactive agent', '2026-07-10T12:02:00Z', 'inactive', 'pi', 'pi:hidden-agent');
setup.prepare(`
INSERT INTO tool_calls (id, message_uuid, session_id, name, input_json)
VALUES (?, ?, ?, ?, ?)
`).run('pi-hidden-main-call', 'pi-hidden-main', 'pi:session', 'read', '{"path":"secret"}');
setup.prepare(`
INSERT INTO tool_calls (id, message_uuid, session_id, name, input_json)
VALUES (?, ?, ?, ?, ?)
`).run('pi-hidden-agent-call', 'pi-hidden-agent', 'pi:session', 'read', '{"path":"secret"}');
setup.prepare(`
INSERT INTO tool_results (tool_use_id, message_uuid, session_id, content)
VALUES (?, ?, ?, ?)
`).run('pi-hidden-main-call', 'pi-hidden-main', 'pi:session', 'hidden result');
setup.prepare(`
INSERT INTO tool_results (tool_use_id, message_uuid, session_id, content)
VALUES (?, ?, ?, ?)
`).run('pi-hidden-agent-call', 'pi-hidden-agent', 'pi:session', 'hidden result');
setup.close(); setup.close();
const ipcHandlers = new Map(); const ipcHandlers = new Map();
@@ -495,14 +588,26 @@ test('usage IPC aggregates normalized tokens across all indexed providers', asyn
try { try {
await importMain(); await importMain();
assert.deepEqual(ipcHandlers.get('db:getSessionSummaries')(null, 'pi:session'), []);
assert.deepEqual(ipcHandlers.get('db:getSessionMessages')(null, 'pi:session'), []);
assert.deepEqual(ipcHandlers.get('db:getSessionToolCalls')(null, 'pi:session'), []);
assert.deepEqual(ipcHandlers.get('db:getSessionToolResults')(null, 'pi:session'), []);
assert.deepEqual(ipcHandlers.get('db:getSubagentMessages')(null, 'pi:hidden-agent'), []);
assert.deepEqual(ipcHandlers.get('db:getSubagentToolCalls')(null, 'pi:hidden-agent'), []);
assert.deepEqual(ipcHandlers.get('db:getSubagentToolResults')(null, 'pi:hidden-agent'), []);
assert.equal(ipcHandlers.get('db:getMessageFullText')(null, 'pi-hidden-main'), null);
const claudeOnly = ipcHandlers.get('db:getUsageStats')(null, {}); const claudeOnly = ipcHandlers.get('db:getUsageStats')(null, {});
assert.equal(claudeOnly.totalTokens, 65); assert.equal(claudeOnly.totalTokens, 65);
assert.equal(claudeOnly.daily[0].tokens, 65); assert.equal(claudeOnly.daily[0].tokens, 65);
const allSources = ipcHandlers.get('db:getUsageStats')(null, { source: 'all' }); const allSources = ipcHandlers.get('db:getUsageStats')(null, { source: 'all' });
assert.equal(allSources.totalTokens, 175); assert.equal(allSources.totalTokens, 210);
assert.equal(allSources.daily[0].tokens, 175); assert.equal(allSources.daily[0].tokens, 210);
assert.equal(allSources.peakDay.tokens, 175); assert.equal(allSources.peakDay.tokens, 210);
const piOnly = ipcHandlers.get('db:getUsageStats')(null, { source: 'pi' });
assert.equal(piOnly.totalTokens, 35);
} finally { } finally {
restore(); restore();
process.env.HOME = originalHome; process.env.HOME = originalHome;
@@ -793,7 +898,9 @@ test('settings rebuild reopens the database from the configured Claude path', as
const openedDbPaths = []; const openedDbPaths = [];
const buildCalls = []; const buildCalls = [];
const serviceEvents = []; const serviceEvents = [];
const sent = [];
let competingLeaseDuringBuild; let competingLeaseDuringBuild;
let publishRebuild = false;
class FakeDatabase { class FakeDatabase {
constructor(dbPath) { constructor(dbPath) {
@@ -810,12 +917,19 @@ test('settings rebuild reopens the database from the configured Claude path', as
class FakeBrowserWindow { class FakeBrowserWindow {
constructor() { constructor() {
this.webContents = { on() {}, setWindowOpenHandler() {}, getURL() { return ''; }, setZoomLevel() {}, openDevTools() {}, send() {} }; this.webContents = {
on() {},
setWindowOpenHandler() {},
getURL() { return ''; },
setZoomLevel() {},
openDevTools() {},
send(channel, payload) { sent.push({ channel, payload }); },
};
} }
loadFile() {} loadFile() {}
loadURL() {} loadURL() {}
close() {} close() {}
static getAllWindows() { return []; } static getAllWindows() { return [new FakeBrowserWindow()]; }
static fromWebContents() { return null; } static fromWebContents() { return null; }
} }
@@ -856,7 +970,20 @@ test('settings rebuild reopens the database from the configured Claude path', as
competingLeaseDuringBuild = Boolean(competingLease); competingLeaseDuringBuild = Boolean(competingLease);
competingLease?.release(); competingLease?.release();
writeFileSync(args.dbPath, 'rebuilt temp db'); writeFileSync(args.dbPath, 'rebuilt temp db');
return { files: 2, affectedSessionIds: ['session-1', 'session-2'] }; return {
files: 2,
affectedSessionIds: ['session-1', 'session-2'],
complete: publishRebuild,
reason: publishRebuild ? undefined : 'incomplete_snapshot',
inventoryIssues: [],
skippedFiles: publishRebuild
? []
: [{
provider: 'pi',
path: '/tmp/pi/structurally-invalid.jsonl',
error: 'Malformed Pi message at line 2',
}],
};
}, },
stop() { return Promise.resolve(); }, stop() { return Promise.resolve(); },
}), }),
@@ -869,19 +996,45 @@ test('settings rebuild reopens the database from the configured Claude path', as
const rebuild = ipcHandlers.get('settings:rebuildIndex'); const rebuild = ipcHandlers.get('settings:rebuildIndex');
assert.equal(typeof rebuild, 'function'); assert.equal(typeof rebuild, 'function');
const liveDbPath = join(home, '.obelisk', 'obelisk.sqlite');
const beforeIncomplete = require('node:fs').readFileSync(liveDbPath, 'utf8');
const incomplete = await rebuild();
assert.equal(incomplete.complete, false);
assert.deepEqual(sent.findLast(message => message.channel === 'obelisk:index-updated'), {
channel: 'obelisk:index-updated',
payload: {
affectedSessionIds: ['session-1', 'session-2'],
sourceIssues: [{
provider: 'pi',
path: '/tmp/pi/structurally-invalid.jsonl',
error: 'Malformed Pi message at line 2',
}],
},
});
assert.equal(
require('node:fs').readFileSync(liveDbPath, 'utf8'),
beforeIncomplete,
'an incomplete temp database must not replace the live database',
);
publishRebuild = true;
await rebuild(); await rebuild();
assert.deepEqual(
sent.findLast(message => message.channel === 'obelisk:index-updated').payload.sourceIssues,
[],
);
assert.equal(buildCalls.at(-1).claudeDir, customClaudeDir); assert.equal(buildCalls.at(-1).claudeDir, customClaudeDir);
assert.equal(buildCalls.at(-1).projectsDir, join(customClaudeDir, 'projects')); assert.equal(buildCalls.at(-1).projectsDir, join(customClaudeDir, 'projects'));
assert.equal(buildCalls.at(-1).codexDir, customCodexDir); assert.equal(buildCalls.at(-1).codexDir, customCodexDir);
assert.notEqual(buildCalls.at(-1).dbPath, join(home, '.obelisk', 'obelisk.sqlite')); assert.notEqual(buildCalls.at(-1).dbPath, liveDbPath);
assert.equal(buildCalls.at(-1).preserveDbPath, join(home, '.obelisk', 'obelisk.sqlite')); assert.equal(buildCalls.at(-1).preserveDbPath, liveDbPath);
assert.equal(buildCalls.at(-1).writerLeasePath, join(home, '.obelisk', 'writer.lock.sqlite')); assert.equal(buildCalls.at(-1).writerLeasePath, join(home, '.obelisk', 'writer.lock.sqlite'));
assert.equal(buildCalls.at(-1).writerLeaseMode, 'caller-held'); assert.equal(buildCalls.at(-1).writerLeaseMode, 'caller-held');
assert.equal(competingLeaseDuringBuild, false); assert.equal(competingLeaseDuringBuild, false);
assert.equal(openedDbPaths.at(-1), join(home, '.obelisk', 'obelisk.sqlite')); assert.equal(openedDbPaths.at(-1), liveDbPath);
assert.equal( assert.equal(
require('node:fs').readFileSync(join(home, '.obelisk', 'obelisk.sqlite'), 'utf8'), require('node:fs').readFileSync(liveDbPath, 'utf8'),
'rebuilt temp db', 'rebuilt temp db',
); );
assert.ok(serviceEvents.indexOf('build') > serviceEvents.indexOf('stop')); assert.ok(serviceEvents.indexOf('build') > serviceEvents.indexOf('stop'));
@@ -1081,7 +1234,7 @@ test('settings rebuild cancels an in-flight background build instead of waiting
buildIndex: async (args) => { buildIndex: async (args) => {
serviceEvents.push(`build-${++buildIndexCalls}`); serviceEvents.push(`build-${++buildIndexCalls}`);
writeFileSync(args.dbPath, 'rebuilt temp db'); writeFileSync(args.dbPath, 'rebuilt temp db');
return { files: 2, affectedSessionIds: [] }; return { files: 2, affectedSessionIds: [], complete: true };
}, },
stop() { stop() {
serviceEvents.push('worker-stop'); serviceEvents.push('worker-stop');
+650
View File
@@ -0,0 +1,650 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import {
chmodSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
renameSync,
unlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { buildIndex } from '../app/src/main/indexer.ts';
import {
createPiProvider,
piSessionId,
PI_CANONICAL_TRANSCRIPT_MARKER,
} from '../packages/core/src/providers/pi.ts';
import { createProviderRegistry } from '../packages/core/src/providers/registry.ts';
const require = createRequire(import.meta.url);
const { DatabaseSync } = require('node:sqlite');
const TOOL_FIXTURE = new URL('./fixtures/pi/tool-session.jsonl', import.meta.url);
class TestDatabase {
constructor(dbPath) {
this.db = new DatabaseSync(dbPath);
}
pragma(statement) { this.db.exec(`PRAGMA ${statement}`); }
exec(sql) { return this.db.exec(sql); }
prepare(sql) { return this.db.prepare(sql); }
close() { return this.db.close(); }
}
class TransactionAwareTestDatabase extends TestDatabase {
get inTransaction() { return this.db.isTransaction; }
}
function writeFixture(piDir) {
const sessionPath = join(piDir, '--tmp-pi-real-tool--', 'session.jsonl');
mkdirSync(dirname(sessionPath), { recursive: true });
writeFileSync(sessionPath, readFileSync(TOOL_FIXTURE));
return sessionPath;
}
function invalidMessageLine(id = 'invalid-message') {
return JSON.stringify({
type: 'message',
id,
parentId: null,
timestamp: '2026-08-02T10:00:01.000Z',
message: null,
});
}
function indexOptions(home, piDir) {
return {
claudeDir: join(home, '.claude'),
codexDir: join(home, '.codex'),
providerRoots: { pi: piDir },
dbPath: join(home, '.obelisk', 'obelisk.sqlite'),
DatabaseImpl: TestDatabase,
};
}
test('app build indexes Pi through the registry and replays complete session snapshots', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-pi-index-'));
const piDir = join(home, 'pi-sessions');
const sessionPath = writeFixture(piDir);
const options = indexOptions(home, piDir);
const provider = createPiProvider({ rootDir: piDir });
const sessionId = provider.discover({ lastCursor: () => null })[0].sessionId;
const first = buildIndex(options);
assert.deepEqual(first.affectedSessionIds, [sessionId]);
let db = new TestDatabase(options.dbPath);
assert.deepEqual(
db.prepare('SELECT id,title,source,message_count FROM sessions').all().map(row => ({ ...row })),
[{ id: sessionId, title: 'Tool probe', source: 'pi', message_count: 4 }],
);
assert.equal(
db.prepare("SELECT text FROM messages WHERE source='pi' AND role='assistant' AND content_type='text'").get().text,
'The read tool returned real-pi-tool-result.',
);
assert.deepEqual(
{ ...db.prepare("SELECT name,file_path FROM tool_calls WHERE session_id=?").get(sessionId) },
{ name: 'read', file_path: 'probe.txt' },
);
const schema = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='messages'").get().sql;
assert.doesNotMatch(schema, /\bpi\b/i);
db.close();
const header = readFileSync(TOOL_FIXTURE, 'utf8').split('\n')[0];
writeFileSync(sessionPath, `${header}\n`);
const replay = buildIndex({ ...options, changedPaths: [sessionPath] });
assert.deepEqual(replay.affectedSessionIds, [sessionId]);
db = new TestDatabase(options.dbPath);
assert.equal(db.prepare("SELECT COUNT(*) AS c FROM messages WHERE source='pi'").get().c, 0);
assert.equal(db.prepare('SELECT message_count FROM sessions WHERE id=?').get(sessionId).message_count, 0);
db.close();
});
test('an unreadable Pi directory does not block readable sessions on a fresh index', {
skip: process.platform === 'win32',
}, (t) => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-pi-partial-inventory-'));
const piDir = join(home, 'pi-sessions');
writeFixture(piDir);
const lockedDir = join(piDir, 'locked');
mkdirSync(lockedDir, { recursive: true });
chmodSync(lockedDir, 0o000);
try {
try {
readdirSync(lockedDir);
t.skip('current user can read mode-000 directories');
return;
} catch {}
const options = indexOptions(home, piDir);
const first = buildIndex(options);
assert.equal(first.complete, false);
assert.equal(first.files, 1);
assert.deepEqual(first.incompleteProviders, ['pi']);
assert.ok(first.inventoryIssues.some((issue) => (
issue.provider === 'pi' && issue.path === lockedDir
)));
const db = new TestDatabase(options.dbPath);
assert.equal(db.prepare("SELECT COUNT(*) AS c FROM sessions WHERE source='pi'").get().c, 1);
assert.equal(
db.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?')
.get(PI_CANONICAL_TRANSCRIPT_MARKER).c,
0,
);
db.close();
} finally {
chmodSync(lockedDir, 0o700);
}
const recovered = buildIndex(indexOptions(home, piDir));
assert.equal(recovered.complete, true);
const db = new TestDatabase(join(home, '.obelisk', 'obelisk.sqlite'));
assert.equal(
db.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?')
.get(PI_CANONICAL_TRANSCRIPT_MARKER).c,
1,
);
db.close();
});
test('an incomplete Pi identity census preserves committed provenance over a readable copy', {
skip: process.platform === 'win32',
}, (t) => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-pi-partial-copy-'));
const piDir = join(home, 'pi-sessions');
const lockedDir = join(piDir, 'locked');
const committedPath = join(lockedDir, 'session.jsonl');
mkdirSync(lockedDir, { recursive: true });
writeFileSync(committedPath, readFileSync(TOOL_FIXTURE));
const options = indexOptions(home, piDir);
assert.equal(buildIndex(options).complete, true);
let db = new TestDatabase(options.dbPath);
const before = {
session: {
...db.prepare("SELECT id,title,message_count,jsonl_path FROM sessions WHERE source='pi'").get(),
},
messages: db.prepare(
"SELECT uuid,text,visibility FROM messages WHERE source='pi' ORDER BY uuid",
).all().map(row => ({ ...row })),
};
db.close();
const readablePath = join(piDir, 'readable', 'session.jsonl');
mkdirSync(dirname(readablePath), { recursive: true });
writeFileSync(
readablePath,
readFileSync(TOOL_FIXTURE, 'utf8').replaceAll(
'real-pi-tool-result',
'UNVERIFIED NEW',
),
);
chmodSync(lockedDir, 0o000);
try {
try {
readdirSync(lockedDir);
t.skip('current user can read mode-000 directories');
return;
} catch {}
const partial = buildIndex(options);
assert.equal(partial.complete, false);
assert.equal(partial.files, 0);
assert.deepEqual(partial.affectedSessionIds, []);
assert.ok(partial.inventoryIssues.some((issue) => (
issue.provider === 'pi' && issue.path === lockedDir
)));
db = new TestDatabase(options.dbPath);
const after = {
session: {
...db.prepare("SELECT id,title,message_count,jsonl_path FROM sessions WHERE source='pi'").get(),
},
messages: db.prepare(
"SELECT uuid,text,visibility FROM messages WHERE source='pi' ORDER BY uuid",
).all().map(row => ({ ...row })),
};
assert.deepEqual(after, before);
assert.equal(
db.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?')
.get(readablePath).c,
0,
);
db.close();
} finally {
chmodSync(lockedDir, 0o700);
}
});
test('Pi canonical marker forces one provider-owned replay after projection changes', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-pi-marker-'));
const piDir = join(home, 'pi-sessions');
writeFixture(piDir);
const options = indexOptions(home, piDir);
const sessionId = createPiProvider({ rootDir: piDir })
.discover({ lastCursor: () => null })[0].sessionId;
buildIndex(options);
let db = new TestDatabase(options.dbPath);
db.prepare("UPDATE messages SET text='stale Pi projection' WHERE source='pi'").run();
db.prepare('DELETE FROM index_state WHERE jsonl_path=?').run(PI_CANONICAL_TRANSCRIPT_MARKER);
db.close();
const replay = buildIndex(options);
assert.deepEqual(replay.affectedSessionIds, [sessionId]);
db = new TestDatabase(options.dbPath);
assert.equal(
db.prepare("SELECT COUNT(*) AS c FROM messages WHERE source='pi' AND text='stale Pi projection'").get().c,
0,
);
assert.equal(
db.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?')
.get(PI_CANONICAL_TRANSCRIPT_MARKER).c,
1,
);
db.close();
});
test('a structurally invalid Pi file retries alone after a canonical replay', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-pi-marker-retry-'));
const piDir = join(home, 'pi-sessions');
const validPath = writeFixture(piDir);
const options = {
...indexOptions(home, piDir),
DatabaseImpl: TransactionAwareTestDatabase,
};
const provider = createPiProvider({ rootDir: piDir });
const parseCalls = new Map();
const countedProvider = {
...provider,
parse(unit, cursor) {
parseCalls.set(unit.key, (parseCalls.get(unit.key) ?? 0) + 1);
return provider.parse(unit, cursor);
},
};
const providerRegistry = createProviderRegistry([countedProvider]);
buildIndex({ ...options, providerRegistry });
const badPath = join(piDir, '--tmp-pi-bad--', 'session.jsonl');
mkdirSync(dirname(badPath), { recursive: true });
writeFileSync(badPath, [
JSON.stringify({
type: 'session',
version: 3,
id: 'permanently-bad',
timestamp: '2026-08-02T10:00:00.000Z',
cwd: '/tmp/pi-bad',
}),
invalidMessageLine('permanently-bad-message'),
'',
].join('\n'));
let db = new TransactionAwareTestDatabase(options.dbPath);
db.prepare('DELETE FROM index_state WHERE jsonl_path=?').run(PI_CANONICAL_TRANSCRIPT_MARKER);
db.close();
parseCalls.clear();
const replay = buildIndex({ ...options, providerRegistry });
assert.equal(replay.files, 2);
assert.equal(replay.skipped, 1);
assert.equal(parseCalls.get(validPath), 1);
assert.equal(parseCalls.get(badPath), 1);
db = new TransactionAwareTestDatabase(options.dbPath);
assert.equal(
db.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?')
.get(PI_CANONICAL_TRANSCRIPT_MARKER).c,
1,
);
assert.equal(
db.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?').get(badPath).c,
0,
);
db.close();
const retry = buildIndex({ ...options, providerRegistry });
assert.equal(retry.files, 1);
assert.equal(retry.skipped, 1);
assert.equal(parseCalls.get(validPath), 1, 'the successful session must not replay again');
assert.equal(parseCalls.get(badPath), 2, 'only the failed session remains retryable');
});
test('a failed Pi unit rolls back a force rebuild to the last complete snapshot', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-pi-force-rollback-'));
const piDir = join(home, 'pi-sessions');
const sessionPath = writeFixture(piDir);
const options = {
...indexOptions(home, piDir),
DatabaseImpl: TransactionAwareTestDatabase,
};
assert.equal(buildIndex(options).complete, true);
let db = new TransactionAwareTestDatabase(options.dbPath);
const before = {
session: { ...db.prepare("SELECT id,title,message_count,jsonl_path FROM sessions WHERE source='pi'").get() },
messages: db.prepare("SELECT uuid,text,content_type FROM messages WHERE source='pi' ORDER BY uuid").all()
.map(row => ({ ...row })),
cursor: { ...db.prepare('SELECT mtime,lines_processed,cursor FROM index_state WHERE jsonl_path=?').get(sessionPath) },
lastBuild: { ...db.prepare("SELECT mtime,lines_processed FROM index_state WHERE jsonl_path='__last_build__'").get() },
};
db.close();
const fixtureHeader = readFileSync(TOOL_FIXTURE, 'utf8').split('\n')[0];
writeFileSync(sessionPath, `${fixtureHeader}\n${invalidMessageLine()}\n`);
const failed = buildIndex({ ...options, force: true });
assert.equal(failed.complete, false);
assert.equal(failed.reason, 'provider_failure');
assert.deepEqual(failed.affectedSessionIds, []);
assert.equal(failed.skippedFiles[0].provider, 'pi');
assert.equal(failed.skippedFiles[0].path, sessionPath);
assert.match(failed.skippedFiles[0].error, /Malformed Pi message at line 2/);
db = new TransactionAwareTestDatabase(options.dbPath);
const after = {
session: { ...db.prepare("SELECT id,title,message_count,jsonl_path FROM sessions WHERE source='pi'").get() },
messages: db.prepare("SELECT uuid,text,content_type FROM messages WHERE source='pi' ORDER BY uuid").all()
.map(row => ({ ...row })),
cursor: { ...db.prepare('SELECT mtime,lines_processed,cursor FROM index_state WHERE jsonl_path=?').get(sessionPath) },
lastBuild: { ...db.prepare("SELECT mtime,lines_processed FROM index_state WHERE jsonl_path='__last_build__'").get() },
};
db.close();
assert.deepEqual(after, before);
});
test('a temp force rebuild uses live Pi provenance before publishing an empty snapshot', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-pi-temp-provenance-'));
const piDir = join(home, 'pi-sessions');
writeFixture(piDir);
const options = indexOptions(home, piDir);
assert.equal(buildIndex(options).complete, true);
const unavailableDir = `${piDir}.unavailable`;
renameSync(piDir, unavailableDir);
const tempDbPath = join(home, '.obelisk', 'obelisk.rebuild.tmp');
const rebuilt = buildIndex({
...options,
dbPath: tempDbPath,
preserveDbPath: options.dbPath,
force: true,
});
assert.equal(rebuilt.complete, false);
assert.equal(rebuilt.reason, 'incomplete_snapshot');
assert.deepEqual(rebuilt.incompleteProviders, ['pi']);
const live = new TestDatabase(options.dbPath);
assert.equal(live.prepare("SELECT COUNT(*) AS c FROM sessions WHERE source='pi'").get().c, 1);
live.close();
renameSync(unavailableDir, piDir);
});
test('an unavailable Pi root keeps replay pending on the missing session cursor', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-pi-incomplete-marker-'));
const piDir = join(home, 'pi-sessions');
const sessionPath = writeFixture(piDir);
const options = indexOptions(home, piDir);
buildIndex(options);
let db = new TestDatabase(options.dbPath);
db.prepare(`
UPDATE messages
SET text='stale Pi projection'
WHERE uuid = (
SELECT uuid FROM messages WHERE source='pi' ORDER BY uuid LIMIT 1
)
`).run();
db.prepare('DELETE FROM index_state WHERE jsonl_path=?').run(PI_CANONICAL_TRANSCRIPT_MARKER);
db.close();
const unavailableDir = `${piDir}.unavailable`;
renameSync(piDir, unavailableDir);
const forced = buildIndex({ ...options, force: true });
assert.equal(forced.complete, false);
assert.equal(forced.reason, 'incomplete_snapshot');
assert.deepEqual(forced.incompleteProviders, ['pi']);
const unavailable = buildIndex(options);
assert.equal(unavailable.files, 0);
db = new TestDatabase(options.dbPath);
assert.equal(
db.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?')
.get(PI_CANONICAL_TRANSCRIPT_MARKER).c,
1,
);
assert.equal(
db.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?')
.get(sessionPath).c,
0,
);
assert.equal(
db.prepare("SELECT COUNT(*) AS c FROM messages WHERE text='stale Pi projection'").get().c,
1,
);
db.close();
renameSync(unavailableDir, piDir);
const replay = buildIndex(options);
assert.equal(replay.files, 1);
db = new TestDatabase(options.dbPath);
assert.equal(
db.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?')
.get(PI_CANONICAL_TRANSCRIPT_MARKER).c,
1,
);
assert.equal(
db.prepare("SELECT COUNT(*) AS c FROM messages WHERE text='stale Pi projection'").get().c,
0,
);
db.close();
});
test('an unresolved Pi root keeps replay pending on the unresolved session cursor', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-pi-unresolved-marker-'));
const piDir = join(home, 'pi-sessions');
const sessionPath = writeFixture(piDir);
const options = indexOptions(home, piDir);
buildIndex(options);
let db = new TestDatabase(options.dbPath);
db.prepare("UPDATE messages SET text='stale unresolved projection' WHERE source='pi'").run();
db.prepare('DELETE FROM index_state WHERE jsonl_path=?').run(PI_CANONICAL_TRANSCRIPT_MARKER);
db.close();
const unresolvedProvider = createPiProvider({ rootDir: 'relative-session-root' });
assert.equal(unresolvedProvider.rootResolution.requiresExplicitRoot, true);
const unresolved = buildIndex({
...options,
providerRegistry: createProviderRegistry([unresolvedProvider]),
});
assert.equal(unresolved.files, 0);
db = new TestDatabase(options.dbPath);
assert.equal(
db.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?')
.get(PI_CANONICAL_TRANSCRIPT_MARKER).c,
1,
);
assert.equal(
db.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?')
.get(sessionPath).c,
0,
);
assert.ok(
db.prepare("SELECT COUNT(*) AS c FROM messages WHERE text='stale unresolved projection'").get().c > 0,
);
db.close();
const replay = buildIndex(options);
assert.equal(replay.files, 1);
db = new TestDatabase(options.dbPath);
assert.equal(
db.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?')
.get(PI_CANONICAL_TRANSCRIPT_MARKER).c,
1,
);
assert.equal(
db.prepare("SELECT COUNT(*) AS c FROM messages WHERE text='stale unresolved projection'").get().c,
0,
);
db.close();
});
test('Pi identity marker retracts a legacy id through a non-selected identical copy', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-pi-identity-marker-'));
const piDir = join(home, 'pi-sessions');
const selectedPath = writeFixture(piDir);
const copiedPath = join(piDir, 'z-copy', 'session.jsonl');
mkdirSync(dirname(copiedPath), { recursive: true });
writeFileSync(copiedPath, readFileSync(TOOL_FIXTURE));
const options = indexOptions(home, piDir);
const sourceHeader = JSON.parse(readFileSync(TOOL_FIXTURE, 'utf8').split('\n')[0]);
const legacyId = `pi:${sourceHeader.id}`;
const currentId = piSessionId(sourceHeader);
buildIndex(options);
let db = new TestDatabase(options.dbPath);
db.prepare("UPDATE sessions SET id=?, jsonl_path=? WHERE source='pi'").run(legacyId, copiedPath);
for (const table of ['messages', 'tool_calls', 'tool_results', 'summaries']) {
db.prepare(`UPDATE ${table} SET session_id=? WHERE session_id=?`).run(legacyId, currentId);
}
db.prepare('UPDATE index_state SET jsonl_path=? WHERE jsonl_path=?').run(copiedPath, selectedPath);
db.prepare('DELETE FROM index_state WHERE jsonl_path=?').run(PI_CANONICAL_TRANSCRIPT_MARKER);
db.close();
const replay = buildIndex(options);
assert.deepEqual(new Set(replay.affectedSessionIds), new Set([legacyId, currentId]));
db = new TestDatabase(options.dbPath);
assert.deepEqual(
db.prepare("SELECT id,jsonl_path FROM sessions WHERE source='pi'").all().map(row => ({ ...row })),
[{ id: currentId, jsonl_path: selectedPath }],
);
db.close();
});
test('app replay keeps Pi identity stable across migration and retracts replacement and unlink', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-pi-provenance-'));
const piDir = join(home, 'pi-sessions');
const sessionPath = writeFixture(piDir);
const options = indexOptions(home, piDir);
const original = readFileSync(sessionPath, 'utf8');
const records = original.trimEnd().split('\n').map(line => JSON.parse(line));
buildIndex(options);
const stableId = piSessionId(records[0]);
records[0].version = 2;
writeFileSync(sessionPath, records.map(record => JSON.stringify(record)).join('\n') + '\n');
buildIndex({ ...options, changedPaths: [sessionPath] });
let db = new TestDatabase(options.dbPath);
assert.deepEqual(
db.prepare("SELECT id FROM sessions WHERE source='pi'").all().map(row => row.id),
[stableId],
);
db.close();
records[0].version = 3;
records[0].id = 'replacement-session';
const replacementId = piSessionId(records[0]);
writeFileSync(sessionPath, records.map(record => JSON.stringify(record)).join('\n') + '\n');
const replacement = buildIndex({ ...options, changedPaths: [sessionPath] });
assert.deepEqual(
new Set(replacement.affectedSessionIds),
new Set([stableId, replacementId]),
);
db = new TestDatabase(options.dbPath);
assert.deepEqual(
db.prepare("SELECT id FROM sessions WHERE source='pi'").all().map(row => row.id),
[replacementId],
);
db.close();
unlinkSync(sessionPath);
const removed = buildIndex({ ...options, changedPaths: [sessionPath] });
assert.deepEqual(removed.affectedSessionIds, [replacementId]);
db = new TestDatabase(options.dbPath);
assert.equal(db.prepare("SELECT COUNT(*) AS c FROM sessions WHERE source='pi'").get().c, 0);
assert.equal(
db.prepare('SELECT mtime FROM index_state WHERE jsonl_path=?').get(sessionPath).mtime,
0,
);
db.close();
});
test('passive Pi inventory retracts deleted sessions when its configured root remains readable', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-pi-passive-delete-'));
const piDir = join(home, 'pi-sessions');
const sessionPath = writeFixture(piDir);
const options = indexOptions(home, piDir);
buildIndex(options);
unlinkSync(sessionPath);
const removed = buildIndex(options);
assert.equal(removed.files, 1);
const db = new TestDatabase(options.dbPath);
assert.equal(db.prepare("SELECT COUNT(*) AS c FROM sessions WHERE source='pi'").get().c, 0);
db.close();
});
test('a terminal malformed line follows Pi by publishing the valid replacement prefix', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-pi-torn-replacement-'));
const piDir = join(home, 'pi-sessions');
const sessionPath = writeFixture(piDir);
const options = indexOptions(home, piDir);
const records = readFileSync(TOOL_FIXTURE, 'utf8')
.trimEnd()
.split('\n')
.map(line => JSON.parse(line));
const oldId = piSessionId(records[0]);
buildIndex(options);
let db = new TestDatabase(options.dbPath);
assert.equal(
db.prepare("SELECT id FROM sessions WHERE source='pi'").get().id,
oldId,
);
const committedCursor = db.prepare(
'SELECT cursor FROM index_state WHERE jsonl_path=?',
).get(sessionPath).cursor;
db.close();
const replacementHeader = { ...records[0], id: 'torn-replacement' };
const replacementId = piSessionId(replacementHeader);
writeFileSync(sessionPath, `${JSON.stringify(replacementHeader)}\n{"type":"message"`);
const prefix = buildIndex({ ...options, changedPaths: [sessionPath] });
assert.deepEqual(
new Set(prefix.affectedSessionIds),
new Set([oldId, replacementId]),
);
db = new TestDatabase(options.dbPath);
assert.deepEqual(
db.prepare("SELECT id,message_count FROM sessions WHERE source='pi'").all().map(row => ({ ...row })),
[{ id: replacementId, message_count: 0 }],
);
assert.notEqual(
db.prepare('SELECT cursor FROM index_state WHERE jsonl_path=?').get(sessionPath).cursor,
committedCursor,
);
db.close();
records[0] = replacementHeader;
writeFileSync(sessionPath, `${records.map(record => JSON.stringify(record)).join('\n')}\n`);
const completed = buildIndex({ ...options, changedPaths: [sessionPath] });
assert.deepEqual(completed.affectedSessionIds, [replacementId]);
db = new TestDatabase(options.dbPath);
assert.deepEqual(
db.prepare("SELECT id,message_count FROM sessions WHERE source='pi'").all().map(row => ({ ...row })),
[{ id: replacementId, message_count: 4 }],
);
db.close();
});
+269
View File
@@ -77,3 +77,272 @@ test('app indexer persists every provider through one registry-driven loop', ()
assert.deepEqual(second.affectedSessionIds, []); assert.deepEqual(second.affectedSessionIds, []);
assert.equal(second.files, 0); assert.equal(second.files, 0);
}); });
test('serialized invalid provider settings stay disabled when the worker rebuilds the registry', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-provider-settings-worker-'));
const result = buildIndex({
providerSettings: {
providerRoots: {
claude: './claude',
codex: './codex',
kimi: './kimi',
},
},
providerRoots: { kimi: join(home, '.kimi-code') },
claudeDir: join(home, '.claude'),
codexDir: join(home, '.codex'),
dbPath: join(home, '.obelisk', 'obelisk.sqlite'),
DatabaseImpl: TestDatabase,
});
assert.equal(result.files, 0);
assert.equal(result.complete, false);
assert.deepEqual(result.incompleteProviders, ['claude', 'codex', 'kimi']);
});
test('an incomplete canonical inventory converges without replaying readable units forever', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-provider-incomplete-replay-'));
const dbPath = join(home, '.obelisk', 'obelisk.sqlite');
const marker = '__alpha_canonical_v1__';
let incomplete = true;
let parseCalls = 0;
const provider = {
name: 'alpha',
descriptor: { id: 'alpha', name: 'Alpha', vendor: 'Test', defaultRoot: '/alpha', color: '#123456' },
indexVersionMarker: marker,
watchRoots: () => [],
discover(ctx) {
if (incomplete) {
ctx.reportIncompleteInventory({
path: '/alpha/locked',
error: 'EACCES: permission denied',
});
}
return ctx.lastCursor('alpha:unit') === '10:1'
? []
: [{
key: 'alpha:unit',
sessionId: 'alpha:session',
}];
},
*parse(unit) {
parseCalls += 1;
yield {
kind: 'session', id: unit.sessionId, title: 'Alpha', project: null,
started_at: null, ended_at: null, git_branch: null, version: null,
message_count: 0, countMode: 'total', jsonl_path: unit.key, source: 'alpha',
};
return '10:1';
},
raw: () => null,
};
const options = {
providerRegistry: createProviderRegistry([provider]),
dbPath,
DatabaseImpl: TestDatabase,
};
const first = buildIndex(options);
const second = buildIndex(options);
assert.equal(first.complete, false);
assert.equal(second.complete, false);
assert.deepEqual(first.incompleteProviders, ['alpha']);
assert.deepEqual(first.inventoryIssues, [{
provider: 'alpha',
path: '/alpha/locked',
error: 'EACCES: permission denied',
}]);
assert.equal(first.files, 1);
assert.equal(second.files, 1);
assert.equal(parseCalls, 2, 'readable units remain available while certification retries');
const forced = buildIndex({ ...options, force: true });
assert.equal(forced.complete, false);
assert.equal(forced.reason, 'incomplete_snapshot');
assert.equal(forced.files, 1);
assert.equal(parseCalls, 2, 'force rejects the whole snapshot before parsing even safe units');
let db = new TestDatabase(dbPath);
assert.equal(db.prepare("SELECT COUNT(*) AS c FROM sessions WHERE source='alpha'").get().c, 1);
assert.equal(
db.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?').get(marker).c,
1,
);
db.close();
incomplete = false;
assert.equal(buildIndex(options).complete, true);
assert.equal(parseCalls, 2, 'completed units keep their cursors after the partial replay');
db = new TestDatabase(dbPath);
assert.equal(
db.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?').get(marker).c,
1,
);
db.close();
assert.equal(buildIndex(options).files, 0);
assert.equal(parseCalls, 2);
});
test('a provider can withhold inventory-dependent tombstones from a partial census', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-provider-incomplete-tombstone-'));
const dbPath = join(home, '.obelisk', 'obelisk.sqlite');
const marker = '__alpha_canonical_v1__';
let removeSession = false;
let parseCalls = 0;
const provider = {
name: 'alpha',
descriptor: { id: 'alpha', name: 'Alpha', vendor: 'Test', defaultRoot: '/alpha', color: '#123456' },
indexVersionMarker: marker,
watchRoots: () => [],
discover(ctx) {
if (removeSession) {
ctx.reportIncompleteInventory({
path: '/alpha/locked',
error: 'EACCES: permission denied',
});
return [
{
key: 'alpha:readable',
sessionId: 'alpha:readable-session',
},
];
}
return [{
key: 'alpha:unit',
sessionId: 'alpha:session',
}];
},
*parse(unit) {
parseCalls += 1;
yield {
kind: 'session', id: unit.sessionId, title: 'Alpha', project: null,
started_at: null, ended_at: null, git_branch: null, version: null,
message_count: 0, countMode: 'total', jsonl_path: unit.key, source: 'alpha',
};
return '10:1';
},
raw: () => null,
};
const options = {
providerRegistry: createProviderRegistry([provider]),
dbPath,
DatabaseImpl: TestDatabase,
};
assert.equal(buildIndex(options).complete, true);
assert.equal(parseCalls, 1);
let db = new TestDatabase(dbPath);
db.prepare('DELETE FROM index_state WHERE jsonl_path=?').run(marker);
db.close();
removeSession = true;
const incomplete = buildIndex(options);
assert.equal(incomplete.complete, false);
assert.equal(incomplete.files, 1);
assert.equal(parseCalls, 2, 'the readable unit committed and the tombstone was filtered before parse');
db = new TestDatabase(dbPath);
assert.equal(db.prepare("SELECT COUNT(*) AS c FROM sessions WHERE id='alpha:session'").get().c, 1);
assert.equal(db.prepare("SELECT COUNT(*) AS c FROM sessions WHERE id='alpha:readable-session'").get().c, 1);
assert.equal(
db.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?').get(marker).c,
1,
);
db.close();
});
test('force rebuild resets arbitrary provider keys and rewrites arbitrary provider markers', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-provider-force-keys-'));
const dbPath = join(home, '.obelisk', 'obelisk.sqlite');
const unitKey = '__remote-1__';
const marker = 'alpha-v1';
let present = true;
let parseCalls = 0;
const provider = {
name: 'alpha',
descriptor: { id: 'alpha', name: 'Alpha', vendor: 'Test', defaultRoot: '/alpha', color: '#123456' },
indexVersionMarker: marker,
watchRoots: () => [],
discover(ctx) {
if (!present || ctx.lastCursor(unitKey) === '10:1') return [];
return [{ key: unitKey, sessionId: 'alpha:session' }];
},
*parse(unit) {
parseCalls += 1;
yield {
kind: 'session', id: unit.sessionId, title: 'Alpha', project: null,
started_at: null, ended_at: null, git_branch: null, version: null,
message_count: 0, countMode: 'total', jsonl_path: unit.key, source: 'alpha',
};
return '10:1';
},
raw: () => null,
};
const options = {
providerRegistry: createProviderRegistry([provider]),
dbPath,
DatabaseImpl: TestDatabase,
};
assert.equal(buildIndex(options).complete, true);
present = false;
assert.equal(buildIndex({ ...options, force: true }).complete, true);
let db = new TestDatabase(dbPath);
assert.equal(db.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?').get(unitKey).c, 0);
assert.equal(db.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?').get(marker).c, 1);
db.close();
present = true;
assert.equal(buildIndex(options).complete, true);
db = new TestDatabase(dbPath);
assert.equal(db.prepare("SELECT COUNT(*) AS c FROM sessions WHERE source='alpha'").get().c, 1);
db.close();
assert.equal(parseCalls, 2);
});
test('force rebuild keeps legacy providers without inventory certification compatible', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-provider-force-certification-'));
const dbPath = join(home, '.obelisk', 'obelisk.sqlite');
const unitKey = 'alpha:unit';
const marker = 'alpha-marker';
const provider = {
name: 'alpha',
descriptor: { id: 'alpha', name: 'Alpha', vendor: 'Test', defaultRoot: '/alpha', color: '#123456' },
indexVersionMarker: marker,
watchRoots: () => [],
discover(ctx) {
return ctx.lastCursor(unitKey) === '10:1'
? []
: [{ key: unitKey, sessionId: 'alpha:session' }];
},
*parse(unit) {
yield {
kind: 'session', id: unit.sessionId, title: 'Last good Alpha', project: null,
started_at: null, ended_at: null, git_branch: null, version: null,
message_count: 0, countMode: 'total', jsonl_path: unit.key, source: 'alpha',
};
return '10:1';
},
raw: () => null,
};
const options = {
providerRegistry: createProviderRegistry([provider]),
dbPath,
DatabaseImpl: TestDatabase,
};
assert.equal(buildIndex(options).complete, true);
const forced = buildIndex({ ...options, force: true });
assert.equal(forced.complete, true);
assert.deepEqual(forced.incompleteProviders, []);
const afterDb = new TestDatabase(dbPath);
assert.deepEqual(
{ ...afterDb.prepare("SELECT id,title,source FROM sessions WHERE source='alpha'").get() },
{ id: 'alpha:session', title: 'Last good Alpha', source: 'alpha' },
);
assert.equal(afterDb.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?').get(unitKey).c, 1);
assert.equal(afterDb.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path=?').get(marker).c, 1);
afterDb.close();
});
+4 -3
View File
@@ -1,6 +1,6 @@
// Regression tests for the write-transaction runner (docs/adr/0006): // Regression tests for the write-transaction runner (docs/adr/0006):
// - a transient BUSY (auto-rolled-back txn) is retried and recovers; // - a transient BUSY (auto-rolled-back txn) is retried and recovers;
// - a persistent BUSY exhausts retries, and that file is SKIPPED, not fatal; // - a persistent BUSY exhausts retries without publishing a partial force rebuild;
// - the guarded rollback never masks the real error ("cannot rollback ..."). // - the guarded rollback never masks the real error ("cannot rollback ...").
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -180,7 +180,7 @@ test('a transient BUSY during a file transaction is retried and recovers', () =>
assert.equal(result.skipped, 0); assert.equal(result.skipped, 0);
}); });
test('a persistent BUSY exhausts retries and skips just that file, not the build', () => { test('a persistent BUSY rolls back the complete force rebuild', () => {
const { home, dbPath, projectsDir } = twoFileHome('POISON alpha', 'hello beta'); const { home, dbPath, projectsDir } = twoFileHome('POISON alpha', 'hello beta');
// Always poison writes that carry alpha's marker text; beta is untouched. // Always poison writes that carry alpha's marker text; beta is untouched.
const Db = makeDbClass((args) => args.some(a => typeof a === 'string' && a.includes('POISON'))); const Db = makeDbClass((args) => args.some(a => typeof a === 'string' && a.includes('POISON')));
@@ -191,7 +191,8 @@ test('a persistent BUSY exhausts retries and skips just that file, not the build
const check = new DatabaseSync(dbPath); const check = new DatabaseSync(dbPath);
const sessions = check.prepare('SELECT id FROM sessions ORDER BY id').all().map(r => r.id); const sessions = check.prepare('SELECT id FROM sessions ORDER BY id').all().map(r => r.id);
check.close(); check.close();
assert.deepEqual(sessions, ['beta'], 'the persistently-failing file is skipped; the other indexes'); assert.deepEqual(sessions, [], 'no partial force snapshot is published');
assert.equal(result.complete, false);
assert.equal(result.skipped, 1, 'the skipped file is reported in the build result'); assert.equal(result.skipped, 1, 'the skipped file is reported in the build result');
assert.equal(result.skippedFiles[0].diagnostics?.phase, 'work', 'diagnostics record the failing phase'); assert.equal(result.skippedFiles[0].diagnostics?.phase, 'work', 'diagnostics record the failing phase');
}); });
+4 -4
View File
@@ -99,7 +99,7 @@ test('search() hit shape matches api-reference.md', () => {
const hit = createQueryApi(db).search('needle', { limit: 1 })[0]; const hit = createQueryApi(db).search('needle', { limit: 1 })[0];
exactKeys(hit, ['message', 'session', 'rank', 'context'], 'search() hit'); exactKeys(hit, ['message', 'session', 'rank', 'context'], 'search() hit');
exactKeys(hit.message, ['uuid', 'text', 'content_type', 'is_meta', 'role', 'timestamp', 'model', 'cwd', 'source'], 'search() hit.message'); exactKeys(hit.message, ['uuid', 'text', 'content_type', 'is_meta', 'role', 'timestamp', 'model', 'cwd', 'visibility', 'source'], 'search() hit.message');
exactKeys(hit.session, ['id', 'title', 'project', 'started_at', 'source'], 'search() hit.session'); exactKeys(hit.session, ['id', 'title', 'project', 'started_at', 'source'], 'search() hit.session');
assert.ok(Array.isArray(hit.context), 'search() hit.context is an array'); assert.ok(Array.isArray(hit.context), 'search() hit.context is an array');
db.close(); db.close();
@@ -119,7 +119,7 @@ test('fileHistory() row shape matches api-reference.md', () => {
const db = fixture(); const db = fixture();
const row = createQueryApi(db).fileHistory('/x/file.ts')[0]; const row = createQueryApi(db).fileHistory('/x/file.ts')[0];
exactKeys(row, ['toolCall', 'session', 'timestamp'], 'fileHistory() row'); exactKeys(row, ['toolCall', 'session', 'timestamp', 'visibility'], 'fileHistory() row');
exactKeys(row.toolCall, ['id', 'message_uuid', 'name', 'input_json'], 'fileHistory() row.toolCall'); exactKeys(row.toolCall, ['id', 'message_uuid', 'name', 'input_json'], 'fileHistory() row.toolCall');
exactKeys(row.session, ['id', 'title', 'project'], 'fileHistory() row.session'); exactKeys(row.session, ['id', 'title', 'project'], 'fileHistory() row.session');
db.close(); db.close();
@@ -129,7 +129,7 @@ test('failures() row shape matches api-reference.md', () => {
const db = fixture(); const db = fixture();
const row = createQueryApi(db).failures()[0]; const row = createQueryApi(db).failures()[0];
exactKeys(row, ['toolCall', 'result', 'session', 'nextMessages'], 'failures() row'); exactKeys(row, ['toolCall', 'result', 'session', 'nextMessages', 'visibility'], 'failures() row');
assert.ok(Array.isArray(row.nextMessages), 'failures() row.nextMessages is an array'); assert.ok(Array.isArray(row.nextMessages), 'failures() row.nextMessages is an array');
assert.equal(row.nextMessages[0].uuid, 'm-after'); assert.equal(row.nextMessages[0].uuid, 'm-after');
db.close(); db.close();
@@ -196,7 +196,7 @@ test('raw() shape matches api-reference.md', () => {
VALUES (?, ?, ?, ?, ?, ?, ?)`).run('m-raw', 'sid-raw', 'user', 'user', 'raw line body', 'text', 'claude'); VALUES (?, ?, ?, ?, ?, ?, ?)`).run('m-raw', 'sid-raw', 'user', 'user', 'raw line body', 'text', 'claude');
const result = createQueryApi(db).raw('m-raw'); const result = createQueryApi(db).raw('m-raw');
exactKeys(result, ['text', 'totalLength', 'offset', 'limit', 'hasMore'], 'raw()'); exactKeys(result, ['text', 'totalLength', 'offset', 'limit', 'hasMore', 'visibility'], 'raw()');
assert.equal(result.text, line); assert.equal(result.text, line);
assert.equal(result.totalLength, line.length); assert.equal(result.totalLength, line.length);
db.close(); db.close();
+4
View File
@@ -17,4 +17,8 @@ test('build:core emits an importable package with its schema resource', async ()
assert.equal(typeof core.searchText, 'function'); assert.equal(typeof core.searchText, 'function');
assert.equal(typeof core.executeQuery, 'function'); assert.equal(typeof core.executeQuery, 'function');
assert.equal(typeof core.executeAttune, 'function'); assert.equal(typeof core.executeAttune, 'function');
const pi = await import(`${pathToFileURL(join(coreDist, 'providers', 'pi.js')).href}?test=${Date.now()}`);
assert.equal(typeof pi.createPiProvider, 'function');
assert.equal(pi.piProvider.descriptor.id, 'pi');
}); });
+50
View File
@@ -4,6 +4,7 @@ import { readFile } from 'node:fs/promises';
import { DatabaseSync } from 'node:sqlite'; import { DatabaseSync } from 'node:sqlite';
import { extractContentType, extractMessageIsMeta } from '../packages/core/src/db.ts'; import { extractContentType, extractMessageIsMeta } from '../packages/core/src/db.ts';
import { migrateCoreSchemaColumns } from '../packages/core/src/schema-migrations.ts';
async function readExecutableSchema() { async function readExecutableSchema() {
return readFile(new URL('../packages/core/src/schema.sql', import.meta.url), 'utf8'); return readFile(new URL('../packages/core/src/schema.sql', import.meta.url), 'utf8');
@@ -50,6 +51,42 @@ test('messages schema stores the raw content block type', async () => {
assert.match(source, /CREATE TRIGGER IF NOT EXISTS messages_fts_ad AFTER DELETE ON messages/); assert.match(source, /CREATE TRIGGER IF NOT EXISTS messages_fts_ad AFTER DELETE ON messages/);
}); });
test('summaries preserve usage from provider-owned summary model calls', async () => {
const source = await readExecutableSchema();
assert.match(source, /summaries \([\s\S]*visibility TEXT DEFAULT 'visible'[\s\S]*input_tokens INTEGER, output_tokens INTEGER/);
assert.match(source, /index_state \([\s\S]*cursor TEXT/);
});
test('additive migrations preserve old index state and summary rows while adding canonical fields', () => {
const db = new DatabaseSync(':memory:');
try {
db.exec(`
CREATE TABLE index_state (
jsonl_path TEXT PRIMARY KEY, mtime REAL, lines_processed INTEGER
);
CREATE TABLE summaries (
id TEXT PRIMARY KEY, session_id TEXT, timestamp TEXT,
source TEXT, content TEXT
);
INSERT INTO index_state VALUES ('unit', 12, 3);
INSERT INTO summaries VALUES ('summary', 'session', NULL, 'legacy', 'kept');
`);
migrateCoreSchemaColumns(db);
assert.equal(
db.prepare("SELECT cursor FROM index_state WHERE jsonl_path='unit'").get().cursor,
null,
);
assert.deepEqual(
{ ...db.prepare("SELECT content,visibility FROM summaries WHERE id='summary'").get() },
{ content: 'kept', visibility: 'visible' },
);
} finally {
db.close();
}
});
test('tool results schema indexes live session patch lookups', async () => { test('tool results schema indexes live session patch lookups', async () => {
const db = new DatabaseSync(':memory:'); const db = new DatabaseSync(':memory:');
try { try {
@@ -125,10 +162,18 @@ test('schema reference stays focused on raw SQL structure', async () => {
assert.ok(ref.split('\n').length < 420, 'schema.md should remain a quick SQL reference'); assert.ok(ref.split('\n').length < 420, 'schema.md should remain a quick SQL reference');
assert.match(ref, /Raw SQL Quick Reference/i); assert.match(ref, /Raw SQL Quick Reference/i);
assert.match(ref, /Claude Code, Codex, Kimi Code, and Pi/);
assert.equal(
ref.match(/Provider ID: `claude`, `codex`, `kimi`, or `pi`/g)?.length,
2,
'session and message source fields should document every provider',
);
assert.match(ref, /references\/api-reference\.md/); assert.match(ref, /references\/api-reference\.md/);
assert.match(ref, /sessions\.id\s+<--\s+messages\.session_id/); assert.match(ref, /sessions\.id\s+<--\s+messages\.session_id/);
assert.match(ref, /tool_calls.*does not have timestamps/i); assert.match(ref, /tool_calls.*does not have timestamps/i);
assert.match(ref, /COALESCE\(m\.is_meta, 0\) = 0/); assert.match(ref, /COALESCE\(m\.is_meta, 0\) = 0/);
assert.match(ref, /provider-attested superseded history/);
assert.match(ref, /Exact opaque provider cursor/);
assert.doesNotMatch(ref, /#### `summaries\(opts\?\)`/); assert.doesNotMatch(ref, /#### `summaries\(opts\?\)`/);
assert.doesNotMatch(ref, /#### `raw\(uuid, opts\?\)`/); assert.doesNotMatch(ref, /#### `raw\(uuid, opts\?\)`/);
}); });
@@ -137,8 +182,11 @@ test('api reference documents query helpers and current return fields', async ()
const ref = await readApiReference(); const ref = await readApiReference();
assert.match(ref, /## Query API Reference/); assert.match(ref, /## Query API Reference/);
assert.match(ref, /'claude' \| 'codex' \| 'kimi' \| 'pi'/);
assert.doesNotMatch(ref, /"claude", "codex", or omitted/);
assert.match(ref, /#### `summaries\(opts\?\)`/); assert.match(ref, /#### `summaries\(opts\?\)`/);
assert.match(ref, /summary rows/i); assert.match(ref, /summary rows/i);
assert.match(ref, /Inactive summaries describe work that was tried and[\s\S]*then superseded/);
assert.match(ref, /session_title/); assert.match(ref, /session_title/);
assert.match(ref, /opts\.branch/); assert.match(ref, /opts\.branch/);
assert.match(ref, /#### `raw\(uuid, opts\?\)`/); assert.match(ref, /#### `raw\(uuid, opts\?\)`/);
@@ -154,6 +202,8 @@ test('api reference documents query helpers and current return fields', async ()
test('skill routes agents to the right reference document', async () => { test('skill routes agents to the right reference document', async () => {
const skill = await readSkill(); const skill = await readSkill();
assert.match(skill, /Claude Code, Codex, Kimi Code, and Pi/);
assert.match(skill, /'claude'.*'codex'.*'kimi'.*'pi'/s);
assert.match(skill, /Reference Map/); assert.match(skill, /Reference Map/);
assert.match(skill, /references\/schema\.md.*raw SQL/i); assert.match(skill, /references\/schema\.md.*raw SQL/i);
assert.match(skill, /references\/api-reference\.md.*helper/i); assert.match(skill, /references\/api-reference\.md.*helper/i);
+5
View File
@@ -0,0 +1,5 @@
{"type":"session","version":3,"id":"harness-checkpoint-fork","timestamp":"2026-08-02T10:17:40.694Z","cwd":"/tmp/pi-harness-project","parentSession":"/tmp/source-session.jsonl"}
{"type":"compaction","id":"2bd121f3","parentId":"3582fbcc","timestamp":"2026-08-02T10:17:40.693Z","summary":"Harness compaction checkpoint","firstKeptEntryId":"bb37b0d7","tokensBefore":9001,"retainedTail":[{"role":"user","content":"Harness retained user turn","timestamp":1785664802000},{"role":"assistant","content":[{"type":"thinking","thinking":"retained reasoning"},{"type":"text","text":"Harness retained assistant turn"}],"api":"openai-responses","provider":"obelisk-probe","model":"probe-model","usage":{"input":101,"output":23,"cacheRead":17,"cacheWrite":5,"reasoning":7,"totalTokens":146,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1785664803000}]}
{"type":"message","id":"f56a248d","parentId":"2bd121f3","timestamp":"2026-08-02T10:17:40.693Z","message":{"role":"user","content":"Harness post-compaction user turn","timestamp":1785664804000}}
{"type":"message","id":"5b2c961b","parentId":"f56a248d","timestamp":"2026-08-02T10:17:40.693Z","message":{"role":"assistant","content":[{"type":"text","text":"Harness post-compaction assistant turn"}],"api":"openai-responses","provider":"obelisk-probe","model":"probe-model","usage":{"input":101,"output":23,"cacheRead":17,"cacheWrite":5,"reasoning":7,"totalTokens":146,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1785664805000}}
{"type":"session_info","id":"4bb3c8bd","parentId":"5b2c961b","timestamp":"2026-08-02T10:17:40.694Z","name":"Checkpoint fork with retained tail"}
+3
View File
@@ -0,0 +1,3 @@
{"type":"session","version":3,"id":"harness-null-leaf","timestamp":"2026-08-02T10:17:40.695Z","cwd":"/tmp/pi-harness-project"}
{"type":"message","id":"be0488ce","parentId":null,"timestamp":"2026-08-02T10:17:40.695Z","message":{"role":"user","content":"This physical message is no longer active","timestamp":1785664806000}}
{"type":"leaf","id":"ff4d594c","parentId":"be0488ce","timestamp":"2026-08-02T10:17:40.695Z","targetId":null}
+11
View File
@@ -0,0 +1,11 @@
{"type":"session","version":3,"id":"harness-probe","timestamp":"2026-08-02T10:17:40.691Z","cwd":"/tmp/pi-harness-project","metadata":{"purpose":"obelisk-pi-adapter-probe"}}
{"type":"message","id":"a49e0082","parentId":null,"timestamp":"2026-08-02T10:17:40.693Z","message":{"role":"user","content":"Harness first user turn","timestamp":1785664800000}}
{"type":"message","id":"6edde419","parentId":"a49e0082","timestamp":"2026-08-02T10:17:40.693Z","message":{"role":"assistant","content":[{"type":"text","text":"Harness first assistant turn"}],"api":"openai-responses","provider":"obelisk-probe","model":"probe-model","usage":{"input":101,"output":23,"cacheRead":17,"cacheWrite":5,"reasoning":7,"totalTokens":146,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1785664801000}}
{"type":"message","id":"bb37b0d7","parentId":"6edde419","timestamp":"2026-08-02T10:17:40.693Z","message":{"role":"user","content":"Harness retained user turn","timestamp":1785664802000}}
{"type":"message","id":"79b4a046","parentId":"bb37b0d7","timestamp":"2026-08-02T10:17:40.693Z","message":{"role":"assistant","content":[{"type":"text","text":"Harness retained assistant turn"}],"api":"openai-responses","provider":"obelisk-probe","model":"probe-model","usage":{"input":101,"output":23,"cacheRead":17,"cacheWrite":5,"reasoning":7,"totalTokens":146,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1785664803000}}
{"type":"active_tools_change","id":"3582fbcc","parentId":"79b4a046","timestamp":"2026-08-02T10:17:40.693Z","activeToolNames":["read","write"]}
{"type":"compaction","id":"2bd121f3","parentId":"3582fbcc","timestamp":"2026-08-02T10:17:40.693Z","summary":"Harness compaction checkpoint","firstKeptEntryId":"bb37b0d7","tokensBefore":9001,"retainedTail":[{"role":"user","content":"Harness retained user turn","timestamp":1785664802000},{"role":"assistant","content":[{"type":"text","text":"Harness retained assistant turn"}],"api":"openai-responses","provider":"obelisk-probe","model":"probe-model","usage":{"input":101,"output":23,"cacheRead":17,"cacheWrite":5,"reasoning":7,"totalTokens":146,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1785664803000}]}
{"type":"message","id":"f56a248d","parentId":"2bd121f3","timestamp":"2026-08-02T10:17:40.693Z","message":{"role":"user","content":"Harness post-compaction user turn","timestamp":1785664804000}}
{"type":"message","id":"5b2c961b","parentId":"f56a248d","timestamp":"2026-08-02T10:17:40.693Z","message":{"role":"assistant","content":[{"type":"text","text":"Harness post-compaction assistant turn"}],"api":"openai-responses","provider":"obelisk-probe","model":"probe-model","usage":{"input":101,"output":23,"cacheRead":17,"cacheWrite":5,"reasoning":7,"totalTokens":146,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1785664805000}}
{"type":"session_info","id":"116253e0","parentId":"5b2c961b","timestamp":"2026-08-02T10:17:40.695Z","name":"Source with durable leaf marker"}
{"type":"leaf","id":"771ef412","parentId":"116253e0","timestamp":"2026-08-02T10:17:40.695Z","targetId":"a49e0082"}
+169
View File
@@ -0,0 +1,169 @@
/*
* Test-only transcription of the Pi 0.83.0 context algorithms:
* https://github.com/earendil-works/pi/blob/v0.83.0/packages/coding-agent/src/core/session-manager.ts
* https://github.com/earendil-works/pi/blob/v0.83.0/packages/agent/src/harness/session/session.ts
*
* MIT License
*
* Copyright (c) 2025 Mario Zechner
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
export const PI_CONTEXT_ORACLE_VERSION = '0.83.0';
function buildEntryIndex(entries, byId) {
if (byId) return byId;
const index = new Map();
for (const entry of entries) index.set(entry.id, entry);
return index;
}
export function buildCodingAgentSessionPath(entries, leafId, byId) {
const index = buildEntryIndex(entries, byId);
let leaf;
if (leafId === null) return [];
if (leafId) leaf = index.get(leafId);
leaf ??= entries[entries.length - 1];
if (!leaf) return [];
const path = [];
let current = leaf;
while (current) {
path.push(current);
current = current.parentId ? index.get(current.parentId) : undefined;
}
path.reverse();
return path;
}
// pi-agent-core 0.83.0 JsonlSessionStorage.getPathToRootOrCompaction().
// The checked fixtures may contain synthetic orphan edges; those terminate the
// path just as the coding-agent oracle above does instead of exercising the
// storage API's separate invalid-session error.
export function buildAgentCoreSessionPath(entries, leafId, byId) {
const index = buildEntryIndex(entries, byId);
if (leafId === null) return [];
let current = leafId ? index.get(leafId) : entries[entries.length - 1];
current ??= entries[entries.length - 1];
if (!current) return [];
const path = [];
let stopAtEntryId = null;
while (current) {
path.unshift(current);
if (stopAtEntryId !== null && current.id === stopAtEntryId) break;
if (current.type === 'compaction') {
if (current.retainedTail) break;
stopAtEntryId = current.firstKeptEntryId ?? null;
}
if (!current.parentId) break;
current = index.get(current.parentId);
}
return path;
}
export function buildCodingAgentContextEntries(entries, leafId, byId) {
const path = buildCodingAgentSessionPath(entries, leafId, byId);
let compaction = null;
for (const entry of path) {
if (entry.type === 'compaction') compaction = entry;
}
if (!compaction) return path;
const compactionIndex = path.findIndex(entry => entry.id === compaction.id);
if (compactionIndex < 0) return path;
const contextEntries = [compaction];
let foundFirstKept = false;
for (let index = 0; index < compactionIndex; index++) {
const entry = path[index];
if (entry.id === compaction.firstKeptEntryId) foundFirstKept = true;
if (foundFirstKept) contextEntries.push(entry);
}
contextEntries.push(...path.slice(compactionIndex + 1));
return contextEntries;
}
export function defaultAgentCoreContextEntryTransform(pathEntries) {
let compaction = null;
for (const entry of pathEntries) {
if (entry.type === 'compaction') compaction = entry;
}
if (!compaction) return [...pathEntries];
const entries = [compaction];
const compactionIndex = pathEntries.findIndex(entry => (
entry.type === 'compaction' && entry.id === compaction.id
));
if (compaction.retainedTail) {
for (let index = compactionIndex + 1; index < pathEntries.length; index++) {
entries.push(pathEntries[index]);
}
return entries;
}
if (compaction.firstKeptEntryId) {
let foundFirstKept = false;
for (let index = 0; index < compactionIndex; index++) {
const entry = pathEntries[index];
if (entry.id === compaction.firstKeptEntryId) foundFirstKept = true;
if (foundFirstKept) entries.push(entry);
}
}
for (let index = compactionIndex + 1; index < pathEntries.length; index++) {
entries.push(pathEntries[index]);
}
return entries;
}
export function pi083LeafId(entries) {
let leafId = null;
for (const entry of entries) {
leafId = entry.type === 'leaf' ? entry.targetId : entry.id;
}
return leafId;
}
function evidence(kind, source, role, content) {
return JSON.stringify({ kind, source, role, content });
}
// The Pi oracles select the active context entries. Obelisk stores those entries
// in durable physical order for its evidence timeline, while retainedTail
// messages remain immediately after their owning compaction summary.
export function projectCanonicalEvidence(physicalEntries, contextEntries) {
const activeIds = new Set(contextEntries.map(entry => entry.id));
return physicalEntries.filter(entry => activeIds.has(entry.id)).flatMap((entry) => {
if (entry.type === 'message') {
return [evidence('message', null, entry.message.role, entry.message.content)];
}
if (entry.type === 'branch_summary') {
return [evidence('summary', 'pi:branch_summary', null, entry.summary)];
}
if (entry.type === 'compaction') {
return [
evidence('summary', 'pi:compaction', null, entry.summary),
...(entry.retainedTail ?? []).map(message => (
evidence('message', null, message.role, message.content)
)),
];
}
return [];
});
}
+7
View File
@@ -0,0 +1,7 @@
{"type":"session","version":3,"id":"real-probe","timestamp":"2026-08-02T09:46:45.516Z","cwd":"/tmp/pi-project"}
{"type":"session_info","id":"211e5f6a","parentId":null,"timestamp":"2026-08-02T09:46:45.517Z","name":"Real model probe"}
{"type":"model_change","id":"ec3a3e01","parentId":"211e5f6a","timestamp":"2026-08-02T09:46:45.535Z","provider":"custom-openai","modelId":"gpt-probe"}
{"type":"message","id":"a7d8022e","parentId":"ec3a3e01","timestamp":"2026-08-02T09:46:45.539Z","message":{"role":"user","content":[{"type":"text","text":"Reply with the probe marker"},{"type":"image","mimeType":"image/png","data":"QUJDRA=="}],"timestamp":1785664005538}}
{"type":"message","id":"3fecb3df","parentId":"a7d8022e","timestamp":"2026-08-02T09:46:46.507Z","message":{"role":"assistant","content":[],"api":"openai-completions","provider":"custom-openai","model":"gpt-probe","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"error","timestamp":1785664005566,"errorMessage":"404 not found"}}
{"type":"message","id":"e40917d4","parentId":"3fecb3df","timestamp":"2026-08-02T09:47:57.481Z","message":{"role":"user","content":[{"type":"text","text":"Try the responses protocol"}],"timestamp":1785664077480}}
{"type":"message","id":"57e279e0","parentId":"e40917d4","timestamp":"2026-08-02T09:47:59.459Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"","thinkingSignature":"opaque-probe-signature"},{"type":"text","text":"REAL_PI_PROBE_OK"}],"api":"openai-responses","provider":"custom-openai","model":"gpt-probe","usage":{"input":973,"output":20,"cacheRead":3840,"cacheWrite":5,"reasoning":9,"totalTokens":4838,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1785664077508}}
+8
View File
@@ -0,0 +1,8 @@
{"type":"session","version":3,"id":"tool-probe","timestamp":"2026-08-02T09:42:38.031Z","cwd":"/tmp/pi-project"}
{"type":"session_info","id":"eacdf41f","parentId":null,"timestamp":"2026-08-02T09:42:38.032Z","name":"Tool probe"}
{"type":"model_change","id":"fba5d21a","parentId":"eacdf41f","timestamp":"2026-08-02T09:42:38.041Z","provider":"obelisk-probe","modelId":"probe-model"}
{"type":"thinking_level_change","id":"e32523fb","parentId":"fba5d21a","timestamp":"2026-08-02T09:42:38.041Z","thinkingLevel":"off"}
{"type":"message","id":"7e0576ed","parentId":"e32523fb","timestamp":"2026-08-02T09:42:38.043Z","message":{"role":"user","content":[{"type":"text","text":"Read probe.txt and report it"}],"timestamp":1785663758043}}
{"type":"message","id":"c2be25cd","parentId":"7e0576ed","timestamp":"2026-08-02T09:42:38.069Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"call_obelisk_probe","name":"read","arguments":{"path":"probe.txt"}}],"api":"openai-completions","provider":"obelisk-probe","model":"probe-model","usage":{"input":14,"output":5,"cacheRead":3,"cacheWrite":0,"reasoning":2,"totalTokens":22,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1785663758058}}
{"type":"message","id":"47e488af","parentId":"c2be25cd","timestamp":"2026-08-02T09:42:38.072Z","message":{"role":"toolResult","toolCallId":"call_obelisk_probe","toolName":"read","content":[{"type":"text","text":"real-pi-tool-result\n"}],"isError":false,"timestamp":1785663758072}}
{"type":"message","id":"9db96a87","parentId":"47e488af","timestamp":"2026-08-02T09:42:38.075Z","message":{"role":"assistant","content":[{"type":"text","text":"The read tool returned real-pi-tool-result."}],"api":"openai-completions","provider":"obelisk-probe","model":"probe-model","usage":{"input":19,"output":9,"cacheRead":4,"cacheWrite":0,"reasoning":0,"totalTokens":32,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1785663758072}}
+10 -2
View File
@@ -11,6 +11,7 @@ import { join } from 'node:path';
import { parse } from '../packages/core/src/providers/claude.ts'; import { parse } from '../packages/core/src/providers/claude.ts';
import { persist } from '../packages/core/src/persist.ts'; import { persist } from '../packages/core/src/persist.ts';
import { storedProviderCursor } from '../packages/core/src/provider-indexing.ts';
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const { DatabaseSync } = require('node:sqlite'); const { DatabaseSync } = require('node:sqlite');
@@ -47,6 +48,10 @@ test('persist writes all record kinds from one claude parse', () => {
assert.equal(db.prepare('SELECT COUNT(*) c FROM tool_calls').get().c, 1); assert.equal(db.prepare('SELECT COUNT(*) c FROM tool_calls').get().c, 1);
assert.equal(db.prepare('SELECT COUNT(*) c FROM tool_results').get().c, 1); assert.equal(db.prepare('SELECT COUNT(*) c FROM tool_results').get().c, 1);
assert.equal(db.prepare('SELECT COUNT(*) c FROM summaries').get().c, 1); assert.equal(db.prepare('SELECT COUNT(*) c FROM summaries').get().c, 1);
assert.deepEqual(
{ ...db.prepare('SELECT visibility,input_tokens,output_tokens FROM summaries').get() },
{ visibility: 'visible', input_tokens: null, output_tokens: null },
);
const ses = db.prepare('SELECT * FROM sessions WHERE id=?').get('sid-p'); const ses = db.prepare('SELECT * FROM sessions WHERE id=?').get('sid-p');
assert.equal(ses.title, 'Persist Session'); assert.equal(ses.title, 'Persist Session');
@@ -60,9 +65,12 @@ test('persist writes all record kinds from one claude parse', () => {
assert.equal(usage.input_tokens, 60); assert.equal(usage.input_tokens, 60);
assert.equal(usage.output_tokens, 5); assert.equal(usage.output_tokens, 5);
// Cursor persisted into index_state (mtime:lines → two columns). // The legacy numeric columns remain queryable, while the opaque token
const state = db.prepare('SELECT lines_processed FROM index_state WHERE jsonl_path=?').get(unit.key); // round-trips byte-for-byte for snapshot providers.
const state = db.prepare('SELECT lines_processed,cursor FROM index_state WHERE jsonl_path=?').get(unit.key);
assert.equal(state.lines_processed, 6); assert.equal(state.lines_processed, 6);
assert.equal(state.cursor, cursor);
assert.equal(storedProviderCursor(db, unit.key), cursor);
assert.equal(cursor.split(':')[1], '6'); assert.equal(cursor.split(':')[1], '6');
}); });
File diff suppressed because it is too large Load Diff
+250
View File
@@ -0,0 +1,250 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { createPiProvider } from '../packages/core/src/providers/pi.ts';
import {
buildCodingAgentContextEntries,
buildCodingAgentSessionPath,
buildAgentCoreSessionPath,
defaultAgentCoreContextEntryTransform,
PI_CONTEXT_ORACLE_VERSION,
pi083LeafId,
projectCanonicalEvidence,
} from './fixtures/pi/pi-0.83.0-context-oracle.mjs';
const CASES = 512;
const SEED = 0x5eedc0de;
function randomGenerator(seed) {
let state = seed >>> 0;
return () => {
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
return state / 0x1_0000_0000;
};
}
function integer(random, min, max) {
return min + Math.floor(random() * (max - min + 1));
}
function timestamp(caseIndex, entryIndex) {
return new Date(Date.UTC(2026, 7, 2, 12, caseIndex % 30, entryIndex)).toISOString();
}
function actualEvidence(records) {
return records.flatMap((record) => {
if (record.kind === 'message' && record.visibility === 'visible') {
return [JSON.stringify({
kind: 'message',
source: null,
role: record.role,
content: record.text,
})];
}
if (record.kind === 'summary' && record.visibility === 'visible') {
return [JSON.stringify({
kind: 'summary',
source: record.source,
role: null,
content: record.content,
})];
}
return [];
});
}
function drain(generator) {
const records = [];
for (;;) {
const step = generator.next();
if (step.done) return records;
records.push(step.value);
}
}
function createCase(random, caseIndex, totals) {
const entries = [];
const ids = [];
let headId = null;
const entryCount = integer(random, 8, 64);
for (let entryIndex = 0; entryIndex < entryCount; entryIndex++) {
const id = `case-${caseIndex}-entry-${entryIndex}`;
const time = timestamp(caseIndex, entryIndex);
const roll = random();
let entry;
if (roll < 0.08 && ids.length > 0) {
const targetId = random() < 0.12 ? null : ids[integer(random, 0, ids.length - 1)];
entry = { type: 'leaf', id, parentId: headId, timestamp: time, targetId };
headId = targetId;
totals.leaves++;
if (targetId === null) totals.nullLeaves++;
} else {
let parentId = headId;
if (random() < 0.06) {
parentId = `omitted-${caseIndex}-${entryIndex}`;
totals.orphanParents++;
} else if (ids.length > 0 && random() < 0.28) {
parentId = ids[integer(random, 0, ids.length - 1)];
}
if (roll < 0.72) {
entry = {
type: 'message',
id,
parentId,
timestamp: time,
message: {
role: 'user',
content: `entry:${id}`,
timestamp: Date.parse(time),
},
};
totals.messages++;
} else if (roll < 0.84) {
entry = {
type: 'branch_summary',
id,
parentId,
timestamp: time,
fromId: parentId ?? id,
summary: `branch:${id}`,
};
totals.branchSummaries++;
} else if (roll < 0.96) {
const retained = random() < 0.5;
entry = {
type: 'compaction',
id,
parentId,
timestamp: time,
summary: `compaction:${id}`,
tokensBefore: integer(random, 0, 100_000),
...(retained
? {
retainedTail: [{
role: 'user',
content: `tail:${id}`,
timestamp: Date.parse(time),
}],
}
: { firstKeptEntryId: parentId }),
};
totals.compactions++;
if (retained) totals.retainedTailCompactions++;
} else {
entry = {
type: 'model_change',
id,
parentId,
timestamp: time,
provider: 'probe',
modelId: 'probe',
};
}
headId = id;
}
entries.push(entry);
ids.push(id);
totals.entries++;
}
return {
header: {
type: 'session',
version: 3,
id: `differential-${caseIndex}`,
timestamp: timestamp(caseIndex, 0),
cwd: `/tmp/obelisk-pi-differential/project-${caseIndex}`,
},
entries,
};
}
test('fixed-seed randomized differential matches vendored Pi 0.83.0 context oracles', () => {
assert.equal(PI_CONTEXT_ORACLE_VERSION, '0.83.0');
const random = randomGenerator(SEED);
const root = mkdtempSync(join(tmpdir(), 'obelisk-pi-randomized-differential-'));
const generated = [];
const totals = {
cases: CASES,
entries: 0,
messages: 0,
leaves: 0,
nullLeaves: 0,
compactions: 0,
retainedTailCompactions: 0,
branchSummaries: 0,
orphanParents: 0,
retainedCheckpointPathCases: 0,
mixedCheckpointLegacyCases: 0,
};
for (let caseIndex = 0; caseIndex < CASES; caseIndex++) {
const generatedCase = createCase(random, caseIndex, totals);
generated.push(generatedCase);
const path = join(root, `case-${caseIndex}`, 'session.jsonl');
mkdirSync(dirname(path), { recursive: true });
writeFileSync(
path,
`${[generatedCase.header, ...generatedCase.entries].map(record => JSON.stringify(record)).join('\n')}\n`,
);
}
const provider = createPiProvider({ rootDir: root });
const units = provider.discover({ lastCursor: () => null });
assert.equal(units.length, CASES);
for (const unit of units) {
const caseIndex = Number(/case-(\d+)/.exec(unit.key)?.[1]);
const entries = generated[caseIndex].entries;
const leafId = pi083LeafId(entries);
const fullPath = buildCodingAgentSessionPath(entries, leafId);
const codingAgentContext = buildCodingAgentContextEntries(entries, leafId);
const agentCoreContext = defaultAgentCoreContextEntryTransform(
buildAgentCoreSessionPath(entries, leafId),
);
// Pi 0.83's CLI owns legacy firstKeptEntryId semantics. retainedTail is the
// agent-core storage checkpoint format, so mixed/new chains use its bounded
// storage path before the context transform.
let checkpointIndex = -1;
for (let index = 0; index < fullPath.length; index++) {
const entry = fullPath[index];
if (entry.type === 'compaction' && entry.retainedTail !== undefined) {
checkpointIndex = index;
}
}
if (checkpointIndex >= 0) {
totals.retainedCheckpointPathCases++;
if (fullPath.slice(checkpointIndex + 1).some(entry => (
entry.type === 'compaction' && entry.retainedTail === undefined
))) {
totals.mixedCheckpointLegacyCases++;
}
}
const expectedContext = checkpointIndex >= 0
? agentCoreContext
: codingAgentContext;
assert.deepEqual(
actualEvidence(drain(provider.parse(unit, null))),
projectCanonicalEvidence(entries, expectedContext),
`seed 0x${SEED.toString(16)}, case ${caseIndex}`,
);
}
assert.deepEqual(totals, {
cases: 512,
entries: 18124,
messages: 11586,
leaves: 1457,
nullLeaves: 167,
compactions: 2230,
retainedTailCompactions: 1137,
branchSummaries: 2191,
orphanParents: 1060,
retainedCheckpointPathCases: 175,
mixedCheckpointLegacyCases: 32,
});
});
+203
View File
@@ -0,0 +1,203 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { pathToFileURL } from 'node:url';
import { DatabaseSync } from 'node:sqlite';
import { piSessionId } from '../packages/core/src/providers/pi.ts';
import { runCli } from './cli-test-helpers.mjs';
test('passive-pull runtime indexes Pi sessions from the default home', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-pi-runtime-'));
const sessionPath = join(
home,
'.pi',
'agent',
'sessions',
'--tmp-pi-project--',
'runtime.jsonl',
);
mkdirSync(dirname(sessionPath), { recursive: true });
writeFileSync(
sessionPath,
readFileSync(new URL('./fixtures/pi/tool-session.jsonl', import.meta.url)),
);
const coreUrl = pathToFileURL(join(process.cwd(), 'packages/core/src/core.ts')).href;
const script = `
import { executeQuery } from ${JSON.stringify(coreUrl)};
const result = await executeQuery("return sessions({ source: 'pi', limit: 5 });");
process.stdout.write(JSON.stringify(result));
`;
const env = { ...process.env, HOME: home, USERPROFILE: home };
delete env.PI_CODING_AGENT_DIR;
delete env.PI_CODING_AGENT_SESSION_DIR;
const run = spawnSync(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', script], {
cwd: process.cwd(),
env,
encoding: 'utf8',
});
assert.equal(run.status, 0, run.stderr);
const sessions = JSON.parse(run.stdout);
assert.equal(sessions.length, 1);
const fixtureHeader = JSON.parse(
readFileSync(new URL('./fixtures/pi/tool-session.jsonl', import.meta.url), 'utf8').split('\n')[0],
);
assert.equal(sessions[0].id, piSessionId(fixtureHeader));
assert.deepEqual(
(({ title, source, message_count }) => ({ title, source, message_count }))(sessions[0]),
{ title: 'Tool probe', source: 'pi', message_count: 4 },
);
});
test('passive-pull runtime honors the same persisted Pi root as the app', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-pi-custom-runtime-'));
const defaultRoot = join(home, '.pi', 'agent', 'sessions');
const customRoot = join(home, 'custom-pi-sessions');
const sessionPath = join(customRoot, '--tmp-pi-real-tool--', 'session.jsonl');
mkdirSync(defaultRoot, { recursive: true });
mkdirSync(dirname(sessionPath), { recursive: true });
writeFileSync(
sessionPath,
readFileSync(new URL('./fixtures/pi/tool-session.jsonl', import.meta.url)),
);
const settingsPath = join(home, '.obelisk', 'settings.json');
mkdirSync(dirname(settingsPath), { recursive: true });
writeFileSync(settingsPath, JSON.stringify({
providerRoots: { pi: customRoot },
}));
const coreUrl = pathToFileURL(join(process.cwd(), 'packages/core/src/core.ts')).href;
const script = `
import { executeQuery } from ${JSON.stringify(coreUrl)};
const result = await executeQuery("return sessions({ source: 'pi', limit: 5 });");
process.stdout.write(JSON.stringify(result));
`;
const env = { ...process.env, HOME: home, USERPROFILE: home };
delete env.PI_CODING_AGENT_DIR;
delete env.PI_CODING_AGENT_SESSION_DIR;
const run = spawnSync(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', script], {
cwd: process.cwd(),
env,
encoding: 'utf8',
});
assert.equal(run.status, 0, run.stderr);
const sessions = JSON.parse(run.stdout);
assert.equal(sessions.length, 1);
assert.equal(sessions[0].source, 'pi');
assert.equal(sessions[0].title, 'Tool probe');
});
test('passive CLI operations report an incomplete Pi inventory without hiding partial results', () => {
for (const command of ['search', 'query', 'attune']) {
const home = mkdtempSync(join(tmpdir(), `obelisk-pi-partial-${command}-`));
const piRoot = join(home, '.pi', 'agent', 'sessions');
mkdirSync(dirname(piRoot), { recursive: true });
writeFileSync(piRoot, 'not a directory');
const queryPath = join(home, 'query.mjs');
writeFileSync(
queryPath,
command === 'attune'
? 'return null;'
: "return sessions({ source: 'pi', limit: 5 });",
);
const args = command === 'search'
? ['--search', 'partial-probe']
: [`--${command}`, queryPath];
const result = runCli(args, {
home,
env: { PI_CODING_AGENT_DIR: '', PI_CODING_AGENT_SESSION_DIR: '' },
});
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.deepEqual(JSON.parse(result.stdout), command === 'attune' ? null : []);
assert.match(result.stderr, /Warning: incomplete pi source inventory/);
assert.match(
result.stderr,
new RegExp(piRoot.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
);
assert.match(result.stderr, /ENOTDIR|not a directory/i);
}
});
test('CLI force rebuild rejects a structurally invalid Pi snapshot and preserves the last good index', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-pi-cli-force-'));
const sessionPath = join(home, '.pi', 'agent', 'sessions', 'project', 'session.jsonl');
mkdirSync(dirname(sessionPath), { recursive: true });
const fixture = readFileSync(new URL('./fixtures/pi/tool-session.jsonl', import.meta.url), 'utf8');
writeFileSync(sessionPath, fixture);
const env = { PI_CODING_AGENT_DIR: '', PI_CODING_AGENT_SESSION_DIR: '' };
const first = runCli(['--build'], { home, env });
assert.equal(first.status, 0, first.stderr || first.stdout);
const dbPath = join(home, '.obelisk', 'obelisk.sqlite');
let db = new DatabaseSync(dbPath, { readOnly: true });
const before = {
sessions: db.prepare("SELECT id,title,message_count,jsonl_path FROM sessions WHERE source='pi'").all()
.map(row => ({ ...row })),
messages: db.prepare("SELECT uuid,text FROM messages WHERE source='pi' ORDER BY uuid").all()
.map(row => ({ ...row })),
};
db.close();
writeFileSync(sessionPath, [
fixture.split('\n')[0],
JSON.stringify({
type: 'message',
id: 'invalid-message',
parentId: null,
timestamp: '2026-08-02T10:00:01.000Z',
message: null,
}),
'',
].join('\n'));
const failed = runCli(['--build'], { home, env });
assert.equal(failed.status, 1, failed.stderr || failed.stdout);
assert.match(JSON.parse(failed.stdout).error, /provider_failure/);
db = new DatabaseSync(dbPath, { readOnly: true });
const after = {
sessions: db.prepare("SELECT id,title,message_count,jsonl_path FROM sessions WHERE source='pi'").all()
.map(row => ({ ...row })),
messages: db.prepare("SELECT uuid,text FROM messages WHERE source='pi' ORDER BY uuid").all()
.map(row => ({ ...row })),
};
db.close();
assert.deepEqual(after, before);
});
test('malformed official Pi settings use Pi 0.83 default-root fallback', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-pi-cli-settings-'));
const agentDir = join(home, '.pi', 'agent');
const customRoot = join(home, 'custom-pi-sessions');
const customPath = join(customRoot, 'project', 'custom.jsonl');
const defaultPath = join(agentDir, 'sessions', 'project', 'default.jsonl');
const settingsPath = join(agentDir, 'settings.json');
const fixture = readFileSync(new URL('./fixtures/pi/tool-session.jsonl', import.meta.url), 'utf8');
mkdirSync(dirname(customPath), { recursive: true });
mkdirSync(dirname(defaultPath), { recursive: true });
writeFileSync(customPath, fixture);
writeFileSync(settingsPath, JSON.stringify({ sessionDir: customRoot }));
const env = { PI_CODING_AGENT_DIR: '', PI_CODING_AGENT_SESSION_DIR: '' };
assert.equal(runCli(['--build'], { home, env }).status, 0);
const lure = fixture.trim().split('\n').map(line => JSON.parse(line));
lure[0] = { ...lure[0], id: 'default-lure', cwd: '/tmp/default-lure' };
writeFileSync(defaultPath, `${lure.map(record => JSON.stringify(record)).join('\n')}\n`);
writeFileSync(settingsPath, '{broken');
const rebuilt = runCli(['--build'], { home, env });
assert.equal(rebuilt.status, 0, rebuilt.stderr || rebuilt.stdout);
const db = new DatabaseSync(join(home, '.obelisk', 'obelisk.sqlite'), { readOnly: true });
assert.deepEqual(
db.prepare("SELECT jsonl_path FROM sessions WHERE source='pi'").all().map(row => row.jsonl_path),
[defaultPath],
);
db.close();
});
+56
View File
@@ -0,0 +1,56 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createClaudeProvider } from '../packages/core/src/providers/claude.ts';
import { createCodexProvider } from '../packages/core/src/providers/codex.ts';
import { createKimiProvider } from '../packages/core/src/providers/kimi.ts';
const providers = [
['claude', createClaudeProvider, 'projects'],
['codex', createCodexProvider, 'sessions'],
['kimi', createKimiProvider, 'sessions'],
];
for (const [name, createProvider, inventoryDir] of providers) {
test(`${name} reports directory enumeration failures`, () => {
const root = mkdtempSync(join(tmpdir(), `obelisk-${name}-inventory-`));
const sourcePath = join(root, inventoryDir);
writeFileSync(sourcePath, 'not a directory');
let issue;
try {
const units = createProvider({ rootDir: root }).discover({
lastCursor: () => null,
reportIncompleteInventory(value) { issue = value; },
});
assert.deepEqual(units, []);
assert.equal(issue.path, sourcePath);
assert.match(issue.error, /ENOTDIR|not a directory/i);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test(`${name} treats a missing source as incomplete only when prior sessions exist`, () => {
const root = join(mkdtempSync(join(tmpdir(), `obelisk-${name}-missing-`)), 'absent');
const provider = createProvider({ rootDir: root });
const issues = [];
const context = {
lastCursor: () => null,
reportIncompleteInventory(value) { issues.push(value); },
};
assert.deepEqual(provider.discover(context), []);
assert.deepEqual(issues, []);
assert.deepEqual(provider.discover({
...context,
indexedSessions: () => [{ sessionId: 'prior', jsonlPath: '/prior/source' }],
}), []);
assert.deepEqual(issues, [{
path: join(root, inventoryDir),
error: 'Source folder is unavailable',
}]);
});
}
+3
View File
@@ -61,12 +61,14 @@ test('built-in provider registry exposes every source without caller-side branch
claude: '/sources/claude', claude: '/sources/claude',
codex: '/sources/codex', codex: '/sources/codex',
kimi: '/sources/kimi', kimi: '/sources/kimi',
pi: '/sources/pi',
}); });
assert.deepEqual(registry.catalog().map(({ id, name }) => ({ id, name })), [ assert.deepEqual(registry.catalog().map(({ id, name }) => ({ id, name })), [
{ id: 'claude', name: 'Claude Code' }, { id: 'claude', name: 'Claude Code' },
{ id: 'codex', name: 'Codex' }, { id: 'codex', name: 'Codex' },
{ id: 'kimi', name: 'Kimi Code' }, { id: 'kimi', name: 'Kimi Code' },
{ id: 'pi', name: 'Pi' },
]); ]);
assert.deepEqual(registry.watchRoots(), [ assert.deepEqual(registry.watchRoots(), [
'/sources/claude/projects', '/sources/claude/projects',
@@ -75,5 +77,6 @@ test('built-in provider registry exposes every source without caller-side branch
'/sources/codex/session_index.jsonl', '/sources/codex/session_index.jsonl',
'/sources/kimi/sessions', '/sources/kimi/sessions',
'/sources/kimi/session_index.jsonl', '/sources/kimi/session_index.jsonl',
'/sources/pi',
]); ]);
}); });
+1 -1
View File
@@ -7,6 +7,6 @@ test('canonical transcript persistence schema changes only by explicit decision'
const schema = readFileSync(new URL('../packages/core/src/schema.sql', import.meta.url)); const schema = readFileSync(new URL('../packages/core/src/schema.sql', import.meta.url));
assert.equal( assert.equal(
createHash('sha256').update(schema).digest('hex'), createHash('sha256').update(schema).digest('hex'),
'ef5d0eea6f91c50e78ca5e28ecdc7b3ed5db83db59200642cc25866158f9d307', '0218f39cb41dabd055eed895cd08906d9f93a856fc2a30850d2d6cb8ee63e1cf',
); );
}); });
+4
View File
@@ -148,6 +148,10 @@ test('provider-classified hidden context never reaches session detail', () => {
records.filter(record => record.kind === 'message' && record.visibility === 'hidden').length, records.filter(record => record.kind === 'message' && record.visibility === 'hidden').length,
2, 2,
); );
assert.equal(
records.find(record => record.kind === 'session').message_count,
1,
);
}); });
test('provider normalization removes only structural image wrappers before deduplication', () => { test('provider normalization removes only structural image wrappers before deduplication', () => {
+153 -2
View File
@@ -1,6 +1,13 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
createConfiguredBuiltinProviderRuntime,
readPersistedProviderSettings,
} from '../packages/core/src/provider-settings.ts';
import { createProviderRegistry } from '../packages/core/src/providers/registry.ts'; import { createProviderRegistry } from '../packages/core/src/providers/registry.ts';
import { import {
buildSourceCatalog, buildSourceCatalog,
@@ -8,10 +15,10 @@ import {
setPersistedSetting, setPersistedSetting,
} from '../app/src/main/provider-settings.ts'; } from '../app/src/main/provider-settings.ts';
function provider(id, defaultRoot, color) { function provider(id, defaultRoot, color, descriptor = {}) {
return { return {
name: id, name: id,
descriptor: { id, name: `${id} name`, vendor: `${id} vendor`, defaultRoot, color }, descriptor: { id, name: `${id} name`, vendor: `${id} vendor`, defaultRoot, color, ...descriptor },
watchRoots: () => [], watchRoots: () => [],
discover: () => [], discover: () => [],
*parse() { yield* []; return null; }, *parse() { yield* []; return null; },
@@ -73,3 +80,147 @@ test('removing a generic provider root restores its descriptor default', () => {
assert.equal(setPersistedSetting(persisted, 'providerRoots.gamma', null), true); assert.equal(setPersistedSetting(persisted, 'providerRoots.gamma', null), true);
assert.deepEqual(resolveProviderRoots(registry, persisted), { gamma: '/default/gamma' }); assert.deepEqual(resolveProviderRoots(registry, persisted), { gamma: '/default/gamma' });
}); });
test('source catalog surfaces exact provider issues without hiding indexed sessions', () => {
const registry = createProviderRegistry([
provider('alpha', '/default/alpha', '#112233'),
]);
assert.deepEqual(buildSourceCatalog({
registry,
roots: { alpha: '/custom/alpha' },
stats: new Map([
['alpha', { sessionCount: 2, lastIndexed: '2026-07-20T10:00:00.000Z' }],
]),
sourceIssues: [{
provider: 'alpha',
path: '/custom/alpha/locked',
error: 'EACCES: permission denied',
}],
pathExists: () => true,
}), [{
id: 'alpha',
name: 'alpha name',
vendor: 'alpha vendor',
color: '#112233',
path: '/custom/alpha',
settingKey: 'providerRoots.alpha',
exists: true,
sessionCount: 2,
lastIndexed: '2026-07-20T10:00:00.000Z',
status: 'warn',
statusText: 'Index issue: /custom/alpha/locked — EACCES: permission denied',
}]);
});
test('an ambiguous provider default stays omitted until the user selects a root', () => {
const registry = createProviderRegistry([
provider('relative', '/fallback/relative', '#999999', {
requiresExplicitRoot: true,
rootResolutionReason: 'Relative runtime setting needs an explicit folder',
}),
]);
assert.deepEqual(resolveProviderRoots(registry), {});
assert.deepEqual(resolveProviderRoots(registry, {
providerRoots: { relative: '/fallback/relative' },
}), {
relative: '/fallback/relative',
});
assert.deepEqual(buildSourceCatalog({
registry,
roots: {},
pathExists: () => true,
}), [{
id: 'relative',
name: 'relative name',
vendor: 'relative vendor',
color: '#999999',
path: '/fallback/relative',
settingKey: 'providerRoots.relative',
exists: true,
sessionCount: 0,
lastIndexed: '',
status: 'error',
statusText: 'Relative runtime setting needs an explicit folder',
}]);
});
test('provider roots expand a persisted home-relative path before registry construction', () => {
const registry = createProviderRegistry([
provider('alpha', '/default/alpha', '#112233'),
]);
assert.deepEqual(resolveProviderRoots(
registry,
{ providerRoots: { alpha: '~/custom/sessions' } },
{ homeDir: '/home/probe' },
), {
alpha: '/home/probe/custom/sessions',
});
});
test('relative provider roots never depend on the Obelisk process cwd', () => {
const registry = createProviderRegistry([
provider('alpha', '/default/alpha', '#112233'),
provider('explicit', '/fallback/explicit', '#445566', { requiresExplicitRoot: true }),
]);
assert.deepEqual(resolveProviderRoots(registry, {
providerRoots: { alpha: './alpha', explicit: '../explicit' },
}), {});
});
test('an invalid persisted root disables that provider instead of selecting its default', () => {
const runtime = createConfiguredBuiltinProviderRuntime({
providerRoots: { claude: './relative-claude' },
}, {
homeDir: '/home/probe',
baseRoots: { claude: '/default/claude' },
});
const claude = runtime.registry.get('claude');
let issue;
assert.equal(runtime.roots.claude, undefined);
assert.equal(claude.descriptor.requiresExplicitRoot, true);
assert.deepEqual(claude.watchRoots('/default/claude'), []);
assert.deepEqual(claude.discover({
lastCursor: () => null,
reportIncompleteInventory(value) {
issue = value;
},
}), []);
assert.deepEqual(issue, {
path: '/default/claude',
error: 'Configured claude root must be absolute or start with ~',
});
});
test('malformed provider root containers cannot select defaults and are repairable', () => {
const registry = createProviderRegistry([
provider('alpha', '/default/alpha', '#112233'),
]);
assert.deepEqual(resolveProviderRoots(registry, { providerRoots: [] }), {});
assert.deepEqual(resolveProviderRoots(registry, { providerRoots: 'invalid' }), {});
const persisted = { providerRoots: [] };
assert.equal(setPersistedSetting(persisted, 'providerRoots.alpha', '/custom/alpha'), true);
assert.deepEqual(persisted.providerRoots, { alpha: '/custom/alpha' });
});
test('settings reader rejects malformed provider root containers', () => {
const directory = mkdtempSync(join(tmpdir(), 'obelisk-provider-settings-'));
const settingsPath = join(directory, 'settings.json');
try {
for (const providerRoots of [[], 'invalid']) {
writeFileSync(settingsPath, JSON.stringify({ providerRoots }));
const result = readPersistedProviderSettings(settingsPath);
assert.equal(result.ok, false);
assert.deepEqual(result.settings, {});
assert.match(result.error, /providerRoots are not an object/);
}
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
+4 -1
View File
@@ -31,6 +31,8 @@ test('raw query delegates source semantics to the registered provider', () => {
.run('alpha:message', 'alpha:session', 'alpha:agent', 'alpha'); .run('alpha:message', 'alpha:session', 'alpha:agent', 'alpha');
db.prepare('INSERT INTO subagents (agent_id,session_id,description) VALUES (?,?,?)') db.prepare('INSERT INTO subagents (agent_id,session_id,description) VALUES (?,?,?)')
.run('alpha:agent', 'alpha:session', 'agent metadata'); .run('alpha:agent', 'alpha:session', 'agent metadata');
db.prepare('INSERT INTO index_state (jsonl_path,mtime,lines_processed,cursor) VALUES (?,?,?,?)')
.run('/alpha/session.data', 10, 1, 'committed-alpha-cursor');
const result = createQueryApi(db, { providerRegistry: registry }).raw('alpha:message', { const result = createQueryApi(db, { providerRegistry: registry }).raw('alpha:message', {
offset: 2, offset: 2,
@@ -38,11 +40,12 @@ test('raw query delegates source semantics to the registered provider', () => {
}); });
assert.deepEqual(result, { assert.deepEqual(result, {
text: '2345', totalLength: 10, offset: 2, limit: 4, hasMore: true, text: '2345', totalLength: 10, offset: 2, limit: 4, hasMore: true, visibility: 'visible',
}); });
assert.equal(calls.length, 1); assert.equal(calls.length, 1);
assert.equal(calls[0].source, 'alpha'); assert.equal(calls[0].source, 'alpha');
assert.equal(calls[0].session.id, 'alpha:session'); assert.equal(calls[0].session.id, 'alpha:session');
assert.equal(calls[0].cursor, 'committed-alpha-cursor');
assert.equal(calls[0].subagent.description, 'agent metadata'); assert.equal(calls[0].subagent.description, 'agent metadata');
db.close(); db.close();
}); });
+287 -7
View File
@@ -42,7 +42,8 @@ function searchDb() {
CREATE TABLE messages ( CREATE TABLE messages (
uuid TEXT PRIMARY KEY, session_id TEXT, text TEXT, role TEXT, uuid TEXT PRIMARY KEY, session_id TEXT, text TEXT, role TEXT,
timestamp TEXT, model TEXT, cwd TEXT, content_type TEXT, timestamp TEXT, model TEXT, cwd TEXT, content_type TEXT,
is_meta INTEGER DEFAULT 0, source TEXT DEFAULT 'claude' is_meta INTEGER DEFAULT 0, visibility TEXT DEFAULT 'visible',
source TEXT DEFAULT 'claude'
); );
CREATE VIRTUAL TABLE messages_fts USING fts5( CREATE VIRTUAL TABLE messages_fts USING fts5(
uuid UNINDEXED, session_id UNINDEXED, text, uuid UNINDEXED, session_id UNINDEXED, text,
@@ -54,13 +55,16 @@ function searchDb() {
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
`).run('sid-search', 'Search session', 'quiet-zero', '2026-06-10T10:00:00Z'); `).run('sid-search', 'Search session', 'quiet-zero', '2026-06-10T10:00:00Z');
const insert = db.prepare(` const insert = db.prepare(`
INSERT INTO messages (uuid, session_id, text, role, timestamp, model, cwd, content_type, is_meta) INSERT INTO messages (uuid, session_id, text, role, timestamp, model, cwd, content_type, is_meta, visibility)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`); `);
insert.run('msg-meta', 'sid-search', 'needle injected caveat', 'user', '2026-06-10T10:00:30Z', null, '/tmp/quiet-zero', 'text', 1); insert.run('msg-meta', 'sid-search', 'needle injected caveat', 'user', '2026-06-10T10:00:30Z', null, '/tmp/quiet-zero', 'text', 1, 'visible');
insert.run('msg-text', 'sid-search', 'needle visible reply', 'assistant', '2026-06-10T10:01:00Z', 'claude-opus', '/tmp/quiet-zero', 'text', 0); insert.run('msg-text', 'sid-search', 'needle visible reply', 'assistant', '2026-06-10T10:01:00Z', 'claude-opus', '/tmp/quiet-zero', 'text', 0, 'visible');
insert.run('msg-meta-near', 'sid-search', '<command-name>/exit</command-name>', 'user', '2026-06-10T10:01:30Z', null, '/tmp/quiet-zero', 'text', 1); insert.run('msg-meta-near', 'sid-search', '<command-name>/exit</command-name>', 'user', '2026-06-10T10:01:30Z', null, '/tmp/quiet-zero', 'text', 1, 'visible');
insert.run('msg-thinking', 'sid-search', 'nearby reasoning trace', 'assistant', '2026-06-10T10:02:00Z', 'claude-opus', '/tmp/quiet-zero', 'thinking', 0); insert.run('msg-thinking', 'sid-search', 'nearby reasoning trace', 'assistant', '2026-06-10T10:02:00Z', 'claude-opus', '/tmp/quiet-zero', 'thinking', 0, 'visible');
insert.run('msg-inactive', 'sid-search', 'needle superseded experiment', 'assistant', '2026-06-10T10:02:30Z', 'claude-opus', '/tmp/quiet-zero', 'text', 0, 'inactive');
insert.run('msg-inactive-meta', 'sid-search', 'needle superseded injected', 'user', '2026-06-10T10:02:40Z', null, '/tmp/quiet-zero', 'text', 1, 'inactive');
insert.run('msg-hidden', 'sid-search', 'needle abandoned branch', 'assistant', '2026-06-10T10:03:00Z', 'claude-opus', '/tmp/quiet-zero', 'text', 0, 'hidden');
db.exec("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')"); db.exec("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')");
return db; return db;
} }
@@ -102,16 +106,292 @@ test('search and thread omit meta messages by default and expose them on request
const withMeta = api.search('injected', { includeMeta: true, limit: 5 }); const withMeta = api.search('injected', { includeMeta: true, limit: 5 });
assert.equal(withMeta[0].message.uuid, 'msg-meta'); assert.equal(withMeta[0].message.uuid, 'msg-meta');
assert.equal(withMeta[0].message.is_meta, 1); assert.equal(withMeta[0].message.is_meta, 1);
assert.deepEqual(api.search('abandoned', { includeMeta: true, limit: 5 }), []);
assert.deepEqual(api.thread('sid-search').map(m => m.uuid), ['msg-text', 'msg-thinking']); assert.deepEqual(api.thread('sid-search').map(m => m.uuid), ['msg-text', 'msg-thinking']);
assert.deepEqual( assert.deepEqual(
api.thread('sid-search', { includeMeta: true }).map(m => m.uuid), api.thread('sid-search', { includeMeta: true }).map(m => m.uuid),
['msg-meta', 'msg-text', 'msg-meta-near', 'msg-thinking'], ['msg-meta', 'msg-text', 'msg-meta-near', 'msg-thinking'],
); );
assert.deepEqual(
api.thread('sid-search', { includeInactive: true }).map(m => [m.uuid, m.visibility]),
[
['msg-text', 'visible'],
['msg-thinking', 'visible'],
['msg-inactive', 'inactive'],
],
);
db.close(); db.close();
}); });
test('inactive search is opt-in, orthogonal to meta filtering, and always labeled', () => {
const db = searchDb();
const api = createQueryApi(db);
assert.deepEqual(api.search('superseded', { limit: 5 }), []);
const inactive = api.search('superseded', { includeInactive: true, limit: 5 });
assert.deepEqual(inactive.map(row => [row.message.uuid, row.message.visibility]), [
['msg-inactive', 'inactive'],
]);
assert.equal(
inactive[0].context.every(row => row.visibility === 'visible' || row.visibility === 'inactive'),
true,
);
const withMeta = api.search('superseded', {
includeInactive: true,
includeMeta: true,
limit: 5,
});
assert.deepEqual(
withMeta.map(row => [row.message.uuid, row.message.visibility]).sort(),
[
['msg-inactive', 'inactive'],
['msg-inactive-meta', 'inactive'],
],
);
assert.deepEqual(api.search('abandoned', { includeInactive: true, includeMeta: true }), []);
db.close();
});
test('context and trace reject hidden targets and omit hidden ancestors', () => {
const db = new DatabaseSync(':memory:');
db.exec(SCHEMA);
db.prepare('INSERT INTO sessions (id,title,source) VALUES (?,?,?)')
.run('sid-chain', 'Visibility chain', 'pi');
const insert = db.prepare(`
INSERT INTO messages (
uuid,session_id,type,parent_uuid,role,text,timestamp,visibility,source
) VALUES (?,?,?,?,?,?,?,?,?)
`);
insert.run('visible-root', 'sid-chain', 'user', null, 'user', 'root', '2026-08-02T10:00:00Z', 'visible', 'pi');
insert.run('hidden-parent', 'sid-chain', 'assistant', 'visible-root', 'assistant', 'secret', '2026-08-02T10:00:01Z', 'hidden', 'pi');
insert.run('visible-child', 'sid-chain', 'user', 'hidden-parent', 'user', 'continue', '2026-08-02T10:00:02Z', 'visible', 'pi');
insert.run('inactive-child', 'sid-chain', 'assistant', 'visible-root', 'assistant', 'superseded', '2026-08-02T10:00:03Z', 'inactive', 'pi');
const api = createQueryApi(db);
assert.equal(api.context('hidden-parent'), null);
assert.equal(api.context('hidden-parent', { includeInactive: true }), null);
assert.deepEqual(api.trace('hidden-parent'), []);
assert.deepEqual(api.trace('hidden-parent', { includeInactive: true }), []);
assert.equal(api.context('inactive-child'), null);
assert.deepEqual(api.trace('inactive-child'), []);
assert.deepEqual(
api.context('inactive-child', { includeInactive: true }).parentChain
.map(message => [message.uuid, message.visibility]),
[['visible-root', 'visible']],
);
assert.deepEqual(
api.trace('inactive-child', { includeInactive: true })
.map(message => [message.uuid, message.visibility]),
[
['visible-root', 'visible'],
['inactive-child', 'inactive'],
],
);
assert.deepEqual(
api.context('visible-child').parentChain.map(message => message.uuid),
['visible-root'],
);
assert.deepEqual(
api.trace('visible-child').map(message => message.uuid),
['visible-root', 'visible-child'],
);
db.close();
});
test('raw rejects hidden targets and labels explicitly included inactive evidence', () => {
const db = searchDb();
const providerRegistry = {
raw: ({ messageUuid }) => ({
text: `raw:${messageUuid}`,
totalLength: `raw:${messageUuid}`.length,
}),
};
const api = createQueryApi(db, { providerRegistry });
assert.equal(api.raw('msg-hidden'), null);
assert.equal(api.raw('msg-hidden', { includeInactive: true }), null);
assert.equal(api.raw('msg-inactive'), null);
assert.deepEqual(
api.raw('msg-inactive', { includeInactive: true }),
{
text: 'raw:msg-inactive',
totalLength: 16,
offset: 0,
limit: 10000,
hasMore: false,
visibility: 'inactive',
},
);
assert.equal(api.raw('msg-text').text, 'raw:msg-text');
assert.equal(api.raw('msg-text').visibility, 'visible');
db.close();
});
test('failures nextMessages does not leak hidden branch messages', () => {
const db = new DatabaseSync(':memory:');
db.exec(SCHEMA);
db.prepare('INSERT INTO sessions (id,title,source) VALUES (?,?,?)')
.run('sid-failure', 'Failure branch', 'pi');
const insertMessage = db.prepare(`
INSERT INTO messages (uuid,session_id,type,role,text,timestamp,visibility,source)
VALUES (?,?,?,?,?,?,?,?)
`);
insertMessage.run('failure-result', 'sid-failure', 'user', 'toolResult', 'failed', '2026-08-02T10:00:00Z', 'visible', 'pi');
insertMessage.run('hidden-next', 'sid-failure', 'assistant', 'assistant', 'abandoned', '2026-08-02T10:00:01Z', 'hidden', 'pi');
insertMessage.run('inactive-next', 'sid-failure', 'assistant', 'assistant', 'superseded', '2026-08-02T10:00:02Z', 'inactive', 'pi');
insertMessage.run('visible-next', 'sid-failure', 'assistant', 'assistant', 'recovered', '2026-08-02T10:00:03Z', 'visible', 'pi');
db.prepare(`
INSERT INTO tool_calls (id,message_uuid,session_id,name,input_json)
VALUES (?,?,?,?,?)
`).run('call-failure', 'failure-result', 'sid-failure', 'read', '{}');
db.prepare(`
INSERT INTO tool_results (tool_use_id,message_uuid,session_id,content,is_error)
VALUES (?,?,?,?,?)
`).run('call-failure', 'failure-result', 'sid-failure', 'failed', 1);
const row = createQueryApi(db).failures('sid-failure')[0];
assert.deepEqual(row.nextMessages.map(message => message.uuid), ['visible-next']);
assert.equal(row.visibility, 'visible');
assert.deepEqual(
createQueryApi(db).failures({ sessionId: 'sid-failure', includeInactive: true })[0]
.nextMessages.map(message => [message.uuid, message.visibility]),
[
['inactive-next', 'inactive'],
['visible-next', 'visible'],
],
);
db.close();
});
test('failures gates both result and linked call message visibility', () => {
const db = new DatabaseSync(':memory:');
db.exec(SCHEMA);
db.prepare('INSERT INTO sessions (id,title,source) VALUES (?,?,?)')
.run('sid-edge-visibility', 'Tool edge visibility', 'pi');
const insertMessage = db.prepare(`
INSERT INTO messages (uuid,session_id,type,role,text,timestamp,visibility,source)
VALUES (?,?,?,?,?,?,?,?)
`);
const insertCall = db.prepare(`
INSERT INTO tool_calls (id,message_uuid,session_id,name,input_json,file_path)
VALUES (?,?,?,?,?,?)
`);
const insertResult = db.prepare(`
INSERT INTO tool_results (tool_use_id,message_uuid,session_id,content,is_error)
VALUES (?,?,?,?,?)
`);
for (const [index, callVisibility] of ['visible', 'inactive', 'hidden'].entries()) {
const callId = `call-${callVisibility}`;
insertMessage.run(
`message-${callVisibility}`,
'sid-edge-visibility',
'assistant',
'assistant',
null,
`2026-08-02T10:00:0${index * 2}Z`,
callVisibility,
'pi',
);
insertMessage.run(
`result-${callVisibility}`,
'sid-edge-visibility',
'user',
'toolResult',
`failed-${callVisibility}`,
`2026-08-02T10:00:0${index * 2 + 1}Z`,
'visible',
'pi',
);
insertCall.run(
callId,
`message-${callVisibility}`,
'sid-edge-visibility',
'read',
JSON.stringify({ path: `/${callVisibility}` }),
`/${callVisibility}`,
);
insertResult.run(
callId,
`result-${callVisibility}`,
'sid-edge-visibility',
`failed-${callVisibility}`,
1,
);
}
const api = createQueryApi(db);
assert.deepEqual(
api.failures('sid-edge-visibility').map(record => record.toolCall.id),
['call-visible'],
);
assert.deepEqual(
api.failures({ sessionId: 'sid-edge-visibility', includeInactive: true })
.map(record => record.toolCall.id)
.sort(),
['call-inactive', 'call-visible'],
);
db.close();
});
test('summaries, file history, and failures expose inactive rows only on request', () => {
const db = new DatabaseSync(':memory:');
db.exec(SCHEMA);
db.prepare('INSERT INTO sessions (id,title,source) VALUES (?,?,?)')
.run('sid-structured', 'Structured visibility', 'pi');
const insertMessage = db.prepare(`
INSERT INTO messages (uuid,session_id,type,role,text,timestamp,visibility,source)
VALUES (?,?,?,?,?,?,?,?)
`);
const insertCall = db.prepare(`
INSERT INTO tool_calls (id,message_uuid,session_id,name,input_json,file_path)
VALUES (?,?,?,?,?,?)
`);
const insertResult = db.prepare(`
INSERT INTO tool_results (tool_use_id,message_uuid,session_id,content,is_error)
VALUES (?,?,?,?,?)
`);
const insertSummary = db.prepare(`
INSERT INTO summaries (id,session_id,timestamp,source,content,visibility)
VALUES (?,?,?,?,?,?)
`);
for (const [index, visibility] of ['visible', 'inactive', 'hidden'].entries()) {
const suffix = visibility;
const uuid = `message-${suffix}`;
const callId = `call-${suffix}`;
const timestamp = `2026-08-02T10:00:0${index}Z`;
insertMessage.run(uuid, 'sid-structured', 'user', 'toolResult', suffix, timestamp, visibility, 'pi');
insertCall.run(callId, uuid, 'sid-structured', 'read', '{}', '/tmp/visibility.ts');
insertResult.run(callId, uuid, 'sid-structured', `failed-${suffix}`, 1);
insertSummary.run(`summary-${suffix}`, 'sid-structured', timestamp, 'pi:branch_summary', suffix, visibility);
}
const api = createQueryApi(db);
assert.deepEqual(api.fileHistory('/tmp/visibility.ts').map(row => row.visibility), ['visible']);
assert.deepEqual(
api.fileHistory('/tmp/visibility.ts', { includeInactive: true }).map(row => row.visibility),
['visible', 'inactive'],
);
assert.deepEqual(api.failures('sid-structured').map(row => row.visibility), ['visible']);
assert.deepEqual(
api.failures({ sessionId: 'sid-structured', includeInactive: true })
.map(row => [row.visibility, row.result.visibility]),
[
['inactive', 'inactive'],
['visible', 'visible'],
],
);
assert.deepEqual(api.summaries('sid-structured').map(row => row.visibility), ['visible']);
assert.deepEqual(
api.summaries({ sessionId: 'sid-structured', includeInactive: true })
.map(row => row.visibility),
['inactive', 'visible'],
);
db.close();
});
test('memories follows list-helper scalar opts and filters by query within scope', () => { test('memories follows list-helper scalar opts and filters by query within scope', () => {
const db = memoryDb(); const db = memoryDb();
const api = createQueryApi(db); const api = createQueryApi(db);
+108
View File
@@ -31,6 +31,44 @@ test('runtime query scripts cannot call attune helpers', () => {
}); });
}); });
test('malformed Obelisk settings skip refresh without disabling provider-backed queries', () => {
const home = tempHome();
const projectDir = join(home, '.claude', 'projects', '-tmp-settings-recovery');
const transcriptPath = join(projectDir, 'settings-recovery.jsonl');
const scriptPath = join(home, 'query.mjs');
mkdirSync(projectDir, { recursive: true });
writeFileSync(transcriptPath, `${JSON.stringify({
uuid: 'settings-recovery-user',
type: 'user',
timestamp: '2026-08-04T10:00:00.000Z',
cwd: '/tmp/settings-recovery',
message: { role: 'user', content: 'settings recovery evidence' },
})}\n`);
writeFileSync(scriptPath, `
const hit = search('settings recovery evidence', { limit: 1 })[0];
return {
uuid: hit?.message.uuid ?? null,
raw: hit ? raw(hit.message.uuid)?.text ?? null : null
};
`);
const indexed = runRuntime(['--query', scriptPath], { home });
assert.equal(indexed.status, 0, indexed.stderr || indexed.stdout);
assert.equal(JSON.parse(indexed.stdout).uuid, 'settings-recovery-user');
writeFileSync(join(home, '.obelisk', 'settings.json'), '{broken');
const recovered = runRuntime(['--query', scriptPath], { home });
assert.equal(recovered.status, 0, recovered.stderr || recovered.stdout);
assert.match(recovered.stderr, /index refresh skipped/);
assert.equal(JSON.parse(recovered.stdout).uuid, 'settings-recovery-user');
assert.match(JSON.parse(recovered.stdout).raw, /settings recovery evidence/);
const rebuild = runRuntime(['--build'], { home });
assert.equal(rebuild.status, 1);
assert.match(JSON.parse(rebuild.stdout).error, /settings_unavailable/);
assert.match(JSON.parse(rebuild.stdout).error, /Unable to read Obelisk settings/);
});
test('runtime attune scripts expose only memory mutation helpers', () => { test('runtime attune scripts expose only memory mutation helpers', () => {
const home = tempHome(); const home = tempHome();
const memoryPath = join(home, 'memory.md'); const memoryPath = join(home, 'memory.md');
@@ -208,6 +246,76 @@ test('runtime indexes Codex root sessions into the shared query helpers', () =>
assert.ok(payload.overviewSources.some(s => s.source === 'codex' && s.session_count === 1)); assert.ok(payload.overviewSources.some(s => s.source === 'codex' && s.session_count === 1));
}); });
test('runtime raw lookup uses the configured Codex root for child sessions', () => {
const home = tempHome();
const codexDir = join(home, 'custom-codex');
const codexSessionDir = join(codexDir, 'sessions', '2026', '08', '04');
const parentId = '019ec6ee-cebd-7431-9c93-ceec89a98a5f';
const childId = '019ec739-9f75-7a02-ba2a-371986e23823';
mkdirSync(codexSessionDir, { recursive: true });
mkdirSync(join(home, '.obelisk'), { recursive: true });
writeFileSync(join(home, '.obelisk', 'settings.json'), JSON.stringify({
providerRoots: { codex: codexDir },
}));
writeFileSync(join(codexSessionDir, `a-parent-${parentId}.jsonl`), [
JSON.stringify({
timestamp: '2026-08-04T10:00:00.000Z',
type: 'session_meta',
payload: {
id: parentId,
timestamp: '2026-08-04T10:00:00.000Z',
cwd: '/tmp/custom-codex-runtime',
source: 'cli',
},
}),
'',
].join('\n'));
writeFileSync(join(codexSessionDir, `b-child-${childId}.jsonl`), [
JSON.stringify({
timestamp: '2026-08-04T10:00:01.000Z',
type: 'session_meta',
payload: {
id: childId,
timestamp: '2026-08-04T10:00:01.000Z',
cwd: '/tmp/custom-codex-runtime',
source: {
subagent: {
thread_spawn: {
parent_thread_id: parentId,
agent_nickname: 'Plato',
agent_role: 'worker',
},
},
},
},
}),
JSON.stringify({
timestamp: '2026-08-04T10:00:02.000Z',
type: 'event_msg',
payload: {
type: 'user_message',
message: 'custom Codex child raw sentinel',
images: [],
local_images: [],
text_elements: [],
},
}),
'',
].join('\n'));
const scriptPath = join(home, 'query.mjs');
writeFileSync(
scriptPath,
`return raw(${JSON.stringify(`codex:${childId}:000002`)}, { limit: 1000 });`,
);
const result = runRuntime(['--query', scriptPath], { home });
assert.equal(result.status, 0, result.stderr || result.stdout);
const raw = JSON.parse(result.stdout);
assert.match(raw.text, /custom Codex child raw sentinel/);
assert.equal(raw.visibility, 'visible');
});
test('runtime skips Codex guardian review threads', () => { test('runtime skips Codex guardian review threads', () => {
const home = tempHome(); const home = tempHome();
const codexSessionDir = join(home, '.codex', 'sessions', '2026', '06', '15'); const codexSessionDir = join(home, '.codex', 'sessions', '2026', '06', '15');
+62
View File
@@ -103,6 +103,68 @@ test('canonical ordering is stable across provider and SQLite iteration order',
assert.deepEqual(detail.messages.map(message => message.uuid), ['a', 'b']); assert.deepEqual(detail.messages.map(message => message.uuid), ['a', 'b']);
}); });
test('session detail remains active-only across direct and persisted visibility values', () => {
const message = (uuid, visibility) => ({
kind: 'message',
uuid,
session_id: 'session',
type: 'user',
parent_uuid: null,
timestamp: `2026-06-10T10:00:0${uuid.length}Z`,
role: 'user',
text: uuid,
content_type: 'text',
is_meta: 0,
visibility,
model: null,
is_sidechain: 0,
agent_id: null,
input_tokens: null,
output_tokens: null,
cwd: null,
skill: null,
source: 'pi',
});
const summary = (id, visibility) => ({
kind: 'summary',
id,
session_id: 'session',
timestamp: null,
source: 'pi:branch_summary',
content: id,
visibility,
input_tokens: null,
output_tokens: null,
});
const direct = assembleSessionDetail([
message('visible', 'visible'),
message('inactive', 'inactive'),
message('hidden', 'hidden'),
summary('visible-summary', 'visible'),
summary('inactive-summary', 'inactive'),
summary('hidden-summary', 'hidden'),
]);
assert.deepEqual(direct.messages.map(row => row.text), ['visible']);
assert.deepEqual(direct.summaries.map(row => row.content), ['visible-summary']);
const persisted = assembleSessionDetail({
messages: [
{ ...message('visible', 'visible'), kind: undefined },
{ ...message('inactive', 'inactive'), kind: undefined },
{ ...message('hidden', 'hidden'), kind: undefined },
{ ...message('unknown', 'future-state'), kind: undefined },
],
summaries: [
{ ...summary('visible-summary', 'visible'), kind: undefined },
{ ...summary('inactive-summary', 'inactive'), kind: undefined },
{ ...summary('hidden-summary', 'hidden'), kind: undefined },
{ ...summary('unknown-summary', 'future-state'), kind: undefined },
],
});
assert.deepEqual(persisted.messages.map(row => row.text), ['visible']);
assert.deepEqual(persisted.summaries.map(row => row.content), ['visible-summary']);
});
test('direct session assembly rejects an incomplete provider delta', () => { test('direct session assembly rejects an incomplete provider delta', () => {
assert.throws(() => assembleSessionDetail([{ assert.throws(() => assembleSessionDetail([{
kind: 'session', id: 'session', title: null, project: null, kind: 'session', id: 'session', title: null, project: null,