diff --git a/SKILL.md b/SKILL.md index e2e4a7c..6a0369a 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: obelisk description: > - Search and query past Claude Code session history. + Search and query past Claude Code and Codex 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,11 +13,19 @@ allowed-tools: # obelisk -Search and query Claude Code session history stored in `~/.claude/`. +Search and query Claude Code and Codex session history stored in `~/.claude/` +and `~/.codex/`. 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 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 sessions by default. @@ -123,8 +131,8 @@ messages. Returns: ```js -[{ message: { uuid, text, content_type, is_meta, role, timestamp, model, cwd }, - session: { id, title, project, started_at }, +[{ message: { uuid, text, content_type, is_meta, role, timestamp, model, cwd, source }, + session: { id, title, project, started_at, source }, rank, context }] ``` @@ -148,13 +156,16 @@ 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. -Opts: `{ limit, sessionId, project, after, before, cwd, includeMeta }`. +Opts: `{ limit, sessionId, project, after, before, cwd, source, includeMeta }`. `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. + ### `context(uuid)` Returns the full story around one indexed message: @@ -191,13 +202,13 @@ not replace `sql()`, but they are the default first-pass surface. Use `sql()` when you need an exact aggregation or a join the helper does not expose. All list helpers accept a bounded `limit`. Many also accept: -`{ project, after, before, sessionId, sessions, branch }`. Check the schema or a -tiny sample before relying on less common filters. +`{ project, after, before, sessionId, sessions, branch, source }`. Check the +schema or a tiny sample before relying on less common filters. -- `overview(opts?)` -- compact orientation map. Returns current cwd/project if knowable, global project counts, and current-project recent sessions plus memory records. It is a map, not evidence. +- `overview(opts?)` -- compact orientation map. Returns current cwd/project if knowable, global project/source counts, and current-project recent sessions plus memory records. It is a map, not evidence. - `sessions(opts?)` -- session rows, newest first. `project` is a SQL `LIKE` pattern. - `recent(n?)` -- shorthand for recent sessions. -- `summaries(opts?)` -- summary rows, newest first: `{ id, session_id, timestamp, source, content, session_title, project }`. +- `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. - `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. @@ -372,6 +383,6 @@ See `references/query-patterns.md` for longer recipes. ## Notes - First run builds the index. Later runs update incrementally. -- DB location: `~/.claude/obelisk.sqlite`. +- DB location: `~/.obelisk/obelisk.sqlite`; old `~/.claude/obelisk.sqlite` is copied forward if needed. - Query scripts run in a sandboxed VM with no filesystem or network access from inside the script. - Indexed text and stored tool inputs/results are truncated to 10k chars. Use `raw(uuid, { offset, limit })` for specific JSONL windows. diff --git a/app/indexer-service.js b/app/indexer-service.js index aab4a58..93203e7 100644 --- a/app/indexer-service.js +++ b/app/indexer-service.js @@ -10,6 +10,7 @@ const DEFAULT_WATCH_RETRY_MS = 5000; function createIndexerService({ projectsDir = DEFAULT_PROJECTS_DIR, + watchDirs = [projectsDir], debounceMs = DEFAULT_DEBOUNCE_MS, stabilityMs = DEFAULT_STABILITY_MS, heartbeatMs = DEFAULT_HEARTBEAT_MS, @@ -28,31 +29,42 @@ function createIndexerService({ } = {}) { if (typeof buildIndex !== 'function') throw new Error('createIndexerService() requires buildIndex'); const watch = watchProjects || ((onChange) => { - if (!fs.existsSync(projectsDir)) return null; - const watcher = (chokidar || require('chokidar')).watch(projectsDir, { - cwd: projectsDir, - ignoreInitial: true, - awaitWriteFinish: { - stabilityThreshold: Math.max(stabilityMs, 500), - pollInterval: 100, - }, - ignored: (targetPath, stats) => { - if (stats?.isDirectory()) return false; - if (!stats) return false; - return !String(targetPath).endsWith('.jsonl') && !String(targetPath).endsWith('.json'); - }, - }); + const roots = [...new Set((Array.isArray(watchDirs) ? watchDirs : [watchDirs]).filter(Boolean))]; + const existingRoots = roots.filter(root => fs.existsSync(root)); + if (!existingRoots.length) return null; + const watchers = []; const onFileChange = (filename) => { const name = filename ? String(filename) : ''; if (!name || name.endsWith('.jsonl') || name.endsWith('.json')) onChange(name); }; - return watcher - .on('add', onFileChange) - .on('change', onFileChange) - .on('unlink', onFileChange) - .on('error', (error) => { - logger.warn?.(`Obelisk watcher failed: ${error.message}`); + for (const root of existingRoots) { + const watcher = (chokidar || require('chokidar')).watch(root, { + cwd: root, + ignoreInitial: true, + awaitWriteFinish: { + stabilityThreshold: Math.max(stabilityMs, 500), + pollInterval: 100, + }, + ignored: (targetPath, stats) => { + if (stats?.isDirectory()) return false; + if (!stats) return false; + return !String(targetPath).endsWith('.jsonl') && !String(targetPath).endsWith('.json'); + }, }); + watcher + .on('add', onFileChange) + .on('change', onFileChange) + .on('unlink', onFileChange) + .on('error', (error) => { + logger.warn?.(`Obelisk watcher failed: ${error.message}`); + }); + watchers.push(watcher); + } + return { + close() { + return Promise.all(watchers.map(w => Promise.resolve(w.close?.()))); + }, + }; }); let buildTimer = null; diff --git a/app/indexer-worker-client.js b/app/indexer-worker-client.js index 7286b30..9065a63 100644 --- a/app/indexer-worker-client.js +++ b/app/indexer-worker-client.js @@ -49,8 +49,9 @@ function createWorkerBuildIndex({ const stop = () => { const current = worker; worker = null; - if (current?.terminate) current.terminate(); + const termination = current?.terminate ? Promise.resolve(current.terminate()) : Promise.resolve(); rejectPending(new Error('Indexer worker stopped')); + return termination; }; return { buildIndex, stop }; diff --git a/app/indexer.js b/app/indexer.js index b55dbed..925f381 100644 --- a/app/indexer.js +++ b/app/indexer.js @@ -5,7 +5,9 @@ const Database = require('better-sqlite3'); const TEXT_LIMIT = 10000; const DEFAULT_CLAUDE_DIR = path.join(os.homedir(), '.claude'); -const DEFAULT_DB_PATH = path.join(DEFAULT_CLAUDE_DIR, 'obelisk.sqlite'); +const DEFAULT_CODEX_DIR = path.join(os.homedir(), '.codex'); +const DEFAULT_OBELISK_DIR = path.join(os.homedir(), '.obelisk'); +const DEFAULT_DB_PATH = path.join(DEFAULT_OBELISK_DIR, 'obelisk.sqlite'); const DEFAULT_PROJECTS_DIR = path.join(DEFAULT_CLAUDE_DIR, 'projects'); const DEFAULT_HISTORY_PATH = path.join(DEFAULT_CLAUDE_DIR, 'history.jsonl'); @@ -20,13 +22,17 @@ function resolveSchemaPath() { return found; } +function installSchema(db, schemaPath = resolveSchemaPath()) { + db.exec(fs.readFileSync(schemaPath, 'utf8')); + migrateDb(db); +} + function openIndexDb({ dbPath = DEFAULT_DB_PATH, schemaPath = resolveSchemaPath(), DatabaseImpl = Database } = {}) { fs.mkdirSync(path.dirname(dbPath), { recursive: true }); const db = new DatabaseImpl(dbPath); db.pragma('journal_mode = WAL'); db.pragma('synchronous = NORMAL'); - db.exec(fs.readFileSync(schemaPath, 'utf8')); - migrateDb(db); + installSchema(db, schemaPath); return db; } @@ -36,13 +42,54 @@ function ensureColumn(db, table, column, definition) { } function migrateDb(db) { + ensureColumn(db, 'sessions', 'source', "TEXT DEFAULT 'claude'"); ensureColumn(db, 'messages', 'content_type', 'TEXT'); ensureColumn(db, 'messages', 'is_meta', 'INTEGER DEFAULT 0'); + ensureColumn(db, 'messages', 'source', "TEXT DEFAULT 'claude'"); ensureColumn(db, 'memories', 'anchors', 'TEXT'); ensureColumn(db, 'memories', 'deleted_at', 'TEXT'); ensureColumn(db, 'memories', 'deleted_reason', 'TEXT'); } +function copyMemoriesFromDb(db, sourceDbPath) { + if (!sourceDbPath || !fs.existsSync(sourceDbPath)) return false; + db.prepare('ATTACH DATABASE ? AS previous_obelisk').run(sourceDbPath); + try { + const hasMemories = db.prepare(` + SELECT name FROM previous_obelisk.sqlite_master + WHERE type='table' AND name='memories' + `).get(); + if (!hasMemories) return false; + + const sourceColumns = new Set( + db.prepare('PRAGMA previous_obelisk.table_info(memories)').all().map(column => column.name), + ); + const targetColumns = [ + 'id', + 'session_id', + 'project', + 'message_start', + 'message_end', + 'path', + 'anchors', + 'summary', + 'created_at', + 'deleted_at', + 'deleted_reason', + ]; + const selectList = targetColumns + .map(column => sourceColumns.has(column) ? column : `NULL AS ${column}`) + .join(','); + db.exec(` + INSERT OR REPLACE INTO memories (${targetColumns.join(',')}) + SELECT ${selectList} FROM previous_obelisk.memories + `); + return true; + } finally { + db.exec('DETACH DATABASE previous_obelisk'); + } +} + function trunc(s) { return typeof s === 'string' && s.length > TEXT_LIMIT ? s.slice(0, TEXT_LIMIT) : s; } @@ -128,6 +175,12 @@ function normalizeObservedCwd(cwd) { return path.normalize(cwd); } +function projectSlugFromPath(projectPath) { + const normalized = normalizeObservedCwd(projectPath); + if (!normalized) return null; + return '-' + normalized.replace(/^[\\/]+/, '').replace(/[\\/]+/g, '-'); +} + function inferProjectPath(project, observedCwds = []) { const byPath = new Map(); for (const cwd of observedCwds) { @@ -158,6 +211,7 @@ function normalizeChangedPath(projectsDir, changedPath) { function jsonlFileInfoFromPath(projectsDir, changedPath) { const fp = normalizeChangedPath(projectsDir, changedPath); if (!fp || !fp.endsWith('.jsonl')) return null; + if (!fs.existsSync(fp)) return null; const rel = path.relative(projectsDir, fp); if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null; const parts = rel.split(path.sep); @@ -191,8 +245,10 @@ function sessionIdFromChangedPath(projectsDir, changedPath) { const rel = path.relative(projectsDir, fp); if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null; const parts = rel.split(path.sep); - if (parts.length === 2 && parts[1].endsWith('.jsonl')) return parts[1].slice(0, -6); - if (parts.length >= 3) return parts[1] || null; + if (parts.length === 2 && parts[1].endsWith('.jsonl')) { + return fs.existsSync(fp) ? parts[1].slice(0, -6) : null; + } + if (parts.length >= 3) return fs.existsSync(fp) ? parts[1] || null : null; return null; } @@ -253,6 +309,70 @@ function discoverJsonlFilesFull({ projectsDir = DEFAULT_PROJECTS_DIR } = {}) { return files; } +function discoverCodexJsonlFiles({ codexDir = DEFAULT_CODEX_DIR, changedPaths = undefined } = {}) { + if (Array.isArray(changedPaths) && changedPaths.length) { + const changedFiles = discoverCodexJsonlFilesForChanges({ codexDir, changedPaths }); + if (changedFiles.length) return changedFiles; + return []; + } + return discoverCodexJsonlFilesFull({ codexDir }); +} + +function codexSessionsDir(codexDir = DEFAULT_CODEX_DIR) { + return path.join(codexDir, 'sessions'); +} + +function normalizeChangedPathForRoot(rootDir, changedPath) { + if (!changedPath) return null; + const raw = String(changedPath); + return path.isAbsolute(raw) ? path.normalize(raw) : path.normalize(path.join(rootDir, raw)); +} + +function isPathInside(rootDir, candidate) { + if (!rootDir || !candidate) return false; + const rel = path.relative(rootDir, candidate); + return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel); +} + +function discoverCodexJsonlFilesForChanges({ codexDir = DEFAULT_CODEX_DIR, changedPaths = [] } = {}) { + const files = []; + const sessionsDir = codexSessionsDir(codexDir); + for (const changedPath of changedPaths) { + const rootRelativePath = normalizeChangedPathForRoot(codexDir, changedPath); + if (!rootRelativePath) continue; + if (path.normalize(rootRelativePath) === path.join(codexDir, 'session_index.jsonl')) { + return discoverCodexJsonlFilesFull({ codexDir }); + } + const sessionRelativePath = normalizeChangedPathForRoot(sessionsDir, changedPath); + const fp = isPathInside(sessionsDir, rootRelativePath) ? rootRelativePath : sessionRelativePath; + if (!fp.endsWith('.jsonl') || !isPathInside(sessionsDir, fp)) continue; + if (!fs.existsSync(fp)) continue; + files.push({ path: fp, source: 'codex' }); + } + return dedupeFileInfos(files); +} + +function discoverCodexJsonlFilesFull({ codexDir = DEFAULT_CODEX_DIR } = {}) { + const root = codexSessionsDir(codexDir); + const files = []; + if (!fs.existsSync(root)) return files; + const stack = [root]; + while (stack.length) { + const current = stack.pop(); + let entries; + try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { continue; } + for (const entry of entries) { + const fp = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(fp); + } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + files.push({ path: fp, source: 'codex' }); + } + } + } + return files.sort((a, b) => a.path.localeCompare(b.path)); +} + function needsReindex(db, fp) { const mt = fs.statSync(fp).mtimeMs; const row = db.prepare('SELECT mtime, lines_processed FROM index_state WHERE jsonl_path = ?').get(fp); @@ -264,10 +384,10 @@ function indexJsonl(db, fi) { const { needed, skip, mtime } = needsReindex(db, fi.path); if (!needed) return; const ins = { - ses: db.prepare('INSERT OR REPLACE INTO sessions (id,title,project,project_path,started_at,ended_at,git_branch,version,message_count,jsonl_path) 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 (?,?,?,?,?,?,?,?,?,?,?)'), msg: db.prepare(` - INSERT INTO messages (uuid,session_id,type,parent_uuid,timestamp,role,text,content_type,is_meta,model,is_sidechain,agent_id,input_tokens,output_tokens,cwd,skill) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + INSERT INTO messages (uuid,session_id,type,parent_uuid,timestamp,role,text,content_type,is_meta,model,is_sidechain,agent_id,input_tokens,output_tokens,cwd,skill,source) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(uuid) DO UPDATE SET session_id=excluded.session_id, type=excluded.type, @@ -283,7 +403,8 @@ function indexJsonl(db, fi) { input_tokens=excluded.input_tokens, output_tokens=excluded.output_tokens, cwd=excluded.cwd, - skill=excluded.skill + skill=excluded.skill, + source=excluded.source `), tc: db.prepare('INSERT OR REPLACE INTO tool_calls (id,message_uuid,session_id,name,input_json,file_path) VALUES (?,?,?,?,?,?)'), tr: db.prepare('INSERT OR REPLACE INTO tool_results (tool_use_id,message_uuid,session_id,content,file_path,is_error) VALUES (?,?,?,?,?,?)'), @@ -336,7 +457,7 @@ function indexJsonl(db, fi) { ins.msg.run(obj.uuid, sid, obj.type, obj.parentUuid || null, ts, msg.role || obj.type, text, contentType, isMeta, msg.model || null, obj.isSidechain ? 1 : 0, aid, usage.input_tokens || null, usage.output_tokens || null, - obj.cwd || null, obj.attributionSkill || null); + obj.cwd || null, obj.attributionSkill || null, 'claude'); } if (obj.type === 'assistant' && Array.isArray(msg.content)) { for (const b of msg.content) { @@ -356,12 +477,464 @@ function indexJsonl(db, fi) { if (!fi.isSubagent) { const pp = inferProjectPath(fi.project, sm.cwds); - ins.ses.run(fi.sessionId, sm.title, fi.project, pp, sm.started_at, sm.ended_at, sm.git_branch, sm.version, sm.n, fi.path); + ins.ses.run(fi.sessionId, sm.title, fi.project, pp, sm.started_at, sm.ended_at, sm.git_branch, sm.version, sm.n, fi.path, 'claude'); } ins.idx.run(fi.path, mtime, lineNum); return { sessionId: fi.sessionId, path: fi.path }; } +function codexDbId(id) { + if (!id) return null; + const raw = String(id).replace(/^codex:/, ''); + return `codex:${raw}`; +} + +function codexRawId(id) { + return id ? String(id).replace(/^codex:/, '') : null; +} + +function codexLineUuid(threadId, lineNum) { + return `codex:${codexRawId(threadId)}:${String(lineNum).padStart(6, '0')}`; +} + +function codexCallId(callId) { + if (!callId) return null; + return `codex:${String(callId).replace(/^codex:/, '')}`; +} + +function codexParentThreadId(meta) { + const subagent = meta?.source?.subagent; + return subagent?.thread_spawn?.parent_thread_id + || meta?.forked_from_id + || subagent?.parent_thread_id + || null; +} + +function codexIsGuardianThread(meta, records = []) { + const subagent = meta?.source?.subagent; + if (subagent?.other === 'guardian') return true; + if (meta?.thread_source !== 'subagent') return false; + return records.some(({ obj }) => obj?.payload?.model === 'codex-auto-review' || obj?.model === 'codex-auto-review'); +} + +function deleteCodexThreadRows(db, threadRawId) { + const threadId = codexDbId(threadRawId); + if (!threadId) return; + db.prepare(` + DELETE FROM tool_results + WHERE session_id = ? + OR message_uuid IN (SELECT uuid FROM messages WHERE session_id = ? OR agent_id = ?) + `).run(threadId, threadId, threadId); + db.prepare(` + DELETE FROM tool_calls + WHERE session_id = ? + OR message_uuid IN (SELECT uuid FROM messages WHERE session_id = ? OR agent_id = ?) + `).run(threadId, threadId, threadId); + db.prepare('DELETE FROM messages WHERE session_id = ? OR agent_id = ?').run(threadId, threadId); + db.prepare('DELETE FROM subagents WHERE agent_id = ? OR session_id = ?').run(threadId, threadId); + db.prepare('DELETE FROM summaries WHERE session_id = ?').run(threadId); + db.prepare('DELETE FROM sessions WHERE id = ?').run(threadId); +} + +function readCodexGuardianThreadInfo(filePath) { + const records = []; + let metaRecord = null; + let lineNum = 0; + readLines(filePath, (line) => { + lineNum++; + let obj; + try { + obj = JSON.parse(line); + } catch { + return; + } + records.push({ lineNum, obj }); + if (obj?.type === 'session_meta' && obj.payload?.id) { + metaRecord = { lineNum, obj }; + if (obj.payload?.source?.subagent?.other === 'guardian') return false; + if (obj.payload?.thread_source !== 'subagent') return false; + } + if (metaRecord && codexIsGuardianThread(metaRecord.obj.payload, records)) return false; + }); + const meta = metaRecord?.obj?.payload; + if (!meta || !codexIsGuardianThread(meta, records)) return null; + return { threadRawId: codexRawId(meta.id), lineNum }; +} + +function codexAgentNickname(meta) { + return meta?.agent_nickname + || meta?.source?.subagent?.thread_spawn?.agent_nickname + || null; +} + +function codexAgentRole(meta) { + return meta?.agent_role + || meta?.source?.subagent?.thread_spawn?.agent_role + || null; +} + +function parseCodexJsonInput(value) { + if (value === null || value === undefined || value === '') return {}; + if (typeof value !== 'string') return value; + try { return JSON.parse(value); } catch { return value; } +} + +function codexUsage(payload) { + const usage = payload?.info?.last_token_usage || payload?.info?.total_token_usage || payload?.last_token_usage || null; + if (!usage) return {}; + return { + inputTokens: usage.input_tokens ?? null, + outputTokens: usage.output_tokens ?? null, + }; +} + +function codexEventText(payload) { + if (typeof payload?.message === 'string') return payload.message; + if (Array.isArray(payload?.text_elements) && payload.text_elements.length) { + const parts = payload.text_elements.map(item => typeof item === 'string' ? item : item?.text).filter(Boolean); + if (parts.length) return parts.join('\n'); + } + if (typeof payload?.text === 'string') return payload.text; + return null; +} + +function codexMessagePayloadText(payload) { + if (!Array.isArray(payload?.content)) return null; + const parts = []; + for (const block of payload.content) { + if (typeof block?.text === 'string') parts.push(block.text); + } + return parts.length ? parts.join('\n') : null; +} + +function codexVisibleMessageKey(role, text) { + return `${role || ''}\u0000${text || ''}`; +} + +function codexToolInput(payload) { + if (payload?.type === 'custom_tool_call') return parseCodexJsonInput(payload.input); + if (payload?.type === 'tool_search_call') return parseCodexJsonInput(payload.arguments); + if (payload?.type === 'web_search_call') return { action: payload.action || null }; + return parseCodexJsonInput(payload?.arguments); +} + +function codexToolOutput(payload) { + if (typeof payload?.output === 'string') return payload.output; + if (payload?.output !== undefined) return JSON.stringify(payload.output); + if (payload?.tools !== undefined) return JSON.stringify(payload.tools); + if (payload?.execution !== undefined) return JSON.stringify(payload.execution); + return null; +} + +function upsertCodexSubagent(db, { + agentId, + sessionId, + parentToolUseId = null, + agentType = null, + description = null, + durationMs = null, + totalTokens = null, +} = {}) { + if (!agentId || !sessionId) return; + db.prepare(` + INSERT INTO subagents (agent_id,session_id,parent_tool_use_id,agent_type,description,duration_ms,total_tokens) + VALUES (?,?,?,?,?,?,?) + ON CONFLICT(agent_id) DO UPDATE SET + session_id=excluded.session_id, + parent_tool_use_id=COALESCE(excluded.parent_tool_use_id, subagents.parent_tool_use_id), + agent_type=COALESCE(excluded.agent_type, subagents.agent_type), + description=COALESCE(excluded.description, subagents.description), + duration_ms=COALESCE(excluded.duration_ms, subagents.duration_ms), + total_tokens=COALESCE(excluded.total_tokens, subagents.total_tokens) + `).run(agentId, sessionId, parentToolUseId, agentType, description, durationMs, totalTokens); +} + +function indexCodexJsonl(db, fi) { + const state = needsReindex(db, fi.path); + if (!state.needed) { + const guardian = readCodexGuardianThreadInfo(fi.path); + if (guardian) deleteCodexThreadRows(db, guardian.threadRawId); + return null; + } + const mtime = state.mtime; + const records = []; + let lineNum = 0; + readLines(fi.path, (line) => { + lineNum++; + try { + records.push({ lineNum, obj: JSON.parse(line) }); + } catch {} + }); + + const metaRecord = records.find(r => r.obj?.type === 'session_meta' && r.obj.payload?.id); + if (!metaRecord) { + db.prepare('INSERT OR REPLACE INTO index_state (jsonl_path,mtime,lines_processed) VALUES (?,?,?)').run(fi.path, mtime, lineNum); + return null; + } + + const meta = metaRecord.obj.payload; + const threadRawId = codexRawId(meta.id); + if (codexIsGuardianThread(meta, records)) { + deleteCodexThreadRows(db, threadRawId); + db.prepare('INSERT OR REPLACE INTO index_state (jsonl_path,mtime,lines_processed) VALUES (?,?,?)').run(fi.path, mtime, lineNum); + return null; + } + const parentRawId = codexParentThreadId(meta); + const sessionId = codexDbId(parentRawId || threadRawId); + const agentId = parentRawId ? codexDbId(threadRawId) : null; + const isSidechain = agentId ? 1 : 0; + const projectPath = normalizeObservedCwd(meta.cwd); + const project = projectSlugFromPath(projectPath); + const sm = { + started_at: meta.timestamp || metaRecord.obj.timestamp || null, + ended_at: meta.timestamp || metaRecord.obj.timestamp || null, + git_branch: meta.git?.branch || null, + version: meta.cli_version || null, + title: null, + n: 0, + cwds: projectPath ? [projectPath] : [], + lastMessageUuid: null, + lastTextAssistantUuid: null, + totalInputTokens: 0, + totalOutputTokens: 0, + }; + + const ins = { + 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 (?,?,?,?,?,?,?,?,?,?,?)'), + msg: db.prepare(` + INSERT INTO messages (uuid,session_id,type,parent_uuid,timestamp,role,text,content_type,is_meta,model,is_sidechain,agent_id,input_tokens,output_tokens,cwd,skill,source) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(uuid) DO UPDATE SET + session_id=excluded.session_id, + type=excluded.type, + parent_uuid=excluded.parent_uuid, + timestamp=excluded.timestamp, + role=excluded.role, + text=excluded.text, + content_type=excluded.content_type, + is_meta=excluded.is_meta, + model=excluded.model, + is_sidechain=excluded.is_sidechain, + agent_id=excluded.agent_id, + input_tokens=excluded.input_tokens, + output_tokens=excluded.output_tokens, + cwd=excluded.cwd, + skill=excluded.skill, + source=excluded.source + `), + tc: db.prepare('INSERT OR REPLACE INTO tool_calls (id,message_uuid,session_id,name,input_json,file_path) VALUES (?,?,?,?,?,?)'), + tr: db.prepare('INSERT OR REPLACE INTO tool_results (tool_use_id,message_uuid,session_id,content,file_path,is_error) VALUES (?,?,?,?,?,?)'), + idx: db.prepare('INSERT OR REPLACE INTO index_state (jsonl_path,mtime,lines_processed) VALUES (?,?,?)'), + dur: db.prepare('UPDATE messages SET turn_duration_ms=? WHERE uuid=?'), + usage: db.prepare('UPDATE messages SET input_tokens=?, output_tokens=? WHERE uuid=?'), + }; + + let currentCwd = projectPath; + let currentModel = null; + const eventMessageKeys = new Set(); + const callMessageUuids = new Map(); + + for (const { obj } of records) { + if (obj?.type !== 'event_msg') continue; + const payload = obj.payload || {}; + if (payload.type !== 'user_message' && payload.type !== 'agent_message') continue; + const text = codexEventText(payload); + if (text === null) continue; + eventMessageKeys.add(codexVisibleMessageKey(payload.type === 'user_message' ? 'user' : 'assistant', text)); + } + + const updateBounds = (ts) => { + if (!ts) return; + if (!sm.started_at || ts < sm.started_at) sm.started_at = ts; + if (!sm.ended_at || ts > sm.ended_at) sm.ended_at = ts; + }; + + const insertMessage = ({ uuid, type, role, text = null, contentType = 'text', timestamp, isMeta = 0 }) => { + ins.msg.run( + uuid, + sessionId, + type, + sm.lastMessageUuid, + timestamp || null, + role, + trunc(text), + contentType, + isMeta, + currentModel, + isSidechain, + agentId, + null, + null, + currentCwd, + null, + 'codex', + ); + sm.lastMessageUuid = uuid; + if (!agentId) sm.n++; + if (type === 'assistant' && contentType === 'text') sm.lastTextAssistantUuid = uuid; + updateBounds(timestamp); + return uuid; + }; + + for (const { lineNum: currentLine, obj } of records) { + const ts = obj.timestamp || null; + if (obj.type === 'session_meta') { + if (obj.payload?.cwd) { + currentCwd = normalizeObservedCwd(obj.payload.cwd) || currentCwd; + if (currentCwd) sm.cwds.push(currentCwd); + } + if (obj.payload?.git?.branch) sm.git_branch = obj.payload.git.branch; + if (obj.payload?.cli_version) sm.version = obj.payload.cli_version; + updateBounds(obj.payload?.timestamp || ts); + continue; + } + if (obj.type === 'turn_context') { + currentCwd = normalizeObservedCwd(obj.payload?.cwd) || currentCwd; + currentModel = obj.payload?.model || currentModel; + if (currentCwd) sm.cwds.push(currentCwd); + updateBounds(ts); + continue; + } + if (obj.type === 'event_msg') { + const payload = obj.payload || {}; + if (payload.type === 'user_message' || payload.type === 'agent_message' || payload.type === 'agent_reasoning') { + const text = codexEventText(payload); + if (text === null) continue; + const isReasoning = payload.type === 'agent_reasoning'; + insertMessage({ + uuid: codexLineUuid(threadRawId, currentLine), + type: payload.type === 'user_message' ? 'user' : 'assistant', + role: payload.type === 'user_message' ? 'user' : 'assistant', + text, + contentType: isReasoning ? 'thinking' : 'text', + timestamp: ts, + }); + continue; + } + if (payload.type === 'collab_agent_spawn_end' && payload.call_id && payload.new_thread_id) { + const uuid = insertMessage({ + uuid: codexLineUuid(threadRawId, currentLine), + type: 'assistant', + role: 'assistant', + text: null, + contentType: 'tool_use', + timestamp: ts, + }); + const toolId = codexCallId(payload.call_id); + const description = payload.new_agent_nickname || payload.new_agent_role || 'Agent'; + const input = { + description, + subagent_type: payload.new_agent_role || 'Agent', + prompt: payload.prompt || '', + new_thread_id: payload.new_thread_id, + model: payload.model || null, + reasoning_effort: payload.reasoning_effort || null, + }; + ins.tc.run(toolId, uuid, sessionId, 'Agent', truncJson(input), null); + callMessageUuids.set(toolId, uuid); + upsertCodexSubagent(db, { + agentId: codexDbId(payload.new_thread_id), + sessionId, + parentToolUseId: toolId, + agentType: payload.new_agent_role || null, + description, + }); + continue; + } + if (payload.type === 'task_complete') { + if (sm.lastTextAssistantUuid && payload.duration_ms !== undefined) { + ins.dur.run(payload.duration_ms || null, sm.lastTextAssistantUuid); + } + updateBounds(ts); + continue; + } + if (payload.type === 'token_count') { + const usage = codexUsage(payload); + if (usage.inputTokens !== null) sm.totalInputTokens = usage.inputTokens; + if (usage.outputTokens !== null) sm.totalOutputTokens = usage.outputTokens; + if (sm.lastTextAssistantUuid && (usage.inputTokens !== null || usage.outputTokens !== null)) { + ins.usage.run(usage.inputTokens, usage.outputTokens, sm.lastTextAssistantUuid); + } + continue; + } + if (payload.type === 'thread_name_updated' && payload.thread_name) { + sm.title = payload.thread_name; + } + continue; + } + if (obj.type !== 'response_item') continue; + const payload = obj.payload || {}; + if (payload.type === 'message' && payload.role !== 'developer') { + const text = codexMessagePayloadText(payload); + const role = payload.role || 'assistant'; + if (text !== null && !eventMessageKeys.has(codexVisibleMessageKey(role, text))) { + insertMessage({ + uuid: codexLineUuid(threadRawId, currentLine), + type: role === 'user' ? 'user' : 'assistant', + role, + text, + contentType: 'text', + timestamp: ts, + }); + } + continue; + } + if (['function_call', 'custom_tool_call', 'tool_search_call', 'web_search_call'].includes(payload.type) && payload.call_id) { + const uuid = insertMessage({ + uuid: codexLineUuid(threadRawId, currentLine), + type: 'assistant', + role: 'assistant', + text: null, + contentType: 'tool_use', + timestamp: ts, + }); + const name = payload.name || payload.tool || payload.type.replace(/_call$/, ''); + const toolId = codexCallId(payload.call_id); + ins.tc.run(toolId, uuid, sessionId, name, truncJson(codexToolInput(payload)), null); + callMessageUuids.set(toolId, uuid); + continue; + } + if (['function_call_output', 'custom_tool_call_output', 'tool_search_output'].includes(payload.type) && payload.call_id) { + const toolId = codexCallId(payload.call_id); + ins.tr.run(toolId, callMessageUuids.get(toolId) || null, sessionId, trunc(codexToolOutput(payload) || ''), null, payload.is_error ? 1 : 0); + } + } + + if (agentId) { + const tokenTotal = (sm.totalInputTokens || 0) + (sm.totalOutputTokens || 0); + const started = sm.started_at ? new Date(sm.started_at).getTime() : null; + const ended = sm.ended_at ? new Date(sm.ended_at).getTime() : null; + upsertCodexSubagent(db, { + agentId, + sessionId, + agentType: codexAgentRole(meta), + description: codexAgentNickname(meta), + durationMs: started && ended ? ended - started : null, + totalTokens: tokenTotal || null, + }); + } else { + const pp = inferProjectPath(project, sm.cwds); + ins.ses.run(sessionId, sm.title, project, pp, sm.started_at, sm.ended_at, sm.git_branch, sm.version, sm.n, fi.path, 'codex'); + } + ins.idx.run(fi.path, mtime, lineNum); + return { sessionId, path: fi.path }; +} + +function indexCodexSessionIndex(db, { codexDir = DEFAULT_CODEX_DIR } = {}) { + const indexPath = path.join(codexDir, 'session_index.jsonl'); + if (!fs.existsSync(indexPath)) return; + readLines(indexPath, (line) => { + try { + const item = JSON.parse(line); + if (!item.id || !item.thread_name) return; + db.prepare('UPDATE sessions SET title=COALESCE(title, ?), ended_at=COALESCE(ended_at, ?) WHERE id=? AND source=?') + .run(item.thread_name, item.updated_at || null, codexDbId(item.id), 'codex'); + } catch (error) { + console.warn(`Warning: malformed Codex session index line: ${error.message}`); + } + }); +} + function refreshSessionProjectPaths(db) { const sessions = db.prepare('SELECT id, project FROM sessions').all(); const cwdStmt = db.prepare(` @@ -452,6 +1025,24 @@ function rebuildFts(db) { db.exec("INSERT INTO memories_fts(memories_fts) VALUES('rebuild')"); } +function checkpointDb(db) { + try { + db.pragma('wal_checkpoint(TRUNCATE)'); + } catch {} +} + +const MESSAGE_FTS_TRIGGERS = [ + 'messages_fts_ai', + 'messages_fts_ad', + 'messages_fts_au', +]; + +function dropMessageFtsTriggers(db) { + for (const trigger of MESSAGE_FTS_TRIGGERS) { + db.exec(`DROP TRIGGER IF EXISTS ${trigger}`); + } +} + function ensureFtsReady(db, { force = false } = {}) { const marker = '__fts_triggers_ready__'; const ready = db.prepare('SELECT jsonl_path FROM index_state WHERE jsonl_path = ?').get(marker); @@ -477,16 +1068,25 @@ function writeHeartbeat({ dbPath = DEFAULT_DB_PATH, DatabaseImpl = Database } = function buildIndex({ claudeDir = DEFAULT_CLAUDE_DIR, + codexDir = path.join(path.dirname(claudeDir), '.codex'), projectsDir = path.join(claudeDir, 'projects'), historyPath = path.join(claudeDir, 'history.jsonl'), - dbPath = path.join(claudeDir, 'obelisk.sqlite'), + dbPath = DEFAULT_DB_PATH, schemaPath = resolveSchemaPath(), DatabaseImpl = Database, force = false, changedPaths = undefined, + preserveDbPath = null, } = {}) { const db = openIndexDb({ dbPath, schemaPath, DatabaseImpl }); - const files = discoverJsonlFiles({ projectsDir, changedPaths: force ? undefined : changedPaths }); + let messageFtsTriggersDropped = false; + if (preserveDbPath && path.resolve(preserveDbPath) !== path.resolve(dbPath)) { + copyMemoriesFromDb(db, preserveDbPath); + } + const files = [ + ...discoverJsonlFiles({ projectsDir, changedPaths: force ? undefined : changedPaths }), + ...discoverCodexJsonlFiles({ codexDir, changedPaths: force ? undefined : changedPaths }), + ]; const latestSourceMtime = files.reduce((latest, file) => { try { return Math.max(latest, fs.statSync(file.path).mtimeMs); @@ -497,6 +1097,8 @@ function buildIndex({ try { if (force) { + dropMessageFtsTriggers(db); + messageFtsTriggersDropped = true; 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(); @@ -517,9 +1119,9 @@ function buildIndex({ for (const file of files) { db.exec('BEGIN'); try { - const indexed = indexJsonl(db, file); + const indexed = file.source === 'codex' ? indexCodexJsonl(db, file) : indexJsonl(db, file); if (indexed?.sessionId) affectedSessionIds.add(indexed.sessionId); - indexSubagentMeta(db, file); + if (file.source !== 'codex') indexSubagentMeta(db, file); db.exec('COMMIT'); } catch (error) { db.exec('ROLLBACK'); @@ -532,6 +1134,8 @@ function buildIndex({ indexWorkflows(db, { projectsDir }); refreshSessionProjectPaths(db); indexHistory(db, { historyPath }); + indexCodexSessionIndex(db, { codexDir }); + if (messageFtsTriggersDropped) installSchema(db, schemaPath); ftsRebuilt = ensureFtsReady(db, { force }); writeIndexMarker(db, '__last_build__'); writeIndexMarker(db, '__app_heartbeat__'); @@ -545,6 +1149,14 @@ function buildIndex({ } return { files: files.length, latestSourceMtime, affectedSessionIds: [...affectedSessionIds], ftsRebuilt }; } finally { + if (messageFtsTriggersDropped) { + try { + installSchema(db, schemaPath); + } catch (error) { + console.warn(`Warning: failed to restore message FTS triggers: ${error.message}`); + } + } + checkpointDb(db); db.close(); } } diff --git a/app/main.js b/app/main.js index 7993f4e..2c38a51 100644 --- a/app/main.js +++ b/app/main.js @@ -31,6 +31,7 @@ function detectClaudeDir() { } const DEFAULT_CLAUDE_DIR = detectClaudeDir(); +const DEFAULT_CODEX_DIR = path.join(os.homedir(), '.codex'); let db; let indexerService; @@ -41,14 +42,106 @@ function getConfiguredClaudeDir() { return persisted.claudeDir || DEFAULT_CLAUDE_DIR; } -function getPathsForClaudeDir(claudeDir = getConfiguredClaudeDir()) { +function getConfiguredCodexDir() { + const persisted = loadPersistedSettings(); + return persisted.codexDir || DEFAULT_CODEX_DIR; +} + +function getPathsForClaudeDir(claudeDir = getConfiguredClaudeDir(), codexDir = getConfiguredCodexDir()) { return { claudeDir, - dbPath: path.join(claudeDir, 'obelisk.sqlite'), + codexDir, + dbPath: path.join(OBELISK_DIR, 'obelisk.sqlite'), projectsDir: path.join(claudeDir, 'projects'), }; } +function migrateLegacyDbIfNeeded(paths = getPathsForClaudeDir()) { + if (fs.existsSync(paths.dbPath)) return; + const legacyDbPath = path.join(paths.claudeDir, 'obelisk.sqlite'); + if (!fs.existsSync(legacyDbPath)) return; + try { + fs.mkdirSync(path.dirname(paths.dbPath), { recursive: true }); + fs.copyFileSync(legacyDbPath, paths.dbPath); + } catch (error) { + console.warn?.(`Obelisk legacy DB migration skipped: ${error.message}`); + } +} + +function rebuildTempDbPath(dbPath) { + return path.join( + path.dirname(dbPath), + `${path.basename(dbPath)}.rebuild-${process.pid}-${Date.now()}.tmp`, + ); +} + +function dbFileSet(dbPath) { + return [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]; +} + +function cleanupDbFiles(dbPath) { + for (const filePath of dbFileSet(dbPath)) { + try { + fs.rmSync(filePath, { force: true }); + } catch {} + } +} + +function replaceDbWithTemp(tempDbPath, dbPath) { + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + for (const sidecar of [`${dbPath}-wal`, `${dbPath}-shm`]) { + try { + fs.rmSync(sidecar, { force: true }); + } catch {} + } + fs.renameSync(tempDbPath, dbPath); + for (const suffix of ['-wal', '-shm']) { + const tempSidecar = `${tempDbPath}${suffix}`; + if (!fs.existsSync(tempSidecar)) continue; + fs.renameSync(tempSidecar, `${dbPath}${suffix}`); + } +} + +function resolveSchemaPath() { + const candidates = [ + path.join(__dirname, 'schema.sql'), + path.join(__dirname, '..', 'scripts', 'schema.sql'), + process.resourcesPath ? path.join(process.resourcesPath, 'scripts', 'schema.sql') : null, + ].filter(Boolean); + return candidates.find(p => fs.existsSync(p)); +} + +function ensureColumn(db, table, column, definition) { + const columns = db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name); + if (!columns.includes(column)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`); +} + +function tableExists(db, table) { + return Boolean(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(table)); +} + +function migrateExistingColumns(db) { + if (tableExists(db, 'sessions')) ensureColumn(db, 'sessions', 'source', "TEXT DEFAULT 'claude'"); + if (tableExists(db, 'messages')) { + ensureColumn(db, 'messages', 'content_type', 'TEXT'); + ensureColumn(db, 'messages', 'is_meta', 'INTEGER DEFAULT 0'); + ensureColumn(db, 'messages', 'source', "TEXT DEFAULT 'claude'"); + } + if (tableExists(db, 'memories')) { + ensureColumn(db, 'memories', 'anchors', 'TEXT'); + ensureColumn(db, 'memories', 'deleted_at', 'TEXT'); + ensureColumn(db, 'memories', 'deleted_reason', 'TEXT'); + } +} + +function migrateDb(db) { + if (typeof db.exec !== 'function' || typeof db.prepare !== 'function') return; + migrateExistingColumns(db); + const schemaPath = resolveSchemaPath(); + if (schemaPath) db.exec(fs.readFileSync(schemaPath, 'utf8')); + migrateExistingColumns(db); +} + function closeDb() { if (db) db.close(); db = null; @@ -59,6 +152,7 @@ function openDb(dbPath = getPathsForClaudeDir().dbPath) { if (!fs.existsSync(dbPath)) return null; db = new Database(dbPath, { readonly: false }); db.pragma('journal_mode = WAL'); + migrateDb(db); return db; } @@ -75,15 +169,30 @@ function notifyIndexUpdated(result = {}) { } } +function sourceWhereClause(opts = {}, column = 'source') { + if (opts.includeCodex || opts.source === 'all') return { sql: '', params: [] }; + if (opts.source) return { sql: `COALESCE(${column}, 'claude') = ?`, params: [opts.source] }; + return { sql: `COALESCE(${column}, 'claude') = 'claude'`, params: [] }; +} + +function appendWhere(sql, params, clause) { + if (!clause) return sql; + return `${sql}${sql.includes(' WHERE ') ? ' AND ' : ' WHERE '}${clause}`; +} + function startIndexerService({ buildOnStart = false } = {}) { const paths = getPathsForClaudeDir(); + migrateLegacyDbIfNeeded(paths); + const codexSessionsDir = path.join(paths.codexDir, 'sessions'); indexerService = createIndexerService({ projectsDir: paths.projectsDir, + watchDirs: [paths.projectsDir, codexSessionsDir], buildIndex: async ({ reason, changedPaths }) => { const result = await indexerWorker.buildIndex({ reason, changedPaths, claudeDir: paths.claudeDir, + codexDir: paths.codexDir, projectsDir: paths.projectsDir, dbPath: paths.dbPath, }); @@ -99,7 +208,9 @@ function startIndexerService({ buildOnStart = false } = {}) { function startBackgroundResources({ runStartupBuild = false } = {}) { if (!indexerWorker) indexerWorker = createWorkerBuildIndex(); - openDb(); + const paths = getPathsForClaudeDir(); + migrateLegacyDbIfNeeded(paths); + openDb(paths.dbPath); if (!indexerService) { const service = startIndexerService({ buildOnStart: false }); if (runStartupBuild) service.runBuildNow('startup'); @@ -107,11 +218,11 @@ function startBackgroundResources({ runStartupBuild = false } = {}) { if (!obeliskWatcher) startObeliskWatcher(); } -async function stopIndexerServiceAndWait() { +async function stopIndexerServiceAndWait({ waitForIdle = true } = {}) { const service = indexerService; if (!service) return; service.stop(); - if (typeof service.idle === 'function') await service.idle(); + if (waitForIdle && typeof service.idle === 'function') await service.idle(); if (indexerService === service) indexerService = null; } @@ -157,7 +268,7 @@ function createWindow() { }); if (isDev) { - win.loadURL('http://localhost:5173'); + win.loadURL(process.env.OBELISK_DEV_SERVER_URL || 'http://localhost:5173'); if (shouldOpenDevTools) { win.webContents.openDevTools(); } @@ -225,9 +336,14 @@ app.on('window-all-closed', () => { ipcMain.handle('db:getSessions', (_, opts = {}) => { if (!db) return []; const { project, limit = 200 } = opts; - let sql = `SELECT id, title, project, project_path, started_at, ended_at, git_branch, version, message_count, jsonl_path FROM sessions`; + let sql = `SELECT id, title, project, project_path, started_at, ended_at, git_branch, version, message_count, jsonl_path, source FROM sessions`; const params = []; - if (project) { sql += ` WHERE project LIKE ?`; params.push(project); } + const sourceFilter = sourceWhereClause(opts); + if (sourceFilter.sql) { + sql = appendWhere(sql, params, sourceFilter.sql); + params.push(...sourceFilter.params); + } + if (project) { sql = appendWhere(sql, params, `project LIKE ?`); params.push(project); } sql += ` ORDER BY COALESCE(ended_at, started_at) DESC LIMIT ?`; params.push(limit); return db.prepare(sql).all(...params); @@ -238,8 +354,8 @@ ipcMain.handle('db:getSessionMessages', (_, sessionId) => { return db.prepare(` SELECT m.uuid, m.session_id, m.type, m.parent_uuid, m.timestamp, m.role, m.text, m.model, m.is_sidechain, m.agent_id, m.input_tokens, m.output_tokens, m.cwd, m.skill, m.turn_duration_ms, - m.content_type, m.is_meta - FROM messages m WHERE m.session_id = ? AND m.agent_id IS NULL ORDER BY m.timestamp + m.content_type, m.is_meta, m.source + FROM messages m WHERE m.session_id = ? AND m.agent_id IS NULL ORDER BY m.timestamp, m.uuid `).all(sessionId); }); @@ -272,8 +388,8 @@ ipcMain.handle('db:getSubagentMessages', (_, agentId) => { return db.prepare(` SELECT m.uuid, m.session_id, m.type, m.parent_uuid, m.timestamp, m.role, m.text, m.model, m.is_sidechain, m.agent_id, m.input_tokens, m.output_tokens, m.cwd, m.skill, m.turn_duration_ms, - m.content_type, m.is_meta - FROM messages m WHERE m.agent_id = ? ORDER BY m.timestamp + m.content_type, m.is_meta, m.source + FROM messages m WHERE m.agent_id = ? ORDER BY m.timestamp, m.uuid `).all(agentId); }); @@ -310,9 +426,45 @@ ipcMain.handle('db:getMemories', () => { ipcMain.handle('db:getMessageFullText', (_, uuid) => { if (!db) return null; - const msg = db.prepare('SELECT session_id, agent_id FROM messages WHERE uuid=?').get(uuid); + const msg = db.prepare('SELECT session_id, agent_id, source FROM messages WHERE uuid=?').get(uuid); if (!msg) return null; + if (msg.source === 'codex' || String(uuid).startsWith('codex:')) { + const match = /^codex:([^:]+):(\d+)$/.exec(String(uuid)); + if (!match) return null; + const rawThreadId = match[1]; + const targetLine = Number(match[2]); + let jsonlPath = null; + if (!msg.agent_id) { + jsonlPath = db.prepare('SELECT jsonl_path FROM sessions WHERE id=?').get(msg.session_id)?.jsonl_path || null; + } + if (!jsonlPath) { + jsonlPath = db.prepare(` + SELECT jsonl_path FROM index_state + WHERE jsonl_path LIKE ? AND jsonl_path LIKE '%.jsonl' + ORDER BY length(jsonl_path) ASC + LIMIT 1 + `).get(`%${rawThreadId}.jsonl`)?.jsonl_path || null; + } + if (!jsonlPath || !fs.existsSync(jsonlPath)) return null; + const lines = fs.readFileSync(jsonlPath, 'utf-8').split('\n').filter(Boolean); + const line = lines[targetLine - 1]; + if (!line) return null; + try { + const obj = JSON.parse(line); + const payload = obj.payload || {}; + if (obj.type === 'event_msg') { + if (typeof payload.message === 'string') return payload.message; + if (typeof payload.text === 'string') return payload.text; + } + if (obj.type === 'response_item' && payload.type === 'message' && Array.isArray(payload.content)) { + const parts = payload.content.map(b => b.text).filter(Boolean); + return parts.join('\n') || null; + } + } catch {} + return null; + } + // Resolve JSONL path let jsonlPath = null; if (msg.agent_id) { @@ -378,58 +530,68 @@ ipcMain.handle('db:restoreMemory', (_, id) => { return true; }); -ipcMain.handle('db:getProjects', () => { +ipcMain.handle('db:getProjects', (_, opts = {}) => { if (!db) return []; + const sourceFilter = sourceWhereClause(opts); + const where = sourceFilter.sql ? `WHERE ${sourceFilter.sql}` : ''; return db.prepare(` SELECT project, project_path, COUNT(*) as session_count, MAX(COALESCE(ended_at, started_at)) as last_active - FROM sessions WHERE project IS NOT NULL + FROM sessions ${where ? `${where} AND` : 'WHERE'} project IS NOT NULL GROUP BY project ORDER BY last_active DESC - `).all(); + `).all(...sourceFilter.params); }); -ipcMain.handle('db:getStats', () => { +ipcMain.handle('db:getStats', (_, opts = {}) => { if (!db) return { sessions: 0, memories: 0, memoriesArchived: 0 }; - const sessions = db.prepare('SELECT COUNT(*) as c FROM sessions').get()?.c || 0; + const sourceFilter = sourceWhereClause(opts); + const where = sourceFilter.sql ? `WHERE ${sourceFilter.sql}` : ''; + const sessions = db.prepare(`SELECT COUNT(*) as c FROM sessions ${where}`).get(...sourceFilter.params)?.c || 0; const memories = db.prepare('SELECT COUNT(*) as c FROM memories WHERE deleted_at IS NULL').get()?.c || 0; const memoriesArchived = db.prepare('SELECT COUNT(*) as c FROM memories WHERE deleted_at IS NOT NULL').get()?.c || 0; return { sessions, memories, memoriesArchived }; }); -ipcMain.handle('db:getUsageStats', () => { +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 daily = db.prepare(` SELECT DATE(timestamp) as day, SUM(COALESCE(input_tokens,0) + COALESCE(output_tokens,0)) as tokens FROM messages WHERE timestamp IS NOT NULL AND (input_tokens IS NOT NULL OR output_tokens IS NOT NULL) + ${sourceSql} GROUP BY DATE(timestamp) ORDER BY day - `).all(); + `).all(...sourceFilter.params); const totalTokens = db.prepare(` SELECT SUM(COALESCE(input_tokens,0) + COALESCE(output_tokens,0)) as total FROM messages - `).get()?.total || 0; + ${sourceFilter.sql ? `WHERE ${sourceFilter.sql}` : ''} + `).get(...sourceFilter.params)?.total || 0; const peakDay = db.prepare(` SELECT DATE(timestamp) as day, SUM(COALESCE(input_tokens,0) + COALESCE(output_tokens,0)) as tokens FROM messages WHERE timestamp IS NOT NULL AND (input_tokens IS NOT NULL OR output_tokens IS NOT NULL) + ${sourceSql} GROUP BY DATE(timestamp) ORDER BY tokens DESC LIMIT 1 - `).get() || null; + `).get(...sourceFilter.params) || null; const longestTurn = db.prepare(` SELECT turn_duration_ms, uuid, session_id, timestamp FROM messages WHERE turn_duration_ms IS NOT NULL + ${sourceSql} ORDER BY turn_duration_ms DESC LIMIT 1 - `).get() || null; + `).get(...sourceFilter.params) || null; return { daily, totalTokens, peakDay, longestTurn }; }); @@ -539,32 +701,66 @@ function savePersistedSettings(settings) { ipcMain.handle('settings:get', () => { const persisted = loadPersistedSettings(); - const { claudeDir, dbPath: dbFile } = getPathsForClaudeDir(persisted.claudeDir || DEFAULT_CLAUDE_DIR); + const { claudeDir, codexDir, dbPath: dbFile } = getPathsForClaudeDir( + persisted.claudeDir || DEFAULT_CLAUDE_DIR, + persisted.codexDir || DEFAULT_CODEX_DIR, + ); const recapDir = persisted.recapDir || RECAP_DIR; - const exists = fs.existsSync(claudeDir); - let sessionCount = 0; + const claudeExists = fs.existsSync(claudeDir); + const codexExists = fs.existsSync(codexDir); + let claudeSessionCount = 0; + let codexSessionCount = 0; let memoryCount = 0; - let lastIndexed = ''; + let claudeLastIndexed = ''; + let codexLastIndexed = ''; if (db) { try { - sessionCount = db.prepare('SELECT COUNT(*) as c FROM sessions').get()?.c || 0; + claudeSessionCount = db.prepare("SELECT COUNT(*) as c FROM sessions WHERE COALESCE(source, 'claude') = 'claude'").get()?.c || 0; + codexSessionCount = db.prepare("SELECT COUNT(*) as c FROM sessions WHERE source = 'codex'").get()?.c || 0; memoryCount = db.prepare('SELECT COUNT(*) as c FROM memories WHERE deleted_at IS NULL').get()?.c || 0; - const latest = db.prepare('SELECT MAX(started_at) as t FROM sessions').get(); - lastIndexed = latest?.t || ''; + const claudeLatest = db.prepare("SELECT MAX(started_at) as t FROM sessions WHERE COALESCE(source, 'claude') = 'claude'").get(); + claudeLastIndexed = claudeLatest?.t || ''; + const codexLatest = db.prepare("SELECT MAX(started_at) as t FROM sessions WHERE source = 'codex'").get(); + codexLastIndexed = codexLatest?.t || ''; } catch {} } return { claudeDir, + codexDir, dbPath: dbFile, recapDir, autoRefresh: persisted.autoRefresh !== false, - sessionCount, + sources: [ + { + id: 'claude', + name: 'Claude Code', + vendor: 'Anthropic', + path: claudeDir, + exists: claudeExists, + sessionCount: claudeSessionCount, + lastIndexed: claudeLastIndexed, + status: claudeExists ? 'ok' : 'error', + statusText: claudeExists ? 'Connected' : 'Folder not found', + }, + { + id: 'codex', + name: 'Codex', + vendor: 'OpenAI', + path: codexDir, + exists: codexExists, + sessionCount: codexSessionCount, + lastIndexed: codexLastIndexed, + status: codexExists ? (codexSessionCount > 0 ? 'ok' : 'warn') : 'error', + statusText: codexExists ? (codexSessionCount > 0 ? 'Connected' : 'No sessions found') : 'Folder not found', + }, + ], memoryCount, - lastIndexed, - status: exists ? 'ok' : 'error', - statusText: exists ? 'Connected' : 'Folder not found', + sessionCount: claudeSessionCount + codexSessionCount, + lastIndexed: claudeLastIndexed, + status: claudeExists ? 'ok' : 'error', + statusText: claudeExists ? 'Connected' : 'Folder not found', }; }); @@ -586,9 +782,14 @@ ipcMain.handle('settings:set', async (_, key, value) => { } } - if (key === 'claudeDir') { + if (key === 'claudeDir' || key === 'codexDir') { await stopIndexerServiceAndWait(); - openDb(); + const paths = getPathsForClaudeDir( + persisted.claudeDir || DEFAULT_CLAUDE_DIR, + persisted.codexDir || DEFAULT_CODEX_DIR, + ); + migrateLegacyDbIfNeeded(paths); + openDb(paths.dbPath); if (persisted.autoRefresh !== false) { startIndexerService({ buildOnStart: true }); } @@ -615,22 +816,43 @@ ipcMain.handle('settings:revealPath', (_, p) => { ipcMain.handle('settings:rebuildIndex', async () => { if (!indexerWorker) return null; const persisted = loadPersistedSettings(); - const paths = getPathsForClaudeDir(persisted.claudeDir || DEFAULT_CLAUDE_DIR); + const paths = getPathsForClaudeDir( + persisted.claudeDir || DEFAULT_CLAUDE_DIR, + persisted.codexDir || DEFAULT_CODEX_DIR, + ); + const tempDbPath = rebuildTempDbPath(paths.dbPath); const shouldRestartWatcher = persisted.autoRefresh !== false; - await stopIndexerServiceAndWait(); - closeDb(); + await stopIndexerServiceAndWait({ waitForIdle: false }); + if (indexerWorker) { + await Promise.resolve(indexerWorker.stop()); + indexerWorker = createWorkerBuildIndex(); + } + cleanupDbFiles(tempDbPath); try { + migrateLegacyDbIfNeeded(paths); const result = await indexerWorker.buildIndex({ reason: 'manual-rebuild', force: true, claudeDir: paths.claudeDir, + codexDir: paths.codexDir, projectsDir: paths.projectsDir, - dbPath: paths.dbPath, + dbPath: tempDbPath, + preserveDbPath: fs.existsSync(paths.dbPath) ? paths.dbPath : null, }); + closeDb(); + replaceDbWithTemp(tempDbPath, paths.dbPath); openDb(paths.dbPath); notifyIndexUpdated(result); return result; } finally { + cleanupDbFiles(tempDbPath); + if (!db) { + try { + openDb(paths.dbPath); + } catch (error) { + console.warn?.(`Obelisk DB reopen after rebuild failed: ${error.message}`); + } + } if (shouldRestartWatcher) startIndexerService({ buildOnStart: false }); } }); diff --git a/app/package.json b/app/package.json index 6f1548d..885761e 100644 --- a/app/package.json +++ b/app/package.json @@ -5,7 +5,7 @@ "main": "main.js", "scripts": { "start": "electron .", - "dev": "electron . --dev", + "dev": "node scripts/dev.js", "dev:renderer": "vite renderer", "build:renderer": "vite build renderer", "build": "npm run build:renderer && electron-builder", diff --git a/app/renderer/src/App.vue b/app/renderer/src/App.vue index 161dd16..d889807 100644 --- a/app/renderer/src/App.vue +++ b/app/renderer/src/App.vue @@ -47,6 +47,11 @@ const sidebarProjectsForCurrentScope = (search = state.projectSearch) => buildSi const sidebarProjects = computed(() => sidebarProjectsForCurrentScope()); +const NOISE_PROJECT_RE = /^(od-conn-test|[0-9a-f]{6,})/i; +const normalProjects = computed(() => sidebarProjects.value.filter(p => p.count > 1 || !NOISE_PROJECT_RE.test(p.label))); +const noiseProjects = computed(() => sidebarProjects.value.filter(p => p.count <= 1 && NOISE_PROJECT_RE.test(p.label))); +const showNoiseProjects = ref(false); + const totalProjectCount = computed(() => { return sidebarProjectsForCurrentScope('').length; }); @@ -158,11 +163,36 @@ const keepAliveIncludes = ['SessionDetail']; const isExportRoute = computed(() => route.name === 'RecapExport'); +// --- Source health dots --- +const sourceDots = ref([]); +const sourceDetails = ref([]); +const showSourcePopover = ref(false); +async function loadSourceDots() { + if (!window.obelisk?.getSettings) return; + const s = await window.obelisk.getSettings(); + sourceDots.value = (s.sources || []).map(src => ({ id: src.id, status: src.status })); + sourceDetails.value = s.sources || []; +} +loadSourceDots(); + // --- Recap --- const recapGenerateOpen = ref(false); function setRecapKind(k) { router.replace({ path: '/recap', query: { kind: k } }); } + +// --- Source filter --- +const showSourceFilter = ref(false); +const sourceFilterActive = computed(() => state.sourceFilter !== 'all' && state.sourceFilter !== undefined); +const sourceFilterLabel = computed(() => { + if (!state.sourceFilter || state.sourceFilter === 'all') return 'All sources'; + return state.sourceFilter === 'claude' ? 'Claude Code' : 'Codex'; +}); +function toggleSourceFilter() { showSourceFilter.value = !showSourceFilter.value; } +function setSourceFilter(id) { + state.sourceFilter = id; + showSourceFilter.value = false; +} provide('recapGenerateOpen', recapGenerateOpen); @@ -203,6 +233,24 @@ provide('recapGenerateOpen', recapGenerateOpen); Obelisk + +
+
Connected sources
+
+ +
+
+ +
+
@@ -403,6 +481,35 @@ provide('recapGenerateOpen', recapGenerateOpen); + +
+ +
+
+
+ +
+ {{ src.id === 'claude' ? 'Claude Code' : 'Codex' }} +
+
+
+
+ +
+ All sources +
+
+
+
{{ session.title || '(untitled)' }}
diff --git a/app/renderer/src/views/SessionList.vue b/app/renderer/src/views/SessionList.vue index 2cc48f1..0a1fd12 100644 --- a/app/renderer/src/views/SessionList.vue +++ b/app/renderer/src/views/SessionList.vue @@ -23,6 +23,7 @@ const visibleSessions = computed(() => { const q = state.query.trim().toLowerCase(); return state.sessions .filter(s => state.projectFilter === 'all' || s.project === state.projectFilter) + .filter(s => state.sourceFilter === 'all' || (s.source || 'claude') === state.sourceFilter) .map(s => { if (!q) return { ...s, messageHit: null }; const topMatch = (s.title || '').toLowerCase().includes(q) || @@ -40,6 +41,14 @@ const visibleSessions = computed(() => { }); const showProjectPrefix = computed(() => state.projectFilter === 'all'); +const showNoise = ref(false); + +function isNoise(s) { + return !s.title; +} + +const normalSessions = computed(() => visibleSessions.value.filter(s => !isNoise(s))); +const noiseSessions = computed(() => visibleSessions.value.filter(s => isNoise(s))); function titleHTML(session) { return highlightPlain(session.title || '(untitled)', state.query.trim()); @@ -135,7 +144,7 @@ function obeliskStyle(session) {
{{ timeLabel(s) }}
+ + +
+ + + +
+ {{ noiseSessions.length }} quiet sessions hidden — untitled, likely tests or incomplete runs. +
+ Show all +
+ + +
+
+ {{ noiseSessions.length }} sessions · untitled +
+
+
+
(untitled)
+
+ + {{ s.message_count || 0 }} msg +
+
+
{{ timeLabel(s) }}
+
+ +
@@ -333,4 +384,65 @@ function obeliskStyle(session) { font-family: var(--font-mono); color: var(--fg-2); background: rgba(0,0,0,0.3); padding: 1px 6px; border-radius: 3px; } + +/* Noise fold */ +.fold-banner { + display: flex; align-items: center; gap: 12px; + padding: 10px 22px; + background: rgba(255,255,255,0.015); + border-top: 1px solid var(--hairline); + border-bottom: 1px solid var(--hairline); + font-size: 12.5px; color: var(--muted); + cursor: pointer; transition: all 0.1s; +} +.fold-banner:hover { background: rgba(255,255,255,0.03); color: var(--fg-2); } +.fold-banner.expanded { color: var(--fg-3); background: rgba(255,255,255,0.02); } +.fold-banner .chev { + width: 10px; height: 10px; color: var(--muted-2); + transition: transform 0.15s; flex-shrink: 0; +} +.fold-banner.expanded .chev { transform: rotate(90deg); color: var(--accent-2); } +.fold-banner .body { flex: 1; } +.fold-banner .body strong { + color: var(--fg-2); font-weight: 500; + font-variant-numeric: tabular-nums; + font-family: var(--font-mono); font-size: 11.5px; +} +.fold-banner .reveal-link { + font-size: 11.5px; color: var(--accent-2); + text-decoration: none; border-bottom: 1px solid rgba(167,139,250,0.4); + padding-bottom: 1px; transition: all 0.12s; flex-shrink: 0; +} +.fold-banner:hover .reveal-link { color: var(--accent); border-bottom-color: var(--accent); } + +.noise-group { + border-bottom: 1px solid var(--hairline-strong); + background: rgba(0,0,0,0.15); +} +.noise-group-head { + padding: 6px 22px; + font-family: var(--font-mono); font-size: 10px; color: var(--muted-2); + letter-spacing: 0.06em; text-transform: uppercase; + background: rgba(0,0,0,0.1); border-bottom: 1px solid var(--hairline); +} +.srow.noise { padding: 8px 22px 8px 18px; } +.srow.noise .srow-title { + color: var(--muted); font-style: italic; + font-size: 13px; font-weight: 400; +} +.srow.noise .srow-meta { color: var(--muted-2); } + +.noise-fold-bottom { + padding: 8px 22px; background: rgba(0,0,0,0.2); + font-family: var(--font-mono); font-size: 11px; color: var(--muted); + cursor: pointer; transition: all 0.1s; + display: flex; align-items: center; gap: 8px; + border-top: 1px solid var(--hairline); + border: none; width: 100%; text-align: left; +} +.noise-fold-bottom:hover { background: rgba(0,0,0,0.3); color: var(--fg-2); } +.noise-fold-bottom .chev { + width: 9px; height: 9px; color: var(--muted-2); + transform: rotate(-90deg); +} diff --git a/app/renderer/src/views/Settings.vue b/app/renderer/src/views/Settings.vue index 3664c60..0bf9376 100644 --- a/app/renderer/src/views/Settings.vue +++ b/app/renderer/src/views/Settings.vue @@ -1,17 +1,13 @@