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)
[![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>
@@ -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.
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
@@ -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
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
@@ -155,12 +168,7 @@ npm ci
npm run dev
```
`electron-vite` starts the renderer dev server and launches Electron. On first
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.
`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.
### Debug the app
@@ -184,7 +192,7 @@ run `npm ci` again.
| 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 |
| **Tool calls** | every tool invocation | Tool name, input, file paths |
| **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
│ │ ├── claude.ts # Claude Code adapter (line-incremental)
│ │ ├── 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
│ ├── persist.ts # Binding-agnostic record writer (upsert/merge)
│ ├── tx.ts # Write transaction + connection config
+138 -34
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 { migrateCoreSchemaColumns } from '../../../packages/core/src/schema-migrations.ts';
import { createBuiltinProviderRegistry } from '../../../packages/core/src/providers/builtins.ts';
import {
createConfiguredBuiltinProviderRuntime,
readPersistedProviderSettings,
} from '../../../packages/core/src/provider-settings.ts';
import {
buildSourceCatalog,
resolveProviderRoots,
setPersistedSetting,
type ProviderSourceIssue,
} from './provider-settings.ts';
import type {
SessionPatchCursor,
@@ -66,6 +70,8 @@ const DEFAULT_CODEX_DIR = path.join(os.homedir(), '.codex');
let db;
let indexerService;
let indexerWorker;
let latestSourceIssues: ProviderSourceIssue[] = [];
let latestSettingsError: string | null = null;
type WriterLeaseMode = 'acquire' | 'caller-held';
@@ -78,16 +84,19 @@ function acquireAppWriterLease(dbPath: string, waitMs = 0) {
}
function getRuntimePaths(persisted = loadPersistedSettings()) {
const defaultRegistry = createBuiltinProviderRegistry({
const runtime = createConfiguredBuiltinProviderRuntime(persisted, {
baseRoots: {
claude: DEFAULT_CLAUDE_DIR,
codex: DEFAULT_CODEX_DIR,
},
});
const providerRoots = resolveProviderRoots(defaultRegistry, persisted);
const providerRegistry = createBuiltinProviderRegistry(providerRoots);
const providerRoots = runtime.roots;
const providerRegistry = runtime.registry;
const claudeDir = providerRoots['claude'] ?? DEFAULT_CLAUDE_DIR;
const codexDir = providerRoots['codex'] ?? DEFAULT_CODEX_DIR;
return {
providerRoots,
providerSettings: persisted,
providerRegistry,
claudeDir,
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)
? [...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()) {
win.webContents.send('obelisk:index-updated', payload);
for (const sessionId of affectedSessionIds) {
@@ -235,6 +270,7 @@ function appendWhere(sql, params, clause) {
function startIndexerService({ buildOnStart = false } = {}) {
const paths = getRuntimePaths();
if (latestSettingsError !== null) return null;
migrateLegacyDbIfNeeded(paths);
indexerService = createIndexerService({
projectsDir: paths.projectsDir,
@@ -244,13 +280,18 @@ function startIndexerService({ buildOnStart = false } = {}) {
reason,
changedPaths,
providerRoots: paths.providerRoots,
providerSettings: paths.providerSettings,
claudeDir: paths.claudeDir,
codexDir: paths.codexDir,
projectsDir: paths.projectsDir,
dbPath: paths.dbPath,
});
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);
}
} else {
@@ -268,11 +309,11 @@ function startIndexerService({ buildOnStart = false } = {}) {
function startBackgroundResources({ runStartupBuild = false } = {}) {
if (!indexerWorker) indexerWorker = createWorkerBuildIndex();
const paths = getRuntimePaths();
migrateLegacyDbIfNeeded(paths);
if (latestSettingsError === null) migrateLegacyDbIfNeeded(paths);
openDb(paths.dbPath);
if (!indexerService) {
if (!indexerService && latestSettingsError === null) {
const service = startIndexerService({ buildOnStart: false });
if (runStartupBuild) service.runBuildNow('startup');
if (runStartupBuild) service?.runBuildNow('startup');
}
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,
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
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[];
}
function querySessionToolCalls(sessionId: string): SessionToolCallRow[] {
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[] {
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[] {
@@ -470,7 +522,10 @@ function querySessionWorkflows(sessionId: string): SessionWorkflowRow[] {
function querySessionSummaries(sessionId: string): SessionSummaryRow[] {
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 {
@@ -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,
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
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);
});
@@ -578,7 +635,7 @@ ipcMain.handle('db:getSubagentToolCalls', (_, agentId) => {
return db.prepare(`
SELECT tc.* FROM tool_calls tc
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);
});
@@ -587,7 +644,7 @@ ipcMain.handle('db:getSubagentToolResults', (_, agentId) => {
return db.prepare(`
SELECT tr.* FROM tool_results tr
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);
});
@@ -606,7 +663,7 @@ ipcMain.handle('db:getMemories', () => {
ipcMain.handle('db:getMessageFullText', (_, uuid) => {
if (!db) return null;
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 subagent = msg.agent_id
? 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
? db.prepare('SELECT * FROM workflow_agents WHERE agent_id=?').get(msg.agent_id) ?? 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 raw = paths.providerRegistry.raw({
source: msg.source || session?.source || 'claude',
messageUuid: String(uuid),
session,
agentId: msg.agent_id || null,
cursor: typeof cursorRow?.cursor === 'string' ? cursorRow.cursor : null,
subagent,
workflowAgent,
});
@@ -641,7 +702,9 @@ function querySessionFileRoots(sessionId: unknown): string[] {
const roots: string[] = [];
try {
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);
for (const row of rows) roots.push(row.cwd);
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 };
const { editorScheme } = loadPersistedSettings();
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 };
} catch {
return { opened: false, path: filePath };
@@ -705,11 +773,25 @@ ipcMain.handle('db:getUsageStats', (_, opts = {}) => {
if (!db) return { daily: [], totalTokens: 0, peakDay: null, longestTurn: null };
const sourceFilter = sourceWhereClause(opts, 'source');
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(`
${usageEvents}
SELECT DATE(timestamp) as day,
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)
${sourceSql}
GROUP BY DATE(timestamp)
@@ -717,15 +799,17 @@ ipcMain.handle('db:getUsageStats', (_, opts = {}) => {
`).all(...sourceFilter.params);
const totalTokens = db.prepare(`
${usageEvents}
SELECT SUM(COALESCE(input_tokens,0) + COALESCE(output_tokens,0)) as total
FROM messages
FROM usage_events
${sourceFilter.sql ? `WHERE ${sourceFilter.sql}` : ''}
`).get(...sourceFilter.params)?.total || 0;
const peakDay = db.prepare(`
${usageEvents}
SELECT DATE(timestamp) as day,
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)
${sourceSql}
GROUP BY DATE(timestamp)
@@ -837,15 +921,25 @@ ipcMain.handle('recap:read', (_, filename) => {
const SETTINGS_PATH = path.join(OBELISK_DIR, 'settings.json');
function loadPersistedSettings() {
try {
if (fs.existsSync(SETTINGS_PATH)) return JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf-8'));
} catch {}
return {};
const result = readPersistedProviderSettings(SETTINGS_PATH);
latestSettingsError = result.ok ? null : result.error ?? 'Obelisk settings are unavailable';
if (latestSettingsError !== null) console.warn(latestSettingsError);
return result.settings;
}
function savePersistedSettings(settings) {
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', () => {
@@ -878,11 +972,13 @@ ipcMain.handle('settings:get', () => {
registry: providerRegistry,
roots: providerRoots,
stats: sourceStats,
sourceIssues: latestSourceIssues,
pathExists: fs.existsSync,
});
const sessionCount = sources.reduce((sum, source) => sum + source.sessionCount, 0);
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 {
version: app.getVersion(),
@@ -898,7 +994,7 @@ ipcMain.handle('settings:get', () => {
sessionCount,
lastIndexed,
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 (value === false && indexerService) {
await stopIndexerServiceAndWait();
} else if (value !== false && indexerService) {
await stopIndexerServiceAndWait();
} else if (value !== false) {
if (indexerService) await stopIndexerServiceAndWait();
startIndexerService({ buildOnStart: false });
}
}
@@ -928,7 +1024,7 @@ ipcMain.handle('settings:set', async (_, key, value) => {
if (persisted.autoRefresh !== false) {
startIndexerService({ buildOnStart: true });
}
notifyIndexUpdated();
notifyIndexUpdated({ inventoryIssues: [] });
}
return true;
});
@@ -951,6 +1047,7 @@ ipcMain.handle('settings:revealPath', (_, p) => {
ipcMain.handle('settings:rebuildIndex', async () => {
if (!indexerWorker) return null;
const persisted = loadPersistedSettings();
if (latestSettingsError !== null) throw new Error(latestSettingsError);
const paths = getRuntimePaths(persisted);
const tempDbPath = rebuildTempDbPath(paths.dbPath);
const shouldRestartWatcher = persisted.autoRefresh !== false;
@@ -977,6 +1074,9 @@ ipcMain.handle('settings:rebuildIndex', async () => {
skipped: 0,
skippedFiles: [],
deferred: true,
complete: false,
incompleteProviders: [],
inventoryIssues: [],
reason: 'writer_busy',
};
}
@@ -985,6 +1085,7 @@ ipcMain.handle('settings:rebuildIndex', async () => {
reason: 'manual-rebuild',
force: true,
providerRoots: paths.providerRoots,
providerSettings: paths.providerSettings,
claudeDir: paths.claudeDir,
codexDir: paths.codexDir,
projectsDir: paths.projectsDir,
@@ -993,7 +1094,10 @@ ipcMain.handle('settings:rebuildIndex', async () => {
writerLeasePath,
writerLeaseMode: 'caller-held',
});
if (result?.deferred) return result;
if (result?.deferred || result?.complete !== true) {
notifyIndexUpdated(result);
return result;
}
closeDb();
replaceDbWithTemp(tempDbPath, paths.dbPath);
openDb(paths.dbPath, { writerLeaseMode: 'caller-held' });
+64 -11
View File
@@ -21,10 +21,17 @@ interface Timers {
interface Watcher {
close(): unknown;
refreshMissingRoots?(): boolean;
}
interface IndexerBuildResult {
deferred?: boolean;
complete?: boolean;
inventoryIssues?: Array<{
provider: string;
path: string;
error: string;
}>;
}
type IndexerBuild = (args: {
@@ -42,7 +49,7 @@ interface IndexerServiceOptions {
deferredRetryMs?: number;
buildIndex?: IndexerBuild;
writeHeartbeat?: () => unknown;
watchProjects?: (onChange: (changedPath: string) => void) => Watcher | null;
watchProjects?: (onChange: (changedPath?: string) => void) => Watcher | null;
chokidar?: any;
timers?: Timers;
logger?: { warn?: (msg: string) => void };
@@ -71,10 +78,11 @@ function createIndexerService({
if (typeof buildIndex !== 'function') throw new Error('createIndexerService() requires buildIndex');
const watch = watchProjects || ((onChange) => {
const roots = [...new Set((Array.isArray(watchDirs) ? watchDirs : [watchDirs]).filter(Boolean))];
const existingRoots = roots.filter(root => fs.existsSync(root));
if (!existingRoots.length) return null;
if (!roots.length) return null;
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 name = filename ? String(filename) : '';
if (!name || name.endsWith('.jsonl') || name.endsWith('.json')) {
@@ -102,11 +110,25 @@ function createIndexerService({
logger.warn?.(`Obelisk watcher failed: ${(error as Error).message}`);
});
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 {
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 lastReason: string | null = null;
let changedPaths = new Set<string>();
let fullInventoryPending = false;
let idlePromise = Promise.resolve();
const requestFullInventory = () => {
fullInventoryPending = true;
changedPaths.clear();
};
const addChangedPath = (changedPath?: string | string[]) => {
if (Array.isArray(changedPath)) {
for (const p of changedPath) addChangedPath(p);
return;
}
const name = changedPath ? String(changedPath) : '';
if (name) changedPaths.add(name);
if (name && !fullInventoryPending) changedPaths.add(name);
};
const takeChangedPaths = () => {
if (fullInventoryPending) {
fullInventoryPending = false;
changedPaths.clear();
return undefined;
}
if (!changedPaths.size) return undefined;
const paths = [...changedPaths];
changedPaths = new Set();
@@ -160,8 +193,16 @@ function createIndexerService({
const buildChangedPaths = takeChangedPaths();
idlePromise = (async () => {
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) {
addChangedPath(buildChangedPaths);
if (buildChangedPaths === undefined) requestFullInventory();
else addChangedPath(buildChangedPaths);
if (!stopped && !deferredRetryTimer) {
deferredRetryTimer = timers.setTimeout(() => {
deferredRetryTimer = null;
@@ -189,7 +230,8 @@ function createIndexerService({
const scheduleBuild = (reason = "change", changedPath: string | undefined = undefined) => {
if (stopped) return;
addChangedPath(changedPath);
if (changedPath === undefined) requestFullInventory();
else addChangedPath(changedPath);
lastReason = reason;
if (running) pending = true;
if (deferredRetryTimer) timers.clearTimeout(deferredRetryTimer);
@@ -209,14 +251,25 @@ function createIndexerService({
}, debounceMs);
};
const scheduleWatchRetry = () => {
if (stopped || watchRetryTimer) return;
watchRetryTimer = timers.setTimeout(() => {
watchRetryTimer = null;
if (!watcher) {
startWatching();
return;
}
if (watcher.refreshMissingRoots?.() === false) scheduleWatchRetry();
}, watchRetryMs);
};
const startWatching = () => {
if (stopped || watcher) return;
watcher = watch((changedPath) => scheduleBuild('watch', changedPath));
if (!watcher) {
watchRetryTimer = timers.setTimeout(() => {
watchRetryTimer = null;
startWatching();
}, watchRetryMs);
scheduleWatchRetry();
} else if (watcher.refreshMissingRoots?.() === false) {
scheduleWatchRetry();
}
};
+191 -59
View File
@@ -5,10 +5,18 @@ import { fileURLToPath } from 'node:url';
import Database from 'better-sqlite3';
import { createBuiltinProviderRegistry } from '../../../packages/core/src/providers/builtins.ts';
import type { ProviderRegistry } from '../../../packages/core/src/providers/registry.ts';
import {
createConfiguredBuiltinProviderRuntime,
type PersistedProviderSettings,
} from '../../../packages/core/src/provider-settings.ts';
import {
createProviderIndexPlan,
indexProviderPlan,
indexProviderPlanStrict,
ProviderIndexFailure,
writeProviderIndexMarkers,
type ProviderInventoryIssue,
type ProviderSessionProvenance,
} from '../../../packages/core/src/provider-indexing.ts';
import { runWriteTransaction, configureConnection, betterSqliteTransactionAdapter } from '../../../packages/core/src/tx.ts';
import { migrateCoreSchemaColumns } from '../../../packages/core/src/schema-migrations.ts';
@@ -48,16 +56,17 @@ function openIndexDb({ dbPath = DEFAULT_DB_PATH, schemaPath = resolveSchemaPath(
return db;
}
function copyMemoriesFromDb(db, sourceDbPath) {
if (!sourceDbPath || !fs.existsSync(sourceDbPath)) return false;
function copyPreservedDataFromDb(db, sourceDbPath): ProviderSessionProvenance[] {
if (!sourceDbPath || !fs.existsSync(sourceDbPath)) {
throw new Error(`Preserved Obelisk database is unavailable: ${sourceDbPath}`);
}
db.prepare('ATTACH DATABASE ? AS previous_obelisk').run(sourceDbPath);
try {
const hasMemories = db.prepare(`
SELECT name FROM previous_obelisk.sqlite_master
WHERE type='table' AND name='memories'
`).get();
if (!hasMemories) return false;
if (hasMemories) {
const sourceColumns = new Set(
db.prepare('PRAGMA previous_obelisk.table_info(memories)').all().map(column => column.name),
);
@@ -81,7 +90,34 @@ function copyMemoriesFromDb(db, sourceDbPath) {
INSERT OR REPLACE INTO memories (${targetColumns.join(',')})
SELECT ${selectList} FROM previous_obelisk.memories
`);
return true;
}
const hasSessions = db.prepare(`
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),
);
if (!sessionColumns.has('id') || !sessionColumns.has('jsonl_path')) {
throw new Error(`Preserved Obelisk sessions schema is incomplete: ${sourceDbPath}`);
}
const sourceExpression = sessionColumns.has('source')
? "COALESCE(source, 'claude')"
: "'claude'";
return db.prepare(`
SELECT id, jsonl_path, ${sourceExpression} AS source
FROM previous_obelisk.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),
}));
} finally {
db.exec('DETACH DATABASE previous_obelisk');
}
@@ -190,6 +226,7 @@ function writeHeartbeat({
interface BuildIndexOptions {
providerRoots?: Record<string, string>;
providerSettings?: PersistedProviderSettings;
providerRegistry?: ProviderRegistry;
claudeDir?: string;
codexDir?: string;
@@ -207,6 +244,7 @@ interface BuildIndexOptions {
}
interface SkippedFile {
provider: string;
path: string;
error: string;
diagnostics?: unknown;
@@ -220,6 +258,9 @@ interface BuildIndexResult {
skipped: number;
skippedFiles: SkippedFile[];
deferred: boolean;
complete: boolean;
incompleteProviders: string[];
inventoryIssues: ProviderInventoryIssue[];
reason?: string;
}
@@ -234,6 +275,9 @@ function deferredBuildResult(
ftsRebuilt: false,
skipped: 0,
skippedFiles: [],
complete: false,
incompleteProviders: [],
inventoryIssues: [],
...overrides,
deferred: true,
reason,
@@ -242,6 +286,7 @@ function deferredBuildResult(
function buildIndex({
providerRoots = {},
providerSettings,
providerRegistry,
claudeDir = DEFAULT_CLAUDE_DIR,
codexDir = path.join(path.dirname(claudeDir), '.codex'),
@@ -276,13 +321,15 @@ function buildIndex({
const txDb = betterSqliteTransactionAdapter(db);
let messageFtsTriggersDropped = false;
try {
let priorSessions: ProviderSessionProvenance[] | undefined;
if (preserveDbPath && path.resolve(preserveDbPath) !== path.resolve(dbPath)) {
copyMemoriesFromDb(db, preserveDbPath);
priorSessions = copyPreservedDataFromDb(db, preserveDbPath);
}
const defaultHome = os.homedir();
const compatibilityHome = path.dirname(claudeDir);
const relocatedDefaults = Object.fromEntries(
createBuiltinProviderRegistry().catalog().map((descriptor) => {
createBuiltinProviderRegistry().catalog().flatMap((descriptor) => {
if (descriptor.requiresExplicitRoot) return [];
const relativeDefault = path.relative(defaultHome, descriptor.defaultRoot);
const root = compatibilityHome !== defaultHome
&& relativeDefault
@@ -290,7 +337,7 @@ function buildIndex({
&& !path.isAbsolute(relativeDefault)
? path.join(compatibilityHome, relativeDefault)
: descriptor.defaultRoot;
return [descriptor.id, root];
return [[descriptor.id, root]];
}),
);
const roots = {
@@ -299,8 +346,15 @@ function buildIndex({
codex: codexDir,
...providerRoots,
};
const registry = providerRegistry ?? createBuiltinProviderRegistry(roots);
const providerPlan = createProviderIndexPlan(db, registry, { force, changedPaths });
const registry = providerRegistry
?? (providerSettings === undefined
? createBuiltinProviderRegistry(roots)
: createConfiguredBuiltinProviderRuntime(providerSettings, { baseRoots: roots }).registry);
const providerPlan = createProviderIndexPlan(db, registry, {
force,
changedPaths,
priorSessions,
});
let latestSourceMtime = providerPlan.items.reduce((latest, { unit }) => {
const providerCursor = (unit.meta as { currentCursor?: unknown } | undefined)?.currentCursor;
if (typeof providerCursor === 'string') {
@@ -312,45 +366,32 @@ function buildIndex({
return latest;
}
}, 0);
try {
if (force) {
runRetryableWriteTransaction(txDb, () => {
dropMessageFtsTriggers(db);
db.prepare("DELETE FROM index_state WHERE substr(jsonl_path, 1, 2) != '__'").run();
db.prepare("DELETE FROM messages").run();
db.prepare("DELETE FROM tool_calls").run();
db.prepare("DELETE FROM tool_results").run();
db.prepare("DELETE FROM sessions").run();
db.prepare("DELETE FROM summaries").run();
db.prepare("DELETE FROM subagents").run();
db.prepare("DELETE FROM workflows").run();
db.prepare("DELETE FROM workflow_agents").run();
}, { label: 'force-cleanup' });
messageFtsTriggersDropped = true;
}
} catch (error) {
if (isBeginBusyFailure(error)) {
return deferredBuildResult('database_busy', {
const discoveredSourceMtime = latestSourceMtime;
const incompleteProviders = [...providerPlan.incompleteProviders].sort();
const inventoryIssues = [...providerPlan.inventoryIssues];
if (force && incompleteProviders.length > 0) {
return {
files: providerPlan.items.length,
latestSourceMtime,
});
}
throw error;
affectedSessionIds: [],
ftsRebuilt: false,
skipped: 0,
skippedFiles: [],
deferred: false,
complete: false,
incompleteProviders,
inventoryIssues,
reason: 'incomplete_snapshot',
};
}
const affectedSessionIds = new Set<string>();
const finalizeAffectedSessionIds = new Set<string>();
const changedMetaJsonlPaths = new Set<string>();
if (Array.isArray(changedPaths)) {
for (const changedPath of changedPaths) {
const sessionId = sessionIdFromChangedPath(projectsDir, changedPath);
const normalizedChangedPath = normalizeChangedPath(projectsDir, changedPath);
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
// commits. Workflow changes are applied during finalize, so stage those
// IDs until the finalize transaction commits. Meta files map back to their
@@ -361,23 +402,118 @@ function buildIndex({
}
}
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({
db,
plan: providerPlan,
runTransaction: (label, work) => runRetryableWriteTransaction(txDb, work, { label }),
onCommitted: ({ unit }, nextCursor) => {
if (nextCursor) latestSourceMtime = Math.max(latestSourceMtime, Number(nextCursor.split(':')[0]) || 0);
if (unit.sessionId) affectedSessionIds.add(unit.sessionId);
},
onCommitted: noteCommitted,
onError: (error, { provider, unit }) => {
if (isBeginBusyFailure(error)) return 'stop';
if (hasUnusableTransaction(error)) throw error;
skipped.push({
provider: provider.name,
path: unit.key,
error: (error as Error).message,
diagnostics: (error as { obelisk?: unknown }).obelisk,
error: error instanceof Error ? error.message : String(error),
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';
},
});
@@ -388,22 +524,13 @@ function buildIndex({
affectedSessionIds: [...affectedSessionIds],
skipped: skipped.length,
skippedFiles: skipped,
incompleteProviders,
inventoryIssues,
});
}
let ftsRebuilt = false;
// Finalize is one transaction; a failure here fails the whole build (the
// index would otherwise be left inconsistent).
// Finalize is one transaction; a failure here fails the whole build.
try {
runRetryableWriteTransaction(txDb, () => {
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' });
runRetryableWriteTransaction(txDb, () => finalize(providerResult), { label: 'finalize' });
} catch (error) {
if (isBeginBusyFailure(error)) {
return deferredBuildResult('database_busy', {
@@ -412,6 +539,8 @@ function buildIndex({
affectedSessionIds: [...affectedSessionIds],
skipped: skipped.length,
skippedFiles: skipped,
incompleteProviders,
inventoryIssues,
});
}
throw error;
@@ -425,6 +554,9 @@ function buildIndex({
skipped: skipped.length,
skippedFiles: skipped,
deferred: false,
complete: providerResult.complete,
incompleteProviders,
inventoryIssues,
};
} finally {
if (messageFtsTriggersDropped) {
+26 -23
View File
@@ -1,4 +1,5 @@
import type { ProviderRegistry } from '../../../packages/core/src/providers/registry.ts';
export { resolveProviderRoots } from '../../../packages/core/src/provider-settings.ts';
type PersistedSettings = Record<string, unknown> & {
providerRoots?: Record<string, unknown>;
@@ -9,30 +10,20 @@ interface SourceStats {
lastIndexed: string;
}
export interface ProviderSourceIssue {
readonly provider: string;
readonly path: string;
readonly error: string;
}
interface BuildSourceCatalogOptions {
registry: ProviderRegistry;
roots: Readonly<Record<string, string>>;
stats?: ReadonlyMap<string, SourceStats>;
sourceIssues?: readonly ProviderSourceIssue[];
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(
persisted: PersistedSettings,
key: string,
@@ -46,7 +37,9 @@ export function setPersistedSetting(
}
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
: {};
if (value === null) delete roots[providerId];
@@ -60,13 +53,23 @@ export function buildSourceCatalog({
registry,
roots,
stats = new Map(),
sourceIssues = [],
pathExists = () => false,
}: BuildSourceCatalogOptions) {
return registry.catalog().map((descriptor) => {
const path = roots[descriptor.id] ?? descriptor.defaultRoot;
const exists = pathExists(path);
const needsRoot = descriptor.requiresExplicitRoot === true;
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 {
id: descriptor.id,
name: descriptor.name,
@@ -78,11 +81,11 @@ export function buildSourceCatalog({
sessionCount: sourceStats.sessionCount,
lastIndexed: sourceStats.lastIndexed,
status,
statusText: !exists
statusText: needsRoot
? descriptor.rootResolutionReason ?? 'Select a session folder'
: !exists
? 'Folder not found'
: sourceStats.sessionCount > 0
? 'Connected'
: 'No sessions found',
: partialStatus ?? (sourceStats.sessionCount > 0 ? 'Connected' : 'No sessions found'),
};
});
}
+7 -1
View File
@@ -22,6 +22,7 @@ import { sourceLabel } from './source-catalog.mjs';
const router = useRouter();
const route = useRoute();
let searchTimer = null;
let stopSourceUpdates = null;
const routeSession = computed(() => {
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(() => {
window.removeEventListener('keydown', handleGlobalKeydown);
stopSourceUpdates?.();
stopSourceUpdates = null;
clearTimeout(searchTimer);
});
+6 -2
View File
@@ -20,14 +20,18 @@ const editorMenuOpen = ref(false);
const memoryCount = ref(0);
const rebuilding = ref(false);
const version = ref('');
let stopIndexUpdates = null;
onMounted(async () => {
stopIndexUpdates = window.obelisk?.onIndexUpdated?.(() => loadSettings()) ?? null;
document.addEventListener('pointerdown', closeEditorMenuOutside);
document.addEventListener('keydown', closeEditorMenuOnEscape);
await loadSettings();
});
onBeforeUnmount(() => {
stopIndexUpdates?.();
stopIndexUpdates = null;
document.removeEventListener('pointerdown', closeEditorMenuOutside);
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-status {
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.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;
}
@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.warn { color: #fbbf24; }
.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.
- **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)`,
`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
uses one session directory containing state plus multiple agent wire logs. An
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
changes. `parse` exposes an iterator as its common interface and streams when
the provider semantics permit it. An adapter may buffer one complete
`IndexUnit` when correctness requires whole-unit semantics — for example,
Codex duplicate reconciliation or Kimi `context.undo` / `context.clear`
replay. Each adapter maps its own resume/change semantics onto the existing
`mtime` and `lines_processed` cursor pair in `index_state`. The emitted
Codex duplicate reconciliation, Kimi `context.undo` / `context.clear`
replay, or Pi tree projection. Each adapter maps its own resume/change
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
detail assembly; see ADR-0007.
- **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
SQLite is one serialization adapter for it. Provider-only concepts are either
projected lossily into that language or ignored. The registry,
not provider switches, drives both indexers, watcher roots, persisted source
roots, source catalog/UI labels and colors, and raw-record routing. Adding Pi
therefore changes the Pi adapter, its registration, and its conformance tests;
the shared schema, persist layer, indexers, settings, query API, and renderer do
not acquire Pi-specific branches.
projected lossily into that language or ignored. A genuinely shared concept may
extend the canonical language by explicit decision: summary-generating model
calls, for example, carry the same normalized input/output usage as ordinary
messages. The registry, not provider switches, drives both indexers, watcher
roots, persisted source roots, source catalog/UI labels and colors, and
raw-record routing. Adding Pi therefore 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`
stream. The adapter owns all source-specific interpretation: duplicate raw
events, stable identities, tool relationships, message classification, and
visibility. `visibility` is separate from `is_meta`: hidden transport context is
omitted from session detail, while visible system evidence can remain a metadata
card. Presentation-sensitive concepts are explicit canonical fields: tool calls
carry a presentation class, Skill instructions carry a content type, and
workflows carry their parent tool-call identity.
visibility. Messages and summaries carry provider-normalized visibility.
`visibility` is separate from `is_meta`. `visible` is current evidence.
`inactive` is physical evidence that the provider explicitly attests was
superseded; default queries omit it, and supported helpers may return it only
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.
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",
"private": true,
"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",
"workspaces": [
"packages/*"
+3 -3
View File
@@ -1,8 +1,8 @@
# Obelisk CLI
The local Obelisk runtime used by coding agents. It indexes Claude Code and
Codex transcripts into `~/.obelisk/obelisk.sqlite` and exposes the stable
`build`, `search`, `query`, and `attune` process interface.
The local Obelisk runtime used by coding agents. It indexes Claude Code, Codex,
Kimi Code, and Pi transcripts into `~/.obelisk/obelisk.sqlite` and exposes the
stable `build`, `search`, `query`, and `attune` process interface.
```bash
npm install --global @obelisk-apps/cli
+21 -1
View File
@@ -32,7 +32,27 @@ async function main() {
}
if (args[0] === '--build') {
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');
} catch (error) { fail(error); }
return;
+1
View File
@@ -15,6 +15,7 @@
"./providers/claude": "./dist/providers/claude.js",
"./providers/codex": "./dist/providers/codex.js",
"./providers/kimi": "./dist/providers/kimi.js",
"./providers/pi": "./dist/providers/pi.js",
"./providers/registry": "./dist/providers/registry.js",
"./providers/builtins": "./dist/providers/builtins.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 { 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 { acquireWriterLease, writerLockPathFor } from './writer-lease.ts';
@@ -19,6 +24,42 @@ export { buildIndex, DB_PATH };
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
// body runs as an async IIFE with a 30s timeout; its `return` value is resolved.
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.
export function searchText(text: string, opts?: Record<string, unknown>): unknown {
buildIndex();
const providerRegistry = refreshQueryIndex();
const db = openReadDb();
try {
return createQueryApi(db).search(text, opts);
return createQueryApi(db, { providerRegistry }).search(text, opts);
} finally {
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.
export async function executeQuery(scriptContent: string): Promise<unknown> {
buildIndex();
const providerRegistry = refreshQueryIndex();
const db = openReadDb();
try {
return await runInSandbox(createQueryApi(db), scriptContent);
return await runInSandbox(createQueryApi(db, { providerRegistry }), scriptContent);
} finally {
db.close();
}
@@ -55,6 +96,7 @@ export async function executeQuery(scriptContent: string): Promise<unknown> {
// Execute a memory-mutation CodeAct script (remember/forget only).
export async function executeAttune(scriptContent: string): Promise<unknown> {
const build = buildIndex() as { reason?: string } | undefined;
reportIncompleteInventory(build);
if (build?.reason === 'daemon_active') {
throw new Error('Obelisk daemon owns index writes; attune is read-only until the daemon stops');
}
+124 -18
View File
@@ -5,15 +5,22 @@ import { inferProjectPath } from './parsing.ts';
import {
createProviderIndexPlan,
indexProviderPlan,
indexProviderPlanStrict,
ProviderIndexFailure,
writeProviderIndexMarkers,
} from './provider-indexing.ts';
import { nodeSqliteTransactionAdapter } from './tx.ts';
import { acquireWriterLease, writerLockPathFor } from './writer-lease.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';
interface SkippedFile {
provider: string;
path: string;
error: string;
diagnostics?: unknown;
@@ -24,6 +31,11 @@ interface BuildCheckOptions {
ignoreRecentBuild?: boolean;
}
interface BuildIndexOptions {
force?: boolean;
providerRegistry?: ProviderRegistry;
}
function errorMessage(error: unknown): string {
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 });
if (ownership.skip) return ownership;
const lease = acquireWriterLease({
@@ -94,34 +106,100 @@ function buildIndex({ force = false }: { force?: boolean } = {}) {
// Ownership may change between the first read and lease acquisition.
const ownershipAfterLease = inspectBuildOwnership({ force });
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 txDb = nodeSqliteTransactionAdapter(db);
const skippedFiles: SkippedFile[] = [];
try {
try {
const providerPlan = createProviderIndexPlan(db, registry, { 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, () => {
db.prepare("DELETE FROM index_state WHERE jsonl_path != '__last_build__'").run();
// Clearing index_state alone re-indexes existing files but leaves rows for
// files that no longer exist on disk (stale sessions accumulate). A force
// build is a clean rebuild: drop every derived table, then re-index from the
// current files. `memories` is the durable, human-approved layer and is never
// cleared; messages_fts is repopulated by the 'rebuild' command in finalize.
// A force build publishes one complete source snapshot or nothing.
// 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();
}
}, { 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, reason: 'database_busy', skipped: skippedFiles.length, skippedFiles };
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;
}
return {
skip: false,
complete: true,
incompleteProviders,
inventoryIssues,
skipped: 0,
skippedFiles,
};
}
const registry = createBuiltinProviderRegistry();
const providerPlan = createProviderIndexPlan(db, registry, { force });
const providerResult = indexProviderPlan({
db,
plan: providerPlan,
@@ -131,13 +209,26 @@ function buildIndex({ force = false }: { force?: boolean } = {}) {
if (hasUnusableTransaction(error)) throw error;
const detail = error as { message?: unknown; obelisk?: unknown } | null;
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`);
return 'skip';
},
});
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
// the build (a half-finalized index would be inconsistent).
@@ -151,11 +242,26 @@ function buildIndex({ force = false }: { force?: boolean } = {}) {
}, { label: 'finalize' });
} catch (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;
}
return { skip: false, skipped: skippedFiles.length, skippedFiles };
return {
skip: false,
complete: providerResult.complete,
incompleteProviders,
inventoryIssues,
skipped: skippedFiles.length,
skippedFiles,
};
} finally {
db.close();
}
+23 -9
View File
@@ -6,6 +6,8 @@ import { closeSync, existsSync, openSync, readSync, readdirSync, statSync } from
import { homedir } from 'node:os';
import { isAbsolute, join, normalize } from 'node:path';
import type { InventoryIssue } from './providers/types.ts';
const CLAUDE_DIR = join(homedir(), '.claude');
const CODEX_DIR = join(homedir(), '.codex');
const PROJECTS_DIR = join(CLAUDE_DIR, 'projects');
@@ -15,6 +17,12 @@ const TEXT_LIMIT = 10000;
type JsonRecord = Record<string, 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 {
path: string;
sessionId: string;
@@ -153,16 +161,19 @@ function inferProjectPath(project: string | null | undefined, observedCwds: unkn
return best?.path || legacyProjectPathFromSlug(project);
}
function discoverJsonlFiles(projectsDir = PROJECTS_DIR): ClaudeJsonlFile[] {
function discoverJsonlFiles(
projectsDir = PROJECTS_DIR,
reportIssue?: DiscoveryIssueHandler,
): ClaudeJsonlFile[] {
const files: ClaudeJsonlFile[] = [];
if (!existsSync(projectsDir)) return files;
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) {
const projPath = join(projectsDir, proj);
if (!isDir(projPath)) continue;
let entries;
try { entries = readdirSync(projPath); } catch { continue; }
try { entries = readdirSync(projPath); } catch (error) { reportIssue?.(sourceInventoryIssue(projPath, error)); continue; }
for (const f of entries) {
if (f.endsWith('.jsonl'))
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');
if (!isDir(saDir)) continue;
let saEntries;
try { saEntries = readdirSync(saDir); } catch { continue; }
try { saEntries = readdirSync(saDir); } catch (error) { reportIssue?.(sourceInventoryIssue(saDir, error)); continue; }
for (const sf of saEntries) {
if (sf.endsWith('.jsonl'))
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');
if (!isDir(wfRoot)) continue;
let wfDirs;
try { wfDirs = readdirSync(wfRoot); } catch { continue; }
try { wfDirs = readdirSync(wfRoot); } catch (error) { reportIssue?.(sourceInventoryIssue(wfRoot, error)); continue; }
for (const wfDir of wfDirs) {
const wfPath = join(wfRoot, wfDir);
if (!isDir(wfPath)) continue;
let wfEntries;
try { wfEntries = readdirSync(wfPath); } catch { continue; }
try { wfEntries = readdirSync(wfPath); } catch (error) { reportIssue?.(sourceInventoryIssue(wfPath, error)); continue; }
for (const wf of wfEntries) {
if (wf.endsWith('.jsonl'))
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;
}
function discoverCodexJsonlFiles(sessionsDir = CODEX_SESSIONS_DIR): CodexJsonlFile[] {
function discoverCodexJsonlFiles(
sessionsDir = CODEX_SESSIONS_DIR,
reportIssue?: DiscoveryIssueHandler,
): CodexJsonlFile[] {
const files: CodexJsonlFile[] = [];
if (!existsSync(sessionsDir)) return files;
const walk = (dir: string): void => {
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) {
const fp = join(dir, entry.name);
if (entry.isDirectory()) {
@@ -369,7 +383,7 @@ export {
CLAUDE_DIR, CODEX_DIR, PROJECTS_DIR, CODEX_SESSIONS_DIR, TEXT_LIMIT,
trunc, truncJson, extractText, extractContentType, extractMessageIsMeta, isSkillInstructions, filePath, isDir, readLines,
legacyProjectPathFromSlug, normalizeObservedCwd, projectSlugFromPath, inferProjectPath,
discoverJsonlFiles, discoverCodexJsonlFiles,
discoverJsonlFiles, discoverCodexJsonlFiles, sourceInventoryIssue,
codexDbId, codexRawId, codexLineUuid, codexCallId, codexParentThreadId, codexIsGuardianThread,
readCodexGuardianThreadInfo, codexAgentNickname, codexAgentRole, parseCodexJsonInput,
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`),
tc: db.prepare('INSERT OR REPLACE INTO tool_calls (id,message_uuid,session_id,name,presentation,input_json,file_path) VALUES (?,?,?,?,?,?,?)'),
tr: db.prepare('INSERT OR REPLACE INTO tool_results (tool_use_id,message_uuid,session_id,content,file_path,is_error) VALUES (?,?,?,?,?,?)'),
sum: db.prepare('INSERT OR REPLACE INTO summaries (id,session_id,timestamp,source,content) VALUES (?,?,?,?,?)'),
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 (?,?,?,?,?,?,?,?,?,?,?)'),
sub: db.prepare(`
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),
tool_calls=COALESCE(excluded.tool_calls, workflow_agents.tool_calls)`),
turn: db.prepare('UPDATE messages SET turn_duration_ms=? WHERE uuid=?'),
idx: db.prepare('INSERT OR REPLACE INTO index_state (jsonl_path,mtime,lines_processed) VALUES (?,?,?)'),
idx: db.prepare('INSERT OR REPLACE INTO index_state (jsonl_path,mtime,lines_processed,cursor) VALUES (?,?,?,?)'),
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);
break;
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;
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);
@@ -147,7 +156,7 @@ export function persist(db: SqliteDb, unit: IndexUnit, gen: Generator<Transcript
if (cursor != null) {
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;
}
+146 -15
View File
@@ -1,52 +1,135 @@
import { persist } from './persist.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';
export interface ProviderSessionProvenance extends IndexedSession {
readonly source: string;
}
export interface ProviderIndexItem {
readonly provider: ProviderAdapter;
readonly unit: IndexUnit;
readonly cursor: Cursor;
}
export interface ProviderInventoryIssue extends InventoryIssue {
readonly provider: string;
}
export interface ProviderIndexPlan {
readonly items: ProviderIndexItem[];
readonly pendingMarkers: ReadonlyMap<string, string>;
readonly replayKeys: ReadonlyMap<string, readonly string[]>;
readonly incompleteProviders: ReadonlySet<string>;
readonly inventoryIssues: readonly ProviderInventoryIssue[];
}
export interface ProviderIndexResult {
readonly committed: ProviderIndexItem[];
readonly failedProviders: ReadonlySet<string>;
readonly failedItems: ProviderIndexItem[];
readonly complete: boolean;
readonly stopped?: { item: ProviderIndexItem; error: unknown };
}
export function storedProviderCursor(db: SqliteDb, key: string): Cursor {
const row = db.prepare('SELECT mtime, lines_processed FROM index_state WHERE jsonl_path = ?').get(key);
return row ? `${String(row.mtime)}:${String(row.lines_processed)}` : null;
export class ProviderIndexFailure extends Error {
readonly item: ProviderIndexItem;
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 {
return Boolean(db.prepare('SELECT 1 FROM sessions WHERE source = ? LIMIT 1').get(source));
export function storedProviderCursor(db: SqliteDb, key: string): Cursor {
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(
db: SqliteDb,
registry: ProviderRegistry,
{ force = false, changedPaths }: { force?: boolean; changedPaths?: string[] } = {},
{
force = false,
changedPaths,
priorSessions,
}: {
force?: boolean;
changedPaths?: string[];
priorSessions?: readonly ProviderSessionProvenance[];
} = {},
): ProviderIndexPlan {
const items: ProviderIndexItem[] = [];
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()) {
const indexedSessions = provenance
.filter((session) => session.source === provider.name)
.map(({ sessionId, jsonlPath }) => ({ sessionId, jsonlPath }));
const marker = provider.indexVersionMarker;
const markerMissing = marker !== undefined && !db.prepare(
'SELECT jsonl_path FROM index_state WHERE jsonl_path = ?',
).get(marker);
if (markerMissing) pendingMarkers.set(provider.name, marker);
const fullReindex = force || (markerMissing && sourceAlreadyIndexed(db, provider.name));
const fullReindex = force || (markerMissing && indexedSessions.length > 0);
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({
lastCursor: fullReindex ? () => null : (key) => storedProviderCursor(db, key),
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) {
items.push({
provider,
@@ -55,7 +138,7 @@ export function createProviderIndexPlan(
});
}
}
return { items, pendingMarkers };
return { items, pendingMarkers, replayKeys, incompleteProviders, inventoryIssues };
}
export function indexProviderPlan({
@@ -73,6 +156,7 @@ export function indexProviderPlan({
}): ProviderIndexResult {
const committed: ProviderIndexItem[] = [];
const failedProviders = new Set<string>();
const failedItems: ProviderIndexItem[] = [];
for (const item of plan.items) {
try {
const cursor = runTransaction(`provider:${item.provider.name}:${item.unit.key}`, () => (
@@ -82,12 +166,45 @@ export function indexProviderPlan({
onCommitted(item, cursor);
} catch (error) {
failedProviders.add(item.provider.name);
failedItems.push(item);
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(
@@ -95,12 +212,26 @@ export function writeProviderIndexMarkers(
plan: ProviderIndexPlan,
result: ProviderIndexResult,
): void {
if (result.stopped !== undefined) return;
const retry = db.prepare('DELETE FROM index_state WHERE jsonl_path = ?');
const write = db.prepare(
'INSERT OR REPLACE INTO index_state (jsonl_path, mtime, lines_processed) VALUES (?, ?, 0)',
);
for (const [provider, marker] of plan.pendingMarkers) {
if (!result.failedProviders.has(provider) && result.stopped === undefined) {
const committed = new Set(result.committed.map(
(item) => `${item.provider.name}\0${item.unit.key}`,
));
// 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 { createCodexProvider } from './codex.ts';
import { createKimiProvider } from './kimi.ts';
import { createPiProvider } from './pi.ts';
import { createProviderRegistry, type ProviderRegistry } from './registry.ts';
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([
createClaudeProvider({ rootDir: roots['claude'] }),
createCodexProvider({ rootDir: roots['codex'] }),
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 {
extractText, extractContentType, extractMessageIsMeta, isSkillInstructions,
filePath, trunc, truncJson, readLines, discoverJsonlFiles, isDir,
filePath, trunc, truncJson, readLines, discoverJsonlFiles, isDir, sourceInventoryIssue,
} from '../parsing.ts';
import type {
@@ -61,6 +61,9 @@ function totalInputTokens(usage: Record<string, unknown>): number | null {
function discoverAt(rootDir: string, ctx: DiscoverContext): IndexUnit[] {
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 historyTitles = new Map<string, string>();
if (existsSync(historyPath)) {
@@ -95,7 +98,7 @@ function discoverAt(rootDir: string, ctx: DiscoverContext): IndexUnit[] {
changedWorkflowPaths.add(absolute);
}
}
const transcriptUnits = discoverJsonlFiles(projectsDir).filter((file) => {
const transcriptUnits = discoverJsonlFiles(projectsDir, ctx.reportIncompleteInventory).filter((file) => {
const normalizedPath = normalize(file.path);
if (ctx.changedPaths !== undefined && !historyChanged && !changedTranscriptPaths.has(normalizedPath)) return false;
const cursor = ctx.lastCursor(file.path);
@@ -118,18 +121,27 @@ function discoverAt(rootDir: string, ctx: DiscoverContext): IndexUnit[] {
const workflowUnits: IndexUnit[] = [];
if (!existsSync(projectsDir)) return transcriptUnits;
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) {
const projectPath = join(projectsDir, project);
if (!isDir(projectPath)) continue;
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) {
const workflowDir = join(projectPath, sessionId, 'workflows');
if (!isDir(workflowDir)) continue;
const mainTranscriptPath = join(projectPath, `${sessionId}.jsonl`);
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) {
if (!file.endsWith('.json')) continue;
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[] {
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 sessionIndex = new Map<string, { title: string; updatedAt: string | null }>();
if (existsSync(sessionIndexPath)) {
@@ -76,7 +79,7 @@ function discoverAt(rootDir: string, ctx: DiscoverContext): IndexUnit[] {
if (!inside || inside.startsWith('..') || isAbsolute(inside)) continue;
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 [];
const cursor = ctx.lastCursor(file.path);
const guardian = readCodexGuardianThreadInfo(file.path);
@@ -187,7 +190,7 @@ export function* parse(unit: IndexUnit, _cursor: Cursor): Generator<TranscriptRe
out.push(rec);
msgByUuid.set(uuid, rec);
sm.lastMessageUuid = uuid;
if (!agentId) sm.n++;
if (!agentId && visibility === 'visible') sm.n++;
if (type === 'assistant' && contentType === 'text') sm.lastTextAssistantUuid = uuid;
updateBounds(timestamp);
return uuid;
+27 -5
View File
@@ -7,7 +7,7 @@ import {
import { homedir } from 'node:os';
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 {
Cursor,
DiscoverContext,
@@ -17,6 +17,7 @@ import type {
ProviderAdapter,
RawLookup,
RawRecord,
InventoryIssue,
SubagentRecord,
SummaryRecord,
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');
if (!existsSync(sessionsDir)) return [];
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;
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));
}
}
@@ -649,10 +667,14 @@ export function createKimiProvider({ rootDir = defaultKimiRoot() }: { rootDir?:
watchRoots: (configuredRoot) => [join(configuredRoot, 'sessions'), join(configuredRoot, 'session_index.jsonl')],
discover(ctx: DiscoverContext): 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
? null
: 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;
const statePath = join(sessionDir, 'state.json');
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;
/** Adapter-private payload, opaque to the orchestration. */
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. */
@@ -45,6 +59,10 @@ export interface DiscoverContext {
lastCursor(key: string): Cursor;
/** When set (daemon changed-path mode), restrict discovery to these paths. */
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;
@@ -62,7 +80,7 @@ export type TranscriptRecord =
| MessageTurnDurationRecord
| DeleteSessionRecord;
export type MessageVisibility = 'visible' | 'hidden';
export type MessageVisibility = 'visible' | 'inactive' | 'hidden';
export interface MessageRecord {
kind: 'message';
@@ -75,7 +93,10 @@ export interface MessageRecord {
text: string | null;
content_type: string | null;
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;
model: string | null;
is_sidechain: 0 | 1;
@@ -116,6 +137,11 @@ export interface SummaryRecord {
timestamp: string | null;
source: 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
@@ -202,7 +228,8 @@ export interface DeleteSessionRecord {
// a line-incremental adapter (claude) yields only new messages ('delta', persist
// accumulates onto the existing row); a full-reparse adapter (codex) yields every
// message each run ('total', persist replaces). A 'delta' parse from an empty
// cursor is equivalent to 'total'.
// cursor is equivalent to 'total'. The count describes the standard visible
// transcript surface; records behind inactive/hidden visibility do not contribute.
export interface SessionRecord {
kind: 'session';
id: string;
@@ -238,6 +265,10 @@ export interface ProviderDescriptor {
readonly vendor: string;
readonly defaultRoot: 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 {
@@ -245,6 +276,8 @@ export interface RawLookup {
readonly messageUuid: string;
readonly session: Record<string, unknown> | 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 workflowAgent?: Record<string, unknown> | null;
}
+153 -24
View File
@@ -18,6 +18,7 @@ interface QueryOptions extends Record<string, any> {
branch?: string;
source?: string;
includeMeta?: boolean;
includeInactive?: boolean;
query?: string;
projectLimit?: number;
memoryLimit?: number;
@@ -74,6 +75,34 @@ function buildWhere(opts: QueryOptions, aliases: ColumnAliases) {
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 {
const text = String(sql || '').trim();
if (!/^(SELECT|WITH)\b/i.test(text)) {
@@ -120,7 +149,17 @@ function createQueryApi(
};
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 ?';
const filterParams: any[] = [];
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 (source && source !== 'all') { where += " AND COALESCE(m.source, s.source, 'claude')=?"; filterParams.push(source); }
if (!includeMeta) where += ' AND COALESCE(m.is_meta,0)=0';
where += ` AND ${visibilitySql('m', includeInactive)}`;
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.source as s_source,
rank
@@ -151,11 +192,31 @@ function createQueryApi(
return rows.map((r: DbRow) => {
const metaClause = includeMeta ? '' : 'AND COALESCE(is_meta,0)=0';
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`
).all(r.session_id, r.uuid, r.timestamp).sort((a: DbRow, b: DbRow) => a.timestamp < b.timestamp ? -1 : 1);
`SELECT uuid,text,content_type,is_meta,role,timestamp,model,
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';
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 },
rank: r.rank,
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);
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 chain: DbRow[] = [];
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;
let workflow = null;
if (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);
}
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[] = [];
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;
};
const thread = (sid: string, opts: QueryOptions = {}) => {
const includeMeta = opts?.includeMeta === true;
const includeInactive = opts?.includeInactive === true;
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) => {
@@ -228,37 +305,73 @@ function createQueryApi(
};
const fileHistory = (fp: string, opts: QueryOptions = {}) => {
const { limit = 200, after, before, source } = opts;
let where = 'tc.file_path=?';
const { limit = 200, after, before, source, includeInactive = false } = opts;
let where = `tc.file_path=? AND ${visibilitySql('m', includeInactive)}`;
const params: any[] = [fp];
if (after) { where += ' AND m.timestamp > ?'; params.push(after); }
if (before) { where += ' AND m.timestamp < ?'; params.push(before); }
if (source && source !== 'all') { where += " AND COALESCE(s.source, 'claude') = ?"; params.push(source); }
params.push(limit);
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) => ({
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 },
timestamp: r.ts,
visibility: normalizedVisibility(r.visibility),
}));
};
const failures = (optsOrSid?: QueryOptions | string) => {
const opts = normalizeOpts(optsOrSid);
const { limit = 50 } = opts;
const includeInactive = opts.includeInactive === true;
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 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 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) => {
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 rm = 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) : [];
return { toolCall: tc, result: r, session, nextMessages: next };
const rmRow = db.prepare('SELECT * FROM messages WHERE uuid=?').get(r.message_uuid);
const rm = rmRow === undefined ? undefined : withVisibility(rmRow);
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 opts = normalizeOpts(optsOrSid);
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' });
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) => {
@@ -456,10 +577,13 @@ function createQueryApi(
};
};
const raw = (messageUuid: string, opts: { offset?: number; limit?: number } = {}) => {
const { offset = 0, limit = 10000 } = opts;
const raw = (
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);
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 subagent = message.agent_id
? 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
: null;
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({
source,
messageUuid,
session,
agentId: message.agent_id || null,
cursor: typeof cursorRow?.cursor === 'string' ? cursorRow.cursor : null,
subagent,
workflowAgent,
});
@@ -484,6 +612,7 @@ function createQueryApi(
offset,
limit,
hasMore: offset + limit < totalLength,
visibility: normalizedVisibility(message.visibility),
};
};
+4
View File
@@ -8,6 +8,10 @@ const COLUMN_MIGRATIONS = [
['messages', 'source', "TEXT DEFAULT 'claude'"],
['tool_calls', 'presentation', "TEXT DEFAULT 'default'"],
['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', 'deleted_at', '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,
duration_ms INTEGER, tokens INTEGER, tool_calls INTEGER);
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 (
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(
uuid UNINDEXED, session_id UNINDEXED, text, content=messages, content_rowid=rowid);
CREATE TRIGGER IF NOT EXISTS messages_fts_ai AFTER INSERT ON messages BEGIN
+41 -7
View File
@@ -1,5 +1,6 @@
import type {
TranscriptRecord,
MessageVisibility,
MessageRecord,
SessionRecord,
SummaryRecord,
@@ -163,6 +164,9 @@ export interface SessionSummaryRow {
[key: string]: unknown;
id: string | number;
session_id?: string | null;
visibility?: string | null;
input_tokens?: number | null;
output_tokens?: number | null;
}
export interface SessionDetailRows {
@@ -180,6 +184,12 @@ function withoutKind<T extends { kind: string }>(record: T): WithoutKind<T> {
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(
messages: SessionDetailMessage[],
toolCalls: ToolCallRecord[],
@@ -187,8 +197,19 @@ function assembleMessages(
subagents: Extract<TranscriptRecord, { kind: 'subagent' }>[],
workflows: SessionDetailWorkflow[],
): 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>();
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' }>>();
for (const subagent of subagents) {
@@ -201,7 +222,7 @@ function assembleMessages(
.filter((workflow) => workflow.parent_tool_use_id)
.map((workflow) => [workflow.parent_tool_use_id as string, workflow]),
);
for (const toolCall of toolCalls) {
for (const toolCall of visibleToolCalls) {
const call: AssembledToolCall = {
id: toolCall.id,
name: toolCall.name,
@@ -234,7 +255,10 @@ function assembleMessages(
const output: AssembledMessage[] = [];
for (let index = 0; index < raw.length; 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') {
const thinkingParts = [message.text ?? ''];
@@ -272,7 +296,10 @@ function assembleMessages(
let nextIndex = index + 1;
while (nextIndex < raw.length) {
const next = raw[nextIndex];
if (next.content_type === 'tool_result') {
if (
next.content_type === 'tool_result'
&& attachedResultMessageUuids.has(next.uuid)
) {
nextIndex++;
continue;
}
@@ -303,7 +330,10 @@ function assembleMessages(
let nextIndex = index + 1;
while (nextIndex < raw.length) {
const next = raw[nextIndex];
if (next.content_type === 'tool_result') {
if (
next.content_type === 'tool_result'
&& attachedResultMessageUuids.has(next.uuid)
) {
nextIndex++;
continue;
}
@@ -359,7 +389,7 @@ function assembleTranscriptRecords(records: Iterable<TranscriptRecord>): Session
};
break;
case 'message': {
if (record.visibility === 'hidden') break;
if (canonicalVisibility(record.visibility) !== 'visible') break;
const message: SessionDetailMessage = {
uuid: record.uuid,
type: record.type || record.role,
@@ -401,6 +431,7 @@ function assembleTranscriptRecords(records: Iterable<TranscriptRecord>): Session
} as WorkflowAgentRecord);
break;
case 'summary':
if (canonicalVisibility(record.visibility) !== 'visible') break;
summaries.push(withoutKind(record));
break;
case 'message-turn-duration': {
@@ -487,7 +518,7 @@ function sessionDetailRecordsFromRows(input: SessionDetailRows): TranscriptRecor
text: typeof message.text === 'string' ? message.text : null,
content_type: typeof message.content_type === 'string' ? message.content_type : null,
is_meta: message.is_meta ? 1 : 0,
visibility: message.visibility === 'hidden' ? 'hidden' : 'visible',
visibility: canonicalVisibility(message.visibility),
model: typeof message.model === 'string' ? message.model : null,
is_sidechain: message.is_sidechain ? 1 : 0,
agent_id: typeof message.agent_id === 'string' ? message.agent_id : null,
@@ -583,6 +614,9 @@ function sessionDetailRecordsFromRows(input: SessionDetailRows): TranscriptRecor
timestamp: typeof summary.timestamp === 'string' ? summary.timestamp : null,
source: typeof summary.source === 'string' ? summary.source : '',
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;
+1 -1
View File
@@ -1,7 +1,7 @@
# Obelisk Skill
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)
+36 -22
View File
@@ -1,7 +1,7 @@
---
name: obelisk
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", "历史记录".
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.
@@ -13,18 +13,17 @@ allowed-tools:
# obelisk
Search and query Claude Code and Codex session history stored in `~/.claude/`
and `~/.codex/`.
Search and query local Claude Code, Codex, Kimi Code, and Pi session history.
Obelisk indexes sessions, messages, tool calls, tool results, summaries,
subagents, workflows, workflow agents, parent chains, and raw JSONL lines into
SQLite + FTS5.
Obelisk has two transcript sources. Treat both as ordinary sessions by default:
Claude rows use `source='claude'`; Codex rows use `source='codex'` and IDs
prefixed with `codex:`. Use `source` only when provenance matters or the user
asks to scope to one provider. Codex subagent child threads are mapped to the
same `subagents` table; Codex workflow rows may be absent because Codex does not
emit Claude-style workflow metadata.
Obelisk has four transcript sources. Treat all of them as ordinary sessions by
default: Claude rows use `source='claude'`, Codex rows use `source='codex'`,
Kimi Code rows use `source='kimi'`, and Pi rows use `source='pi'`. Use `source`
only when provenance matters or the user asks to scope to one provider.
Provider-specific records are projected into the same canonical tables; some
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
the JSON, then answer. Do not turn history into a flat document or browse entire
@@ -148,7 +147,7 @@ messages.
Returns:
```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 },
rank,
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
be treated as the user's request by default. `search()` and `thread()` omit meta
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
identity. Results are already ordered by FTS5 rank; lower rank sorts earlier.
Prefer returned order over manually interpreting numeric rank unless you are
deliberately using FTS5 semantics.
`source` can be `'claude'`, `'codex'`, or omitted. Omitted means search all
indexed sources.
`source` can be `'claude'`, `'codex'`, `'kimi'`, `'pi'`, or omitted. Omitted
means search all indexed sources.
### `context(uuid)`
### `context(uuid, opts?)`
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
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)`
@@ -224,17 +236,17 @@ All list helpers accept a bounded `limit`. Many also accept:
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.
- `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.
- `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`.
- `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.
- `fileHistory(filePath, opts?)` -- Read/Edit/Write tool calls for a file, oldest first; includes many `Read` rows.
- `failures(opts?)` -- failed tool results with tool/session context, newest first.
- `trace(uuid)` -- 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.
- `raw(uuid, opts?)` -- windowed access to the original JSONL line.
- `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 and `visibility`, newest first.
- `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 }` for injected context or `{ includeInactive: true }` for superseded Pi history.
- `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.
## 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.
- 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 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.
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
FROM messages
WHERE session_id=? AND timestamp>=?
AND COALESCE(visibility, 'visible') = 'visible'
ORDER BY timestamp LIMIT 6`,
hit.session.id,
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.before` | `string` | ISO upper bound on message timestamp |
| `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.includeInactive` | `boolean` | Include provider-attested superseded rows, default false |
Returns:
```js
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 },
rank,
context
@@ -72,6 +73,7 @@ Array<{
```
`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
FTS rank sorts earlier; prefer returned order unless deliberately inspecting
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 —
so ordinary text never crashes the query.
#### `context(uuid)`
#### `context(uuid, opts?)`
Full indexed context around one message.
| Param | Type | Description |
| --- | --- | --- |
| `uuid` | `string` | Message UUID |
| `opts.includeInactive` | `boolean` | Include a superseded target and ancestors, default false |
Returns:
@@ -95,9 +98,11 @@ Returns:
{ 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
metadata exists.
metadata exists. Hidden targets always return `null`, and hidden ancestors are
always omitted.
#### `sql(query, ...params)`
@@ -176,7 +181,7 @@ Returns:
projects,
sessions,
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.limit` | `number` | Max rows, default 50 |
| `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.sessions` | `string[]` | Restrict to session IDs |
Returns `Array<session_row>`.
`message_count` describes the visible canonical transcript; inactive and hidden
records do not increase it.
#### `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.source` | `string` | Provider filter through joined session |
| `opts.limit` | `number` | Max rows, default 100 |
| `opts.includeInactive` | `boolean` | Include superseded summaries, default false |
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
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?)`
@@ -265,11 +276,13 @@ full content.
## Structural Expansion Helpers
#### `trace(uuid)`
#### `trace(uuid, opts?)`
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?)`
@@ -279,30 +292,34 @@ Messages in a session ordered by timestamp.
| --- | --- | --- |
| `sessionId` | `string` | Session ID |
| `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
search/context or compact SQL projections.
#### `raw(uuid, opts?)`
Windowed access to the original JSONL line for one indexed message. Use this
when indexed text, tool inputs, or tool results were truncated and you need the
raw source.
Windowed access to the source record for one indexed message, normally its
original JSONL line. Use this when indexed text, tool inputs, or tool results
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 |
| --- | --- | --- |
| `uuid` | `string` | Message UUID |
| `opts.offset` | `number` | Character offset into the JSONL line, default 0 |
| `opts.limit` | `number` | Max characters, default 10000 |
| `opts.includeInactive` | `boolean` | Allow a superseded target, default false |
Returns:
```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
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.source` | `string` | Provider filter |
| `opts.limit` | `number` | Max rows, default 200 |
| `opts.includeInactive` | `boolean` | Include superseded tool evidence, default false |
Returns:
@@ -376,7 +394,8 @@ Returns:
Array<{
toolCall: { id, message_uuid, name, input_json },
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.source` | `string` | Provider filter |
| `opts.limit` | `number` | Max rows, default 50 |
| `opts.includeInactive` | `boolean` | Include superseded failures and neighbors, default false |
Returns:
```js
Array<{ toolCall, result, session, nextMessages }>
Array<{ toolCall, result, session, nextMessages, visibility }>
```
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
WHERE s.project LIKE ?
AND m.text LIKE ?
AND COALESCE(m.visibility, 'visible') = 'visible'
ORDER BY m.timestamp DESC
LIMIT 10
`, '%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
WHERE m.session_id IN (${sessionIds.map(() => '?').join(',')})
AND m.text IS NOT NULL
AND COALESCE(m.visibility, 'visible') = 'visible'
AND (${clauses})
ORDER BY m.timestamp
LIMIT 3
@@ -448,6 +449,7 @@ const before = sql(
`SELECT uuid, role, timestamp, substr(text,1,200) AS snippet
FROM messages
WHERE session_id=? AND timestamp<?
AND COALESCE(visibility, 'visible') = 'visible'
ORDER BY timestamp DESC LIMIT 3`,
s.session_id,
s.timestamp
@@ -456,6 +458,7 @@ const after = sql(
`SELECT uuid, role, timestamp, substr(text,1,200) AS snippet
FROM messages
WHERE session_id=? AND timestamp>?
AND COALESCE(visibility, 'visible') = 'visible'
ORDER BY timestamp ASC LIMIT 3`,
s.session_id,
s.timestamp
@@ -532,6 +535,7 @@ const counts = sql(`
JOIN messages m ON m.uuid = tr.message_uuid
JOIN sessions s ON s.id = tr.session_id
WHERE tr.is_error = 1
AND COALESCE(m.visibility, 'visible') = 'visible'
AND s.project LIKE ?
GROUP BY tc.name
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 sessions s ON s.id = tr.session_id
WHERE tr.is_error = 1
AND COALESCE(m.visibility, 'visible') = 'visible'
AND s.project LIKE ?
ORDER BY m.timestamp DESC
LIMIT 8
@@ -580,6 +585,7 @@ const groups = sql(`
JOIN messages m ON m.uuid = tr.message_uuid
JOIN sessions s ON s.id = tr.session_id
WHERE tr.is_error = 1
AND COALESCE(m.visibility, 'visible') = 'visible'
AND s.project LIKE ?
GROUP BY s.id
ORDER BY last_failure_at DESC
@@ -598,6 +604,7 @@ const examples = sql(`
JOIN messages m ON m.uuid = tr.message_uuid
JOIN sessions s ON s.id = tr.session_id
WHERE tr.is_error = 1
AND COALESCE(m.visibility, 'visible') = 'visible'
AND s.project LIKE ?
ORDER BY m.timestamp DESC
LIMIT 12
@@ -694,6 +701,7 @@ const row = sql(`
SELECT uuid, length(text) AS indexed_len
FROM messages
WHERE length(text) >= 10000
AND COALESCE(visibility, 'visible') = 'visible'
LIMIT 1
`)[0];
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.
- `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.
- `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 `source`: optional provider filter. Omit it unless provenance matters.
@@ -85,6 +85,9 @@ Ordering and context are semantic:
- `fileHistory()` is oldest first.
- `search().context` is temporal neighbors in one session, not causal context.
- `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
+15 -6
View File
@@ -14,11 +14,14 @@ exist.
## 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'`.
- Codex rows use `source='codex'`; root session and message IDs are prefixed
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.
- Codex child threads are represented through `subagents`; Codex may not have
Claude-style workflow rows.
@@ -59,9 +62,9 @@ One row per root session.
| `started_at`, `ended_at` | ISO timestamps |
| `git_branch` | Branch at session time |
| `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 |
| `source` | `claude` or `codex` |
| `source` | Provider ID: `claude`, `codex`, `kimi`, or `pi` |
### `messages`
@@ -77,6 +80,7 @@ Core evidence table.
| `text` | Extracted text, truncated to 10k chars |
| `content_type` | `text`, `thinking`, `tool_use`, `tool_result`, or `unknown` |
| `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 |
| `is_sidechain` | Retry/branch marker |
| `agent_id` | Subagent/workflow agent ID |
@@ -84,7 +88,7 @@ Core evidence table.
| `cwd` | Working directory at message time |
| `skill` | Skill that generated the response, if known |
| `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
`tool_calls`. `content_type='tool_result'` marks provider-emitted tool-result
@@ -131,6 +135,8 @@ Session summary rows.
| `timestamp` | Summary timestamp |
| `source` | Summary kind, such as `away_summary`; not provider source |
| `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`
@@ -207,6 +213,7 @@ Indexer progress and sentinel state.
| `jsonl_path` | Source path or synthetic sentinel key |
| `mtime` | Last indexed mtime |
| `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__`,
`__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 sessions s ON s.id = tc.session_id
WHERE s.project LIKE ?
AND COALESCE(m.visibility, 'visible') = 'visible'
ORDER BY m.timestamp DESC
LIMIT 20;
```
@@ -262,6 +270,7 @@ FROM tool_results tr
JOIN tool_calls tc ON tc.id = tr.tool_use_id
JOIN messages m ON m.uuid = tr.message_uuid
WHERE tr.is_error = 1
AND COALESCE(m.visibility, 'visible') = 'visible'
ORDER BY m.timestamp DESC
LIMIT 20;
```
@@ -274,6 +283,7 @@ FROM messages m
JOIN sessions s ON s.id = m.session_id
WHERE s.project LIKE ?
AND COALESCE(m.is_meta, 0) = 0
AND COALESCE(m.visibility, 'visible') = 'visible'
ORDER BY m.timestamp DESC
LIMIT 20;
```
@@ -322,7 +332,6 @@ Common indexed filters:
absolute path when known; `messages.cwd` is per-message working directory.
- 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
`references/api-reference.md` when a specific message needs the original JSONL
line.
`references/api-reference.md` when a specific message needs its source record.
- Prefer SQL-side `COUNT`, `GROUP BY`, `MAX`, `ORDER BY`, and `LIMIT` over
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 assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { mkdtempSync } from 'node:fs';
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
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);
});
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 () => {
const timers = manualTimers();
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/);
});
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 () => {
const timers = manualTimers();
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'),
]);
});
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 originalHome = process.env.HOME;
const home = join(tmpdir(), `obelisk-window-flags-${Date.now()}-${Math.random()}`);
mkdirSync(join(home, '.obelisk'), { recursive: true });
writeFileSync(join(home, '.obelisk', 'obelisk.sqlite'), '');
if (settingsText !== undefined) {
writeFileSync(join(home, '.obelisk', 'settings.json'), settingsText);
}
process.env.HOME = home;
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);
});
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 () => {
const originalHome = process.env.HOME;
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;
const serviceOptions = [];
const workerCalls = [];
class FakeDatabase {
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 {
@@ -265,8 +284,11 @@ test('main process watches every root declared by the built-in provider registry
join(codexDir, 'session_index.jsonl'),
join(home, '.kimi-code', 'sessions'),
join(home, '.kimi-code', 'session_index.jsonl'),
join(home, '.pi', 'agent', 'sessions'),
]);
assert.equal(serviceOptions[0].watchDirs.includes(codexDir), false);
await serviceOptions[0].buildIndex({ reason: 'settings-transfer' });
assert.deepEqual(workerCalls[0].providerSettings, {});
} finally {
restore();
process.env.HOME = originalHome;
@@ -286,6 +308,7 @@ test('main process forwards committed IDs without reopening after a deferred bui
let databaseOpens = 0;
let serviceOptions;
let notifications = 0;
const sent = [];
class FakeDatabase {
constructor() { databaseOpens += 1; }
@@ -302,7 +325,16 @@ test('main process forwards committed IDs without reopening after a deferred bui
loadFile() {}
loadURL() {}
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; }
}
@@ -322,7 +354,19 @@ test('main process forwards committed IDs without reopening after a deferred bui
[INDEXER_WORKER_URL, {
namedExports: {
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() {},
}),
},
@@ -338,6 +382,22 @@ test('main process forwards committed IDs without reopening after a deferred bui
assert.equal(result.deferred, true);
assert.equal(databaseOpens, opensBeforeBuild);
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 {
restore();
process.env.HOME = originalHome;
@@ -459,6 +519,39 @@ test('usage IPC aggregates normalized tokens across all indexed providers', asyn
input_tokens, output_tokens, source
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).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();
const ipcHandlers = new Map();
@@ -495,14 +588,26 @@ test('usage IPC aggregates normalized tokens across all indexed providers', asyn
try {
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, {});
assert.equal(claudeOnly.totalTokens, 65);
assert.equal(claudeOnly.daily[0].tokens, 65);
const allSources = ipcHandlers.get('db:getUsageStats')(null, { source: 'all' });
assert.equal(allSources.totalTokens, 175);
assert.equal(allSources.daily[0].tokens, 175);
assert.equal(allSources.peakDay.tokens, 175);
assert.equal(allSources.totalTokens, 210);
assert.equal(allSources.daily[0].tokens, 210);
assert.equal(allSources.peakDay.tokens, 210);
const piOnly = ipcHandlers.get('db:getUsageStats')(null, { source: 'pi' });
assert.equal(piOnly.totalTokens, 35);
} finally {
restore();
process.env.HOME = originalHome;
@@ -793,7 +898,9 @@ test('settings rebuild reopens the database from the configured Claude path', as
const openedDbPaths = [];
const buildCalls = [];
const serviceEvents = [];
const sent = [];
let competingLeaseDuringBuild;
let publishRebuild = false;
class FakeDatabase {
constructor(dbPath) {
@@ -810,12 +917,19 @@ test('settings rebuild reopens the database from the configured Claude path', as
class FakeBrowserWindow {
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() {}
loadURL() {}
close() {}
static getAllWindows() { return []; }
static getAllWindows() { return [new FakeBrowserWindow()]; }
static fromWebContents() { return null; }
}
@@ -856,7 +970,20 @@ test('settings rebuild reopens the database from the configured Claude path', as
competingLeaseDuringBuild = Boolean(competingLease);
competingLease?.release();
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(); },
}),
@@ -869,19 +996,45 @@ test('settings rebuild reopens the database from the configured Claude path', as
const rebuild = ipcHandlers.get('settings:rebuildIndex');
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();
assert.deepEqual(
sent.findLast(message => message.channel === 'obelisk:index-updated').payload.sourceIssues,
[],
);
assert.equal(buildCalls.at(-1).claudeDir, customClaudeDir);
assert.equal(buildCalls.at(-1).projectsDir, join(customClaudeDir, 'projects'));
assert.equal(buildCalls.at(-1).codexDir, customCodexDir);
assert.notEqual(buildCalls.at(-1).dbPath, join(home, '.obelisk', 'obelisk.sqlite'));
assert.equal(buildCalls.at(-1).preserveDbPath, join(home, '.obelisk', 'obelisk.sqlite'));
assert.notEqual(buildCalls.at(-1).dbPath, liveDbPath);
assert.equal(buildCalls.at(-1).preserveDbPath, liveDbPath);
assert.equal(buildCalls.at(-1).writerLeasePath, join(home, '.obelisk', 'writer.lock.sqlite'));
assert.equal(buildCalls.at(-1).writerLeaseMode, 'caller-held');
assert.equal(competingLeaseDuringBuild, false);
assert.equal(openedDbPaths.at(-1), join(home, '.obelisk', 'obelisk.sqlite'));
assert.equal(openedDbPaths.at(-1), liveDbPath);
assert.equal(
require('node:fs').readFileSync(join(home, '.obelisk', 'obelisk.sqlite'), 'utf8'),
require('node:fs').readFileSync(liveDbPath, 'utf8'),
'rebuilt temp db',
);
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) => {
serviceEvents.push(`build-${++buildIndexCalls}`);
writeFileSync(args.dbPath, 'rebuilt temp db');
return { files: 2, affectedSessionIds: [] };
return { files: 2, affectedSessionIds: [], complete: true };
},
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.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):
// - 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 ...").
import { test } from 'node:test';
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);
});
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');
// 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')));
@@ -191,7 +191,8 @@ test('a persistent BUSY exhausts retries and skips just that file, not the build
const check = new DatabaseSync(dbPath);
const sessions = check.prepare('SELECT id FROM sessions ORDER BY id').all().map(r => r.id);
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.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];
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');
assert.ok(Array.isArray(hit.context), 'search() hit.context is an array');
db.close();
@@ -119,7 +119,7 @@ test('fileHistory() row shape matches api-reference.md', () => {
const db = fixture();
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.session, ['id', 'title', 'project'], 'fileHistory() row.session');
db.close();
@@ -129,7 +129,7 @@ test('failures() row shape matches api-reference.md', () => {
const db = fixture();
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.equal(row.nextMessages[0].uuid, 'm-after');
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');
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.totalLength, line.length);
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.executeQuery, '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 { extractContentType, extractMessageIsMeta } from '../packages/core/src/db.ts';
import { migrateCoreSchemaColumns } from '../packages/core/src/schema-migrations.ts';
async function readExecutableSchema() {
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/);
});
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 () => {
const db = new DatabaseSync(':memory:');
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.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, /sessions\.id\s+<--\s+messages\.session_id/);
assert.match(ref, /tool_calls.*does not have timestamps/i);
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, /#### `raw\(uuid, opts\?\)`/);
});
@@ -137,8 +182,11 @@ test('api reference documents query helpers and current return fields', async ()
const ref = await readApiReference();
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, /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, /opts\.branch/);
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 () => {
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, /references\/schema\.md.*raw SQL/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 { persist } from '../packages/core/src/persist.ts';
import { storedProviderCursor } from '../packages/core/src/provider-indexing.ts';
const require = createRequire(import.meta.url);
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_results').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');
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.output_tokens, 5);
// Cursor persisted into index_state (mtime:lines → two columns).
const state = db.prepare('SELECT lines_processed FROM index_state WHERE jsonl_path=?').get(unit.key);
// The legacy numeric columns remain queryable, while the opaque token
// 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.cursor, cursor);
assert.equal(storedProviderCursor(db, unit.key), cursor);
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',
codex: '/sources/codex',
kimi: '/sources/kimi',
pi: '/sources/pi',
});
assert.deepEqual(registry.catalog().map(({ id, name }) => ({ id, name })), [
{ id: 'claude', name: 'Claude Code' },
{ id: 'codex', name: 'Codex' },
{ id: 'kimi', name: 'Kimi Code' },
{ id: 'pi', name: 'Pi' },
]);
assert.deepEqual(registry.watchRoots(), [
'/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/kimi/sessions',
'/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));
assert.equal(
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,
2,
);
assert.equal(
records.find(record => record.kind === 'session').message_count,
1,
);
});
test('provider normalization removes only structural image wrappers before deduplication', () => {
+153 -2
View File
@@ -1,6 +1,13 @@
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 {
createConfiguredBuiltinProviderRuntime,
readPersistedProviderSettings,
} from '../packages/core/src/provider-settings.ts';
import { createProviderRegistry } from '../packages/core/src/providers/registry.ts';
import {
buildSourceCatalog,
@@ -8,10 +15,10 @@ import {
setPersistedSetting,
} from '../app/src/main/provider-settings.ts';
function provider(id, defaultRoot, color) {
function provider(id, defaultRoot, color, descriptor = {}) {
return {
name: id,
descriptor: { id, name: `${id} name`, vendor: `${id} vendor`, defaultRoot, color },
descriptor: { id, name: `${id} name`, vendor: `${id} vendor`, defaultRoot, color, ...descriptor },
watchRoots: () => [],
discover: () => [],
*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.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');
db.prepare('INSERT INTO subagents (agent_id,session_id,description) VALUES (?,?,?)')
.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', {
offset: 2,
@@ -38,11 +40,12 @@ test('raw query delegates source semantics to the registered provider', () => {
});
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[0].source, 'alpha');
assert.equal(calls[0].session.id, 'alpha:session');
assert.equal(calls[0].cursor, 'committed-alpha-cursor');
assert.equal(calls[0].subagent.description, 'agent metadata');
db.close();
});
+287 -7
View File
@@ -42,7 +42,8 @@ function searchDb() {
CREATE TABLE messages (
uuid TEXT PRIMARY KEY, session_id TEXT, text TEXT, role 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(
uuid UNINDEXED, session_id UNINDEXED, text,
@@ -54,13 +55,16 @@ function searchDb() {
VALUES (?, ?, ?, ?)
`).run('sid-search', 'Search session', 'quiet-zero', '2026-06-10T10:00:00Z');
const insert = db.prepare(`
INSERT INTO messages (uuid, session_id, text, role, timestamp, model, cwd, content_type, is_meta)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO messages (uuid, session_id, text, role, timestamp, model, cwd, content_type, is_meta, visibility)
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-text', 'sid-search', 'needle visible reply', 'assistant', '2026-06-10T10:01:00Z', 'claude-opus', '/tmp/quiet-zero', 'text', 0);
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-thinking', 'sid-search', 'nearby reasoning trace', 'assistant', '2026-06-10T10:02:00Z', 'claude-opus', '/tmp/quiet-zero', 'thinking', 0);
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, '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, 'visible');
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')");
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 });
assert.equal(withMeta[0].message.uuid, 'msg-meta');
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', { includeMeta: true }).map(m => m.uuid),
['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();
});
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', () => {
const db = memoryDb();
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', () => {
const home = tempHome();
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));
});
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', () => {
const home = tempHome();
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']);
});
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', () => {
assert.throws(() => assembleSessionDetail([{
kind: 'session', id: 'session', title: null, project: null,