From 3ee44de4e595d679c54ce4d748547b2f2238a4a6 Mon Sep 17 00:00:00 2001 From: tommy0103 Date: Mon, 20 Jul 2026 22:43:23 +0800 Subject: [PATCH] feat: add registry-driven Kimi provider --- CONTEXT.md | 19 +- README.md | 15 +- app/src/main/index.ts | 240 +++---- app/src/main/indexer-service.ts | 10 +- app/src/main/indexer.ts | 386 ++--------- app/src/main/provider-settings.ts | 88 +++ app/src/renderer/src/App.vue | 12 +- app/src/renderer/src/activity-ledger.mjs | 12 +- .../src/components/ActivityLedgerRow.vue | 2 + app/src/renderer/src/source-catalog.mjs | 28 + app/src/renderer/src/store.js | 1 + app/src/renderer/src/views/SessionDetail.vue | 5 +- app/src/renderer/src/views/Settings.vue | 9 +- app/src/renderer/styles/detail.css | 3 +- app/src/renderer/styles/sidebar.css | 12 +- .../adr/0001-parse-core-and-persist-layers.md | 26 +- packages/core/package.json | 4 + packages/core/src/indexer.ts | 135 +--- packages/core/src/parsing.ts | 14 +- packages/core/src/provider-indexing.ts | 106 +++ packages/core/src/providers/builtins.ts | 14 + packages/core/src/providers/claude.ts | 146 +++- packages/core/src/providers/codex.ts | 121 +++- packages/core/src/providers/kimi.ts | 656 ++++++++++++++++++ packages/core/src/providers/registry.ts | 41 ++ packages/core/src/providers/types.ts | 39 +- packages/core/src/query.ts | 100 +-- tests/app-activity-ledger.test.mjs | 5 + tests/app-indexer-service.test.mjs | 6 +- tests/app-kimi-index.test.mjs | 125 ++++ tests/app-main-settings.test.mjs | 11 +- tests/app-provider-indexer.test.mjs | 79 +++ tests/kimi-parse.test.mjs | 227 ++++++ tests/kimi-runtime.test.mjs | 46 ++ tests/provider-registry.test.mjs | 78 +++ tests/provider-schema-stability.test.mjs | 12 + tests/provider-settings.test.mjs | 75 ++ tests/query-provider-raw.test.mjs | 48 ++ tests/source-catalog.test.mjs | 16 + 39 files changed, 2240 insertions(+), 732 deletions(-) create mode 100644 app/src/main/provider-settings.ts create mode 100644 app/src/renderer/src/source-catalog.mjs create mode 100644 packages/core/src/provider-indexing.ts create mode 100644 packages/core/src/providers/builtins.ts create mode 100644 packages/core/src/providers/kimi.ts create mode 100644 packages/core/src/providers/registry.ts create mode 100644 tests/app-kimi-index.test.mjs create mode 100644 tests/app-provider-indexer.test.mjs create mode 100644 tests/kimi-parse.test.mjs create mode 100644 tests/kimi-runtime.test.mjs create mode 100644 tests/provider-registry.test.mjs create mode 100644 tests/provider-schema-stability.test.mjs create mode 100644 tests/provider-settings.test.mjs create mode 100644 tests/query-provider-raw.test.mjs create mode 100644 tests/source-catalog.test.mjs diff --git a/CONTEXT.md b/CONTEXT.md index b1eaea1..e3e9476 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,7 +1,7 @@ # Obelisk Obelisk is explicit memory infrastructure for coding agents: it indexes local -Claude Code and Codex transcripts into a queryable SQLite evidence layer, and a +Claude Code, Codex, and Kimi Code sessions into a queryable SQLite evidence layer, and a CodeAct runtime lets an agent write a small query, run it, and answer from real session history. This glossary pins the terms that are specific to Obelisk; it is not a spec. @@ -29,9 +29,12 @@ promoted to an external tool surface. ## Indexing **Provider adapter**: -A pure per-source module (claude, codex, later opencode, pi, …) that discovers a -source's transcript files and parses one into a stream of records. It never opens -or writes a database; adding a source means adding one adapter. The shared pure +A pure per-source module (claude, codex, kimi, later pi, …) that owns its +descriptor, watch roots, discovery, parsing, cursor interpretation, and raw +record lookup. It discovers `IndexUnit`s rather than assuming one transcript +file per unit; Kimi uses a session directory containing multiple wire logs. It +never opens or writes a database; adding a source means adding one adapter and +registering it. The shared pure parse/discover helpers live in `packages/core/src/parsing.ts`, which imports only node:fs/path/os — deliberately node:sqlite-free so the compiled providers can be consumed by the app (whose Electron runtime has no `node:sqlite`). @@ -62,10 +65,10 @@ active daemon: the command brings the index up to date, then answers. _Avoid_: lazy indexing, on-read indexing **index_state**: -The bookkeeping table shared by both indexing modes. It records, per transcript -path, the last-seen `mtime` and `lines_processed` (enabling resume-from-line -incremental indexing), plus heartbeat/last-build markers used for daemon -arbitration. +The bookkeeping table shared by both indexing modes. It stores the adapter's +numeric cursor pair in the existing `mtime` and `lines_processed` columns (a +file adapter can use mtime + line offset; Kimi uses aggregate max-mtime + total +lines), plus heartbeat/last-build markers used for daemon arbitration. **Daemon arbitration**: The policy by which the passive pull mode detects a fresh daemon from the diff --git a/README.md b/README.md index b224544..5ba9937 100644 --- a/README.md +++ b/README.md @@ -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) -Every past Claude Code and Codex session -- queryable by your agent, browsable by you. +Past Claude Code, Codex, and Kimi Code sessions -- queryable by your agent, browsable by you. @@ -25,15 +25,20 @@ 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` and Codex transcripts from `~/.codex/sessions`. +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`). -## Codex support +## Multi-provider support -Obelisk indexes Claude Code and Codex into the same SQLite schema instead of keeping separate databases. Rows carry a `source` value (`claude` or `codex`), and Codex IDs are prefixed with `codex:` so they cannot collide with Claude session IDs. +Obelisk indexes every provider into the same SQLite schema instead of keeping separate databases. Rows carry a `source` value, and non-Claude IDs are provider-prefixed so they cannot collide. Codex root threads become normal Obelisk sessions. Codex child threads are attached through the same `subagents` table when parent-thread metadata is available. Codex does not emit Claude-style workflow metadata, so workflow tables may be empty for Codex-only history. -For live app refresh, Obelisk watches `~/.claude/projects` and `~/.codex/sessions`. It does not watch the whole `~/.codex` root. Codex's `session_index.jsonl` is used as lightweight title/update metadata during indexing, not as the message transcript source. +Kimi session directories become one Obelisk session each. Main and child-agent +`wire.jsonl` streams are projected into the same messages, tools, summaries and +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. ## Skill: agent-first retrieval diff --git a/app/src/main/index.ts b/app/src/main/index.ts index c44344a..f651b6c 100644 --- a/app/src/main/index.ts +++ b/app/src/main/index.ts @@ -10,6 +10,12 @@ import { createIndexerService } from './indexer-service.ts'; import { createWorkerBuildIndex } from './indexer-worker-client.ts'; import { buildRecapExportQuery } from './recap-capture-query.ts'; import { acquireWriterLease, writerLockPathFor } from '../../../packages/core/src/writer-lease.ts'; +import { createBuiltinProviderRegistry } from '../../../packages/core/src/providers/builtins.ts'; +import { + buildSourceCatalog, + resolveProviderRoots, + setPersistedSetting, +} from './provider-settings.ts'; import type { SessionPatchCursor, SessionPatchSnapshot, @@ -69,18 +75,18 @@ function acquireAppWriterLease(dbPath: string, waitMs = 0) { }); } -function getConfiguredClaudeDir() { - const persisted = loadPersistedSettings(); - return persisted.claudeDir || DEFAULT_CLAUDE_DIR; -} - -function getConfiguredCodexDir() { - const persisted = loadPersistedSettings(); - return persisted.codexDir || DEFAULT_CODEX_DIR; -} - -function getPathsForClaudeDir(claudeDir = getConfiguredClaudeDir(), codexDir = getConfiguredCodexDir()) { +function getRuntimePaths(persisted = loadPersistedSettings()) { + const defaultRegistry = createBuiltinProviderRegistry({ + claude: DEFAULT_CLAUDE_DIR, + codex: DEFAULT_CODEX_DIR, + }); + const providerRoots = resolveProviderRoots(defaultRegistry, persisted); + const providerRegistry = createBuiltinProviderRegistry(providerRoots); + const claudeDir = providerRoots['claude'] ?? DEFAULT_CLAUDE_DIR; + const codexDir = providerRoots['codex'] ?? DEFAULT_CODEX_DIR; return { + providerRoots, + providerRegistry, claudeDir, codexDir, dbPath: path.join(OBELISK_DIR, 'obelisk.sqlite'), @@ -89,7 +95,7 @@ function getPathsForClaudeDir(claudeDir = getConfiguredClaudeDir(), codexDir = g } function migrateLegacyDbIfNeeded( - paths = getPathsForClaudeDir(), + paths = getRuntimePaths(), { writerLeaseMode = 'acquire' }: { writerLeaseMode?: WriterLeaseMode } = {}, ) { if (fs.existsSync(paths.dbPath)) return; @@ -191,7 +197,7 @@ function closeDb() { } function openDb( - dbPath = getPathsForClaudeDir().dbPath, + dbPath = getRuntimePaths().dbPath, { writerLeaseMode = 'acquire' }: { writerLeaseMode?: WriterLeaseMode } = {}, ) { closeDb(); @@ -212,7 +218,7 @@ function openDb( function runAppDbWrite(work: () => void): boolean { if (!db) return false; - const lease = acquireAppWriterLease(getPathsForClaudeDir().dbPath, 250); + const lease = acquireAppWriterLease(getRuntimePaths().dbPath, 250); if (!lease) { throw new Error('Obelisk index writer is busy; memory change was not applied'); } @@ -249,16 +255,16 @@ function appendWhere(sql, params, clause) { } function startIndexerService({ buildOnStart = false } = {}) { - const paths = getPathsForClaudeDir(); + const paths = getRuntimePaths(); migrateLegacyDbIfNeeded(paths); - const codexSessionsDir = path.join(paths.codexDir, 'sessions'); indexerService = createIndexerService({ projectsDir: paths.projectsDir, - watchDirs: [paths.projectsDir, codexSessionsDir], + watchDirs: paths.providerRegistry.watchRoots(paths.providerRoots), buildIndex: async ({ reason, changedPaths }) => { const result = await indexerWorker.buildIndex({ reason, changedPaths, + providerRoots: paths.providerRoots, claudeDir: paths.claudeDir, codexDir: paths.codexDir, projectsDir: paths.projectsDir, @@ -282,7 +288,7 @@ function startIndexerService({ buildOnStart = false } = {}) { function startBackgroundResources({ runStartupBuild = false } = {}) { if (!indexerWorker) indexerWorker = createWorkerBuildIndex(); - const paths = getPathsForClaudeDir(); + const paths = getRuntimePaths(); migrateLegacyDbIfNeeded(paths); openDb(paths.dbPath); if (!indexerService) { @@ -575,87 +581,25 @@ ipcMain.handle('db:getMemories', () => { ipcMain.handle('db:getMessageFullText', (_, uuid) => { if (!db) return null; - const msg = db.prepare('SELECT session_id, agent_id, source FROM messages WHERE uuid=?').get(uuid); + const msg = db.prepare('SELECT * 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: string | null = 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: string | null = null; - if (msg.agent_id) { - const wa = db.prepare('SELECT agent_id, run_id, session_id FROM workflow_agents WHERE agent_id=?').get(msg.agent_id); - if (wa) { - const ses = db.prepare('SELECT jsonl_path FROM sessions WHERE id=?').get(wa.session_id); - if (ses) jsonlPath = path.join(path.dirname(ses.jsonl_path), wa.session_id, 'subagents', 'workflows', wa.run_id, wa.agent_id + '.jsonl'); - } - if (!jsonlPath) { - const sa = db.prepare('SELECT agent_id, session_id FROM subagents WHERE agent_id=?').get(msg.agent_id); - if (sa) { - const ses = db.prepare('SELECT jsonl_path FROM sessions WHERE id=?').get(sa.session_id); - if (ses) jsonlPath = path.join(path.dirname(ses.jsonl_path), sa.session_id, 'subagents', sa.agent_id + '.jsonl'); - } - } - } - if (!jsonlPath) { - const ses = db.prepare('SELECT jsonl_path FROM sessions WHERE id=?').get(msg.session_id); - if (ses) jsonlPath = ses.jsonl_path; - } - if (!jsonlPath || !fs.existsSync(jsonlPath)) return null; - - // Scan JSONL for the message UUID and extract full text - const data = fs.readFileSync(jsonlPath, 'utf-8'); - const lines = data.split('\n'); - for (const line of lines) { - if (!line.includes(uuid)) continue; - try { - const obj = JSON.parse(line); - if (obj.uuid !== uuid) continue; - const content = obj.message?.content; - if (typeof content === 'string') return content; - if (!Array.isArray(content)) return null; - const parts: string[] = []; - for (const b of content) { - if (b.type === 'text' && b.text) parts.push(b.text); - else if (b.type === 'thinking' && b.thinking) parts.push(b.thinking); - } - return parts.join('\n') || null; - } catch { continue; } - } - 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 + : null; + const workflowAgent = msg.agent_id + ? db.prepare('SELECT * FROM workflow_agents WHERE agent_id=?').get(msg.agent_id) ?? null + : null; + const paths = getRuntimePaths(); + const raw = paths.providerRegistry.raw({ + source: msg.source || session?.source || 'claude', + messageUuid: String(uuid), + session, + agentId: msg.agent_id || null, + subagent, + workflowAgent, + }); + return raw?.messageText ?? msg.text ?? null; }); ipcMain.handle('db:readMemoryFile', (_, filePath) => { @@ -849,76 +793,59 @@ function savePersistedSettings(settings) { ipcMain.handle('settings:get', () => { const persisted = loadPersistedSettings(); - const { claudeDir, codexDir, dbPath: dbFile } = getPathsForClaudeDir( - persisted.claudeDir || DEFAULT_CLAUDE_DIR, - persisted.codexDir || DEFAULT_CODEX_DIR, - ); + const paths = getRuntimePaths(persisted); + const { providerRoots, providerRegistry, claudeDir, codexDir, dbPath: dbFile } = paths; const recapDir = persisted.recapDir || RECAP_DIR; - const claudeExists = fs.existsSync(claudeDir); - const codexExists = fs.existsSync(codexDir); - let claudeSessionCount = 0; - let codexSessionCount = 0; let memoryCount = 0; - let claudeLastIndexed = ''; - let codexLastIndexed = ''; + const sourceStats = new Map(); if (db) { try { - 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; + const rows = db.prepare(` + SELECT COALESCE(source, 'claude') AS source, + COUNT(*) AS session_count, + MAX(started_at) AS last_indexed + FROM sessions + GROUP BY COALESCE(source, 'claude') + `).all(); + for (const row of rows) { + sourceStats.set(row.source, { + sessionCount: row.session_count || 0, + lastIndexed: row.last_indexed || '', + }); + } memoryCount = db.prepare('SELECT COUNT(*) as c FROM memories WHERE deleted_at IS NULL').get()?.c || 0; - 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 {} } + const sources = buildSourceCatalog({ + registry: providerRegistry, + roots: providerRoots, + stats: sourceStats, + 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'); return { + providerRoots, claudeDir, codexDir, dbPath: dbFile, recapDir, autoRefresh: persisted.autoRefresh !== false, - 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', - }, - ], + sources, memoryCount, - sessionCount: claudeSessionCount + codexSessionCount, - lastIndexed: claudeLastIndexed, - status: claudeExists ? 'ok' : 'error', - statusText: claudeExists ? 'Connected' : 'Folder not found', + sessionCount, + lastIndexed, + status: connected ? 'ok' : 'error', + statusText: connected ? 'Connected' : 'No source folders found', }; }); ipcMain.handle('settings:set', async (_, key, value) => { const persisted = loadPersistedSettings(); - if (value === null) { - delete persisted[key]; - } else { - persisted[key] = value; - } + const providerRootChanged = setPersistedSetting(persisted, key, value); savePersistedSettings(persisted); if (key === 'autoRefresh') { @@ -930,12 +857,13 @@ ipcMain.handle('settings:set', async (_, key, value) => { } } - if (key === 'claudeDir' || key === 'codexDir') { + const knownLegacyRootChanged = createBuiltinProviderRegistry({ + claude: DEFAULT_CLAUDE_DIR, + codex: DEFAULT_CODEX_DIR, + }).catalog().some((provider) => key === `${provider.id}Dir`); + if (providerRootChanged || knownLegacyRootChanged) { await stopIndexerServiceAndWait(); - const paths = getPathsForClaudeDir( - persisted.claudeDir || DEFAULT_CLAUDE_DIR, - persisted.codexDir || DEFAULT_CODEX_DIR, - ); + const paths = getRuntimePaths(persisted); migrateLegacyDbIfNeeded(paths); openDb(paths.dbPath); if (persisted.autoRefresh !== false) { @@ -951,7 +879,7 @@ ipcMain.handle('settings:browseFolder', async (event) => { if (!win) return null; const { filePaths } = await dialog.showOpenDialog(win, { properties: ['openDirectory'], - title: 'Select Claude Code data folder', + title: 'Select session data folder', }); if (filePaths && filePaths[0]) return filePaths[0]; return null; @@ -964,10 +892,7 @@ 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, - persisted.codexDir || DEFAULT_CODEX_DIR, - ); + const paths = getRuntimePaths(persisted); const tempDbPath = rebuildTempDbPath(paths.dbPath); const shouldRestartWatcher = persisted.autoRefresh !== false; await stopIndexerServiceAndWait({ waitForIdle: false }); @@ -1000,6 +925,7 @@ ipcMain.handle('settings:rebuildIndex', async () => { const result = await indexerWorker.buildIndex({ reason: 'manual-rebuild', force: true, + providerRoots: paths.providerRoots, claudeDir: paths.claudeDir, codexDir: paths.codexDir, projectsDir: paths.projectsDir, diff --git a/app/src/main/indexer-service.ts b/app/src/main/indexer-service.ts index 78021ac..16ee065 100644 --- a/app/src/main/indexer-service.ts +++ b/app/src/main/indexer-service.ts @@ -74,11 +74,13 @@ function createIndexerService({ const existingRoots = roots.filter(root => fs.existsSync(root)); if (!existingRoots.length) return null; const watchers: any[] = []; - const onFileChange = (filename) => { - const name = filename ? String(filename) : ''; - if (!name || name.endsWith('.jsonl') || name.endsWith('.json')) onChange(name); - }; for (const root of existingRoots) { + const onFileChange = (filename) => { + const name = filename ? String(filename) : ''; + if (!name || name.endsWith('.jsonl') || name.endsWith('.json')) { + onChange(name && !path.isAbsolute(name) ? path.join(root, name) : name); + } + }; const watcher = (chokidar || chokidarModule).watch(root, { cwd: root, ignoreInitial: true, diff --git a/app/src/main/indexer.ts b/app/src/main/indexer.ts index 8ce7db6..9dbb269 100644 --- a/app/src/main/indexer.ts +++ b/app/src/main/indexer.ts @@ -3,12 +3,13 @@ import os from 'node:os'; import path from 'node:path'; 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 { - CLAUDE_INPUT_TOKEN_SEMANTICS_MARKER, - parse as claudeParse, -} from '../../../packages/core/src/providers/claude.ts'; -import { parse as codexParse } from '../../../packages/core/src/providers/codex.ts'; -import { persist } from '../../../packages/core/src/persist.ts'; + createProviderIndexPlan, + indexProviderPlan, + writeProviderIndexMarkers, +} from '../../../packages/core/src/provider-indexing.ts'; import { runWriteTransaction, configureConnection, betterSqliteTransactionAdapter } from '../../../packages/core/src/tx.ts'; import { acquireWriterLease, writerLockPathFor } from '../../../packages/core/src/writer-lease.ts'; import { runRetryableWriteTransaction, isBeginBusyFailure, hasUnusableTransaction } from '../../../packages/core/src/write-coordinator.ts'; @@ -17,9 +18,6 @@ import { isDir, readLines, codexDbId, - codexRawId, - codexParentThreadId, - readCodexGuardianThreadInfo, } from '../../../packages/core/src/parsing.ts'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -31,16 +29,6 @@ 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'); -interface FileInfo { - path: string; - sessionId?: string; - project?: string; - isSubagent?: boolean; - agentId?: string; - workflowRunId?: string; - source?: string; -} - function resolveSchemaPath() { const candidates = [ path.join(__dirname, 'schema.sql'), @@ -119,54 +107,12 @@ function copyMemoriesFromDb(db, sourceDbPath) { } } -function discoverJsonlFiles({ projectsDir = DEFAULT_PROJECTS_DIR, changedPaths = undefined }: { projectsDir?: string; changedPaths?: string[] } = {}) { - if (Array.isArray(changedPaths) && changedPaths.length) { - const changedFiles = discoverJsonlFilesForChanges({ projectsDir, changedPaths }); - if (changedFiles.length) return changedFiles; - } - return discoverJsonlFilesFull({ projectsDir }); -} - function normalizeChangedPath(projectsDir, changedPath) { if (!changedPath) return null; const raw = String(changedPath); return path.isAbsolute(raw) ? path.normalize(raw) : path.normalize(path.join(projectsDir, raw)); } -function jsonlFileInfoFromPath(projectsDir, changedPath) { - let fp = normalizeChangedPath(projectsDir, changedPath); - if (fp?.toLowerCase().endsWith('.meta.json')) { - fp = fp.slice(0, -'.meta.json'.length) + '.jsonl'; - } - 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); - const project = parts[0]; - if (!project) return null; - if (parts.length === 2) { - const filename = parts[1]; - return { path: fp, sessionId: filename.slice(0, -6), project, isSubagent: false }; - } - if (parts.length === 4 && parts[2] === 'subagents') { - const filename = parts[3]; - return { path: fp, sessionId: parts[1], project, isSubagent: true, agentId: filename.slice(0, -6) }; - } - if (parts.length === 6 && parts[2] === 'subagents' && parts[3] === 'workflows') { - const filename = parts[5]; - return { - path: fp, - sessionId: parts[1], - project, - isSubagent: true, - agentId: filename.slice(0, -6), - workflowRunId: parts[4], - }; - } - return null; -} - function sessionIdFromChangedPath(projectsDir, changedPath) { const fp = normalizeChangedPath(projectsDir, changedPath); if (!fp) return null; @@ -180,186 +126,6 @@ function sessionIdFromChangedPath(projectsDir, changedPath) { return null; } -function dedupeFileInfos(files) { - const byPath = new Map(); - for (const file of files) byPath.set(file.path, file); - return [...byPath.values()]; -} - -function discoverJsonlFilesForChanges({ projectsDir = DEFAULT_PROJECTS_DIR, changedPaths = [] }: { projectsDir?: string; changedPaths?: string[] } = {}) { - const files: FileInfo[] = []; - for (const changedPath of changedPaths) { - const info = jsonlFileInfoFromPath(projectsDir, changedPath); - if (info) files.push(info); - } - return dedupeFileInfos(files); -} - -function discoverJsonlFilesFull({ projectsDir = DEFAULT_PROJECTS_DIR } = {}) { - const files: FileInfo[] = []; - if (!fs.existsSync(projectsDir)) return files; - let projects; - try { projects = fs.readdirSync(projectsDir); } catch { return files; } - for (const proj of projects) { - const projPath = path.join(projectsDir, proj); - if (!isDir(projPath)) continue; - let entries; - try { entries = fs.readdirSync(projPath); } catch { continue; } - for (const f of entries) { - if (f.endsWith('.jsonl')) - files.push({ path: path.join(projPath, f), sessionId: f.slice(0, -6), project: proj, isSubagent: false }); - } - for (const sd of entries) { - const saDir = path.join(projPath, sd, 'subagents'); - if (!isDir(saDir)) continue; - let saEntries; - try { saEntries = fs.readdirSync(saDir); } catch { continue; } - for (const sf of saEntries) { - if (sf.endsWith('.jsonl')) - files.push({ path: path.join(saDir, sf), sessionId: sd, project: proj, isSubagent: true, agentId: sf.slice(0, -6) }); - } - const wfRoot = path.join(saDir, 'workflows'); - if (!isDir(wfRoot)) continue; - let wfDirs; - try { wfDirs = fs.readdirSync(wfRoot); } catch { continue; } - for (const wfDir of wfDirs) { - const wfPath = path.join(wfRoot, wfDir); - if (!isDir(wfPath)) continue; - let wfEntries; - try { wfEntries = fs.readdirSync(wfPath); } catch { continue; } - for (const wf of wfEntries) { - if (wf.endsWith('.jsonl')) - files.push({ path: path.join(wfPath, wf), sessionId: sd, project: proj, isSubagent: true, agentId: wf.slice(0, -6), workflowRunId: wfDir }); - } - } - } - } - return files; -} - -function discoverCodexJsonlFiles({ codexDir = DEFAULT_CODEX_DIR, changedPaths = undefined }: { codexDir?: string; changedPaths?: string[] } = {}) { - 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 = [] }: { codexDir?: string; changedPaths?: string[] } = {}) { - const files: FileInfo[] = []; - 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 || !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: FileInfo[] = []; - 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); - if (!row) return { needed: true, skip: 0, mtime: mt }; - return mt > row.mtime ? { needed: true, skip: row.lines_processed, mtime: mt } : { needed: false, skip: 0, mtime: mt }; -} - -// Index one Claude transcript via the shared provider + persist core. -// Returns { sessionId, path } when reindexed, undefined when skipped. -function indexClaudeFile(db, file, { forceFull = false } = {}) { - const { needed, skip, mtime } = needsReindex(db, file.path); - if (!needed && !forceFull) return undefined; - const unit = { - key: file.path, - sessionId: file.sessionId, - project: file.project, - isSubagent: file.isSubagent, - agentId: file.agentId, - }; - const cursor = !forceFull && skip > 0 ? `${mtime}:${skip}` : null; - persist(db, unit, claudeParse(unit, cursor)); - return { sessionId: file.sessionId, path: file.path }; -} - -function codexSessionMeta(filePath) { - let meta: any = null; - readLines(filePath, (line) => { - let obj; - try { obj = JSON.parse(line); } catch { return; } - if (obj?.type === 'session_meta' && obj.payload?.id) { - meta = obj.payload; - return false; - } - }); - return meta; -} - -// Index one Codex rollout via the shared provider + persist core (full reparse). -// Returns { sessionId, path } when reindexed, undefined when skipped. -function indexCodexFile(db, file) { - const { needed } = needsReindex(db, file.path); - const guardian = readCodexGuardianThreadInfo(file.path); - if (!needed) { - if (guardian) { - persist(db, { key: file.path, sessionId: '' }, (function* () { - yield { kind: "delete-session", sessionId: codexDbId(guardian.threadRawId) as string }; - return null; - })()); - } - return undefined; - } - const unit = { key: file.path, sessionId: '' }; - persist(db, unit, codexParse(unit, null)); - if (guardian) return undefined; - const meta = codexSessionMeta(file.path); - const sessionId = meta ? codexDbId(codexParentThreadId(meta) || codexRawId(meta.id)) : undefined; - return { sessionId, path: file.path }; -} - function indexCodexSessionIndex(db, { codexDir = DEFAULT_CODEX_DIR } = {}) { const indexPath = path.join(codexDir, 'session_index.jsonl'); if (!fs.existsSync(indexPath)) return; @@ -392,28 +158,6 @@ function refreshSessionProjectPaths(db) { } } -function indexSubagentMeta(db, fi) { - if (!fi.isSubagent) return false; - const mp = fi.path.replace('.jsonl', '.meta.json'); - if (!fs.existsSync(mp)) return false; - let meta; - try { - meta = JSON.parse(fs.readFileSync(mp, 'utf8')); - } catch (error) { - console.warn(`Warning: failed to read subagent meta ${mp}: ${(error as Error).message}`); - return false; - } - const tok = db.prepare('SELECT COALESCE(SUM(input_tokens),0)+COALESCE(SUM(output_tokens),0) as t FROM messages WHERE agent_id=?').get(fi.agentId); - const ts = db.prepare('SELECT MIN(timestamp) as t0, MAX(timestamp) as t1 FROM messages WHERE agent_id=?').get(fi.agentId); - const dur = ts?.t0 && ts?.t1 ? new Date(ts.t1).getTime() - new Date(ts.t0).getTime() : null; - if (fi.workflowRunId) { - db.prepare('INSERT OR REPLACE INTO workflow_agents (agent_id,run_id,session_id,agent_type,description) VALUES(?,?,?,?,?)').run(fi.agentId, fi.workflowRunId, fi.sessionId, meta.agentType||null, meta.description||null); - } else { - db.prepare('INSERT OR REPLACE INTO subagents VALUES(?,?,?,?,?,?,?)').run(fi.agentId, fi.sessionId, meta.toolUseId||null, meta.agentType||null, meta.description||null, dur, tok?.t||0); - } - return true; -} - function indexWorkflows(db, { projectsDir = DEFAULT_PROJECTS_DIR } = {}) { if (!fs.existsSync(projectsDir)) return; let projects; @@ -537,6 +281,8 @@ function writeHeartbeat({ } interface BuildIndexOptions { + providerRoots?: Record; + providerRegistry?: ProviderRegistry; claudeDir?: string; codexDir?: string; projectsDir?: string; @@ -588,6 +334,8 @@ function deferredBuildResult( } function buildIndex({ + providerRoots = {}, + providerRegistry, claudeDir = DEFAULT_CLAUDE_DIR, codexDir = path.join(path.dirname(claudeDir), '.codex'), projectsDir = path.join(claudeDir, 'projects'), @@ -620,30 +368,40 @@ function buildIndex({ try { const db = openIndexDb({ dbPath, schemaPath, DatabaseImpl }); const txDb = betterSqliteTransactionAdapter(db); - const claudeInputMarkerMissing = !db.prepare( - 'SELECT jsonl_path FROM index_state WHERE jsonl_path = ?', - ).get(CLAUDE_INPUT_TOKEN_SEMANTICS_MARKER); - const claudeInputSemanticsOutdated = claudeInputMarkerMissing && Boolean(db.prepare(` - SELECT 1 FROM messages - WHERE COALESCE(source, 'claude') = 'claude' - AND (input_tokens IS NOT NULL OR output_tokens IS NOT NULL) - LIMIT 1 - `).get()); let messageFtsTriggersDropped = false; try { if (preserveDbPath && path.resolve(preserveDbPath) !== path.resolve(dbPath)) { copyMemoriesFromDb(db, preserveDbPath); } - const files = [ - ...discoverJsonlFiles({ - projectsDir, - changedPaths: force || claudeInputSemanticsOutdated ? undefined : changedPaths, + const defaultHome = os.homedir(); + const compatibilityHome = path.dirname(claudeDir); + const relocatedDefaults = Object.fromEntries( + createBuiltinProviderRegistry().catalog().map((descriptor) => { + const relativeDefault = path.relative(defaultHome, descriptor.defaultRoot); + const root = compatibilityHome !== defaultHome + && relativeDefault + && !relativeDefault.startsWith('..') + && !path.isAbsolute(relativeDefault) + ? path.join(compatibilityHome, relativeDefault) + : descriptor.defaultRoot; + return [descriptor.id, root]; }), - ...discoverCodexJsonlFiles({ codexDir, changedPaths: force ? undefined : changedPaths }), - ]; - const latestSourceMtime = files.reduce((latest, file) => { + ); + const roots = { + ...relocatedDefaults, + claude: claudeDir, + codex: codexDir, + ...providerRoots, + }; + const registry = providerRegistry ?? createBuiltinProviderRegistry(roots); + const providerPlan = createProviderIndexPlan(db, registry, { force, changedPaths }); + let latestSourceMtime = providerPlan.items.reduce((latest, { unit }) => { + const providerCursor = (unit.meta as { currentCursor?: unknown } | undefined)?.currentCursor; + if (typeof providerCursor === 'string') { + return Math.max(latest, Number(providerCursor.split(':')[0]) || 0); + } try { - return Math.max(latest, fs.statSync(file.path).mtimeMs); + return Math.max(latest, fs.statSync(unit.key).mtimeMs); } catch { return latest; } @@ -668,7 +426,7 @@ function buildIndex({ } catch (error) { if (isBeginBusyFailure(error)) { return deferredBuildResult('database_busy', { - files: files.length, + files: providerPlan.items.length, latestSourceMtime, }); } @@ -697,39 +455,34 @@ function buildIndex({ } } const skipped: SkippedFile[] = []; - let claudeInputMigrationFailed = false; - for (const file of files) { - try { - // The write is committed before affectedSessionIds is updated, so a - // failed/rolled-back file never reports a phantom updated session. - const indexed = runRetryableWriteTransaction(txDb, () => { - const result = file.source === 'codex' - ? indexCodexFile(db, file) - : indexClaudeFile(db, file, { forceFull: claudeInputSemanticsOutdated }); - const metaIndexed = file.source !== 'codex' && indexSubagentMeta(db, file); - if (!result?.sessionId && metaIndexed && changedMetaJsonlPaths.has(file.path)) { - return { sessionId: file.sessionId, path: file.path }; - } - return result; - }, { label: `file:${file.path}` }); - if (indexed?.sessionId) affectedSessionIds.add(indexed.sessionId); - } catch (error) { - if (isBeginBusyFailure(error)) { - return deferredBuildResult('database_busy', { - files: files.length, - latestSourceMtime, - affectedSessionIds: [...affectedSessionIds], - skipped: skipped.length, - skippedFiles: skipped, - }); - } + 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); + }, + onError: (error, { provider, unit }) => { + if (isBeginBusyFailure(error)) return 'stop'; if (hasUnusableTransaction(error)) throw error; - if (claudeInputSemanticsOutdated && file.source !== 'codex') { - claudeInputMigrationFailed = true; - } - skipped.push({ path: file.path, error: (error as Error).message, diagnostics: (error as { obelisk?: unknown }).obelisk }); - console.warn(`Warning: failed to index ${file.path}: ${(error as Error).message}`); - } + skipped.push({ + path: unit.key, + error: (error as Error).message, + diagnostics: (error as { obelisk?: unknown }).obelisk, + }); + console.warn(`Warning: failed to index ${provider.name} unit ${unit.key}: ${(error as Error).message}`); + return 'skip'; + }, + }); + if (providerResult.stopped) { + return deferredBuildResult('database_busy', { + files: providerPlan.items.length, + latestSourceMtime, + affectedSessionIds: [...affectedSessionIds], + skipped: skipped.length, + skippedFiles: skipped, + }); } let ftsRebuilt = false; // Finalize is one transaction; a failure here fails the whole build (the @@ -745,15 +498,13 @@ function buildIndex({ writeIndexMarker(db, '__last_build__'); writeIndexMarker(db, '__app_last_successful_build__'); writeIndexMarker(db, '__indexer_owner_app__'); - if (!claudeInputSemanticsOutdated || !claudeInputMigrationFailed) { - writeIndexMarker(db, CLAUDE_INPUT_TOKEN_SEMANTICS_MARKER); - } + writeProviderIndexMarkers(db, providerPlan, providerResult); if (latestSourceMtime) writeIndexMarker(db, '__last_source_mtime__', latestSourceMtime); }, { label: 'finalize' }); } catch (error) { if (isBeginBusyFailure(error)) { return deferredBuildResult('database_busy', { - files: files.length, + files: providerPlan.items.length, latestSourceMtime, affectedSessionIds: [...affectedSessionIds], skipped: skipped.length, @@ -764,7 +515,7 @@ function buildIndex({ } for (const sessionId of finalizeAffectedSessionIds) affectedSessionIds.add(sessionId); return { - files: files.length, + files: providerPlan.items.length, latestSourceMtime, affectedSessionIds: [...affectedSessionIds], ftsRebuilt, @@ -792,6 +543,5 @@ export { buildIndex, writeHeartbeat, openIndexDb, - discoverJsonlFiles, inferProjectPath, }; diff --git a/app/src/main/provider-settings.ts b/app/src/main/provider-settings.ts new file mode 100644 index 0000000..b32a532 --- /dev/null +++ b/app/src/main/provider-settings.ts @@ -0,0 +1,88 @@ +import type { ProviderRegistry } from '../../../packages/core/src/providers/registry.ts'; + +type PersistedSettings = Record & { + providerRoots?: Record; +}; + +interface SourceStats { + sessionCount: number; + lastIndexed: string; +} + +interface BuildSourceCatalogOptions { + registry: ProviderRegistry; + roots: Readonly>; + stats?: ReadonlyMap; + 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 { + 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, + value: unknown, +): boolean { + const providerMatch = /^providerRoots\.(.+)$/.exec(key); + if (providerMatch === null) { + if (value === null) delete persisted[key]; + else persisted[key] = value; + return false; + } + + const providerId = providerMatch[1]!; + const roots = persisted.providerRoots && typeof persisted.providerRoots === 'object' + ? persisted.providerRoots + : {}; + if (value === null) delete roots[providerId]; + else roots[providerId] = value; + if (Object.keys(roots).length === 0) delete persisted.providerRoots; + else persisted.providerRoots = roots; + return true; +} + +export function buildSourceCatalog({ + registry, + roots, + stats = new Map(), + pathExists = () => false, +}: BuildSourceCatalogOptions) { + return registry.catalog().map((descriptor) => { + const path = roots[descriptor.id] ?? descriptor.defaultRoot; + const exists = pathExists(path); + const sourceStats = stats.get(descriptor.id) ?? { sessionCount: 0, lastIndexed: '' }; + const status = !exists ? 'error' : sourceStats.sessionCount > 0 ? 'ok' : 'warn'; + return { + id: descriptor.id, + name: descriptor.name, + vendor: descriptor.vendor, + color: descriptor.color, + path, + settingKey: `providerRoots.${descriptor.id}`, + exists, + sessionCount: sourceStats.sessionCount, + lastIndexed: sourceStats.lastIndexed, + status, + statusText: !exists + ? 'Folder not found' + : sourceStats.sessionCount > 0 + ? 'Connected' + : 'No sessions found', + }; + }); +} diff --git a/app/src/renderer/src/App.vue b/app/src/renderer/src/App.vue index a6689fd..32ea5cc 100644 --- a/app/src/renderer/src/App.vue +++ b/app/src/renderer/src/App.vue @@ -17,6 +17,7 @@ import { import { formatProjectLabel } from './utils.js'; import { buildSidebarProjects } from './sidebar-projects.mjs'; import { resolveGlobalShortcut } from './keyboard-shortcuts.mjs'; +import { sourceLabel } from './source-catalog.mjs'; const router = useRouter(); const route = useRoute(); @@ -207,8 +208,9 @@ 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 })); + sourceDots.value = (s.sources || []).map(src => ({ id: src.id, status: src.status, color: src.color })); sourceDetails.value = s.sources || []; + state.sources = s.sources || []; } loadSourceDots(); @@ -223,7 +225,7 @@ 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'; + return sourceLabel(state.sourceFilter, state.sources); }); function toggleSourceFilter() { showSourceFilter.value = !showSourceFilter.value; } function setSourceFilter(id) { @@ -271,13 +273,13 @@ provide('recapGenerateOpen', recapGenerateOpen); Obelisk
Connected sources