diff --git a/app/electron.vite.config.ts b/app/electron.vite.config.ts index 59c8e42..83f6faa 100644 --- a/app/electron.vite.config.ts +++ b/app/electron.vite.config.ts @@ -2,22 +2,22 @@ import { resolve } from 'node:path'; import { defineConfig, externalizeDepsPlugin } from 'electron-vite'; import vue from '@vitejs/plugin-vue'; -// Kept CommonJS for the first electron-vite boot (no "type":"module" yet); the -// TS + ESM migration is a later stage. Each main-process module is its own input -// so it is emitted to out/main/.js and the CommonJS require("./x") calls -// between them (and `new Worker(__dirname/indexer-worker.js)`) resolve at runtime. +// The app main/preload/renderer are TypeScript + ESM. Each main-process module +// is its own rollup input so it is emitted to out/main/.js and the +// relative imports between them (and `new Worker(__dirname/indexer-worker.js)`) +// resolve to the built .js at runtime. export default defineConfig({ main: { plugins: [externalizeDepsPlugin()], build: { rollupOptions: { input: { - index: resolve('src/main/index.js'), - indexer: resolve('src/main/indexer.js'), - 'indexer-service': resolve('src/main/indexer-service.js'), - 'indexer-worker': resolve('src/main/indexer-worker.js'), - 'indexer-worker-client': resolve('src/main/indexer-worker-client.js'), - 'recap-capture-query': resolve('src/main/recap-capture-query.js'), + index: resolve('src/main/index.ts'), + indexer: resolve('src/main/indexer.ts'), + 'indexer-service': resolve('src/main/indexer-service.ts'), + 'indexer-worker': resolve('src/main/indexer-worker.ts'), + 'indexer-worker-client': resolve('src/main/indexer-worker-client.ts'), + 'recap-capture-query': resolve('src/main/recap-capture-query.ts'), }, }, }, diff --git a/app/package-lock.json b/app/package-lock.json index c297a04..a17f096 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -12,6 +12,7 @@ "chokidar": "^4.0.3" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.13", "@vitejs/plugin-vue": "^5.0.0", "electron": "^33.0.0", "electron-builder": "^25.0.0", @@ -1848,6 +1849,16 @@ "node": ">= 10" } }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/cacheable-request": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", diff --git a/app/package.json b/app/package.json index 790c354..49e9a16 100644 --- a/app/package.json +++ b/app/package.json @@ -60,6 +60,7 @@ "chokidar": "^4.0.3" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.13", "@vitejs/plugin-vue": "^5.0.0", "electron": "^33.0.0", "electron-builder": "^25.0.0", diff --git a/app/src/main/index.js b/app/src/main/index.ts similarity index 97% rename from app/src/main/index.js rename to app/src/main/index.ts index 88f1700..ebdb5fb 100644 --- a/app/src/main/index.js +++ b/app/src/main/index.ts @@ -5,10 +5,10 @@ import fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import Database from 'better-sqlite3'; import chokidar from 'chokidar'; -import { writeHeartbeat } from './indexer.js'; -import { createIndexerService } from './indexer-service.js'; -import { createWorkerBuildIndex } from './indexer-worker-client.js'; -import { buildRecapExportQuery } from './recap-capture-query.js'; +import { writeHeartbeat } from './indexer.ts'; +import { createIndexerService } from './indexer-service.ts'; +import { createWorkerBuildIndex } from './indexer-worker-client.ts'; +import { buildRecapExportQuery } from './recap-capture-query.ts'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -68,7 +68,7 @@ function migrateLegacyDbIfNeeded(paths = getPathsForClaudeDir()) { fs.mkdirSync(path.dirname(paths.dbPath), { recursive: true }); fs.copyFileSync(legacyDbPath, paths.dbPath); } catch (error) { - console.warn?.(`Obelisk legacy DB migration skipped: ${error.message}`); + console.warn?.(`Obelisk legacy DB migration skipped: ${(error as Error).message}`); } } @@ -111,7 +111,7 @@ function resolveSchemaPath() { path.join(__dirname, 'schema.sql'), path.join(__dirname, '..', 'scripts', 'schema.sql'), process.resourcesPath ? path.join(process.resourcesPath, 'scripts', 'schema.sql') : null, - ].filter(Boolean); + ].filter((c): c is string => Boolean(c)); return candidates.find(p => fs.existsSync(p)); } @@ -160,7 +160,7 @@ function openDb(dbPath = getPathsForClaudeDir().dbPath) { return db; } -function notifyIndexUpdated(result = {}) { +function notifyIndexUpdated(result: { affectedSessionIds?: unknown } = {}) { const affectedSessionIds = Array.isArray(result.affectedSessionIds) ? [...new Set(result.affectedSessionIds.filter(Boolean))] : []; @@ -173,7 +173,7 @@ function notifyIndexUpdated(result = {}) { } } -function sourceWhereClause(opts = {}, column = 'source') { +function sourceWhereClause(opts: { includeCodex?: boolean; source?: string } = {}, column = "source"): { sql: string; params: unknown[] } { 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: [] }; @@ -283,7 +283,7 @@ function createWindow() { const OBELISK_DIR = path.join(os.homedir(), '.obelisk'); const RECAP_DIR = path.join(OBELISK_DIR, 'recap'); -let obeliskWatcher = null; +let obeliskWatcher: import("chokidar").FSWatcher | null = null; function startObeliskWatcher() { if (obeliskWatcher) return obeliskWatcher; @@ -340,7 +340,7 @@ 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, source FROM sessions`; - const params = []; + const params: unknown[] = []; const sourceFilter = sourceWhereClause(opts); if (sourceFilter.sql) { sql = appendWhere(sql, params, sourceFilter.sql); @@ -437,7 +437,7 @@ ipcMain.handle('db:getMessageFullText', (_, uuid) => { if (!match) return null; const rawThreadId = match[1]; const targetLine = Number(match[2]); - let jsonlPath = null; + 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; } @@ -469,7 +469,7 @@ ipcMain.handle('db:getMessageFullText', (_, uuid) => { } // Resolve JSONL path - let jsonlPath = null; + 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) { @@ -501,7 +501,7 @@ ipcMain.handle('db:getMessageFullText', (_, uuid) => { const content = obj.message?.content; if (typeof content === 'string') return content; if (!Array.isArray(content)) return null; - const parts = []; + 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); @@ -614,7 +614,7 @@ async function createExportCapture(parentWin, query) { nodeIntegration: false, offscreen: true, deviceScaleFactor: 2, - }, + } as Electron.WebPreferences, }); const isDev = process.argv.includes('--dev') || !!process.env.ELECTRON_RENDERER_URL; @@ -802,6 +802,7 @@ ipcMain.handle('settings:set', async (_, key, value) => { ipcMain.handle('settings:browseFolder', async (event) => { const win = BrowserWindow.fromWebContents(event.sender); + if (!win) return null; const { filePaths } = await dialog.showOpenDialog(win, { properties: ['openDirectory'], title: 'Select Claude Code data folder', @@ -851,7 +852,7 @@ ipcMain.handle('settings:rebuildIndex', async () => { try { openDb(paths.dbPath); } catch (error) { - console.warn?.(`Obelisk DB reopen after rebuild failed: ${error.message}`); + console.warn?.(`Obelisk DB reopen after rebuild failed: ${(error as Error).message}`); } } if (shouldRestartWatcher) startIndexerService({ buildOnStart: false }); diff --git a/app/src/main/indexer-service.js b/app/src/main/indexer-service.ts similarity index 76% rename from app/src/main/indexer-service.js rename to app/src/main/indexer-service.ts index 0b63fe3..407b310 100644 --- a/app/src/main/indexer-service.js +++ b/app/src/main/indexer-service.ts @@ -9,6 +9,34 @@ const DEFAULT_STABILITY_MS = 500; const DEFAULT_HEARTBEAT_MS = 30000; const DEFAULT_WATCH_RETRY_MS = 5000; +type TimerHandle = ReturnType; + +interface Timers { + setTimeout: (fn: () => void, ms?: number) => TimerHandle; + clearTimeout: (handle: TimerHandle) => void; + setInterval?: (fn: () => void, ms?: number) => TimerHandle; + clearInterval?: (handle: TimerHandle) => void; +} + +interface Watcher { + close(): unknown; +} + +interface IndexerServiceOptions { + projectsDir?: string; + watchDirs?: string | string[]; + debounceMs?: number; + stabilityMs?: number; + heartbeatMs?: number; + watchRetryMs?: number; + buildIndex?: (args: { reason?: string; changedPaths?: string[] }) => unknown; + writeHeartbeat?: () => void; + watchProjects?: (onChange: (changedPath: string) => void) => Watcher | null; + chokidar?: any; + timers?: Timers; + logger?: { warn?: (msg: string) => void }; +} + function createIndexerService({ projectsDir = DEFAULT_PROJECTS_DIR, watchDirs = [projectsDir], @@ -27,13 +55,13 @@ function createIndexerService({ clearInterval, }, logger = console, -} = {}) { +}: IndexerServiceOptions = {}) { if (typeof buildIndex !== 'function') throw new Error('createIndexerService() requires buildIndex'); const watch = watchProjects || ((onChange) => { const roots = [...new Set((Array.isArray(watchDirs) ? watchDirs : [watchDirs]).filter(Boolean))]; const existingRoots = roots.filter(root => fs.existsSync(root)); if (!existingRoots.length) return null; - const watchers = []; + const watchers: any[] = []; const onFileChange = (filename) => { const name = filename ? String(filename) : ''; if (!name || name.endsWith('.jsonl') || name.endsWith('.json')) onChange(name); @@ -57,7 +85,7 @@ function createIndexerService({ .on('change', onFileChange) .on('unlink', onFileChange) .on('error', (error) => { - logger.warn?.(`Obelisk watcher failed: ${error.message}`); + logger.warn?.(`Obelisk watcher failed: ${(error as Error).message}`); }); watchers.push(watcher); } @@ -68,19 +96,19 @@ function createIndexerService({ }; }); - let buildTimer = null; - let stabilityTimer = null; - let heartbeatTimer = null; - let watchRetryTimer = null; - let watcher = null; + let buildTimer: TimerHandle | null = null; + let stabilityTimer: TimerHandle | null = null; + let heartbeatTimer: TimerHandle | null = null; + let watchRetryTimer: TimerHandle | null = null; + let watcher: Watcher | null = null; let stopped = false; let running = false; let pending = false; - let lastReason = null; - let changedPaths = new Set(); + let lastReason: string | null = null; + let changedPaths = new Set(); let idlePromise = Promise.resolve(); - const addChangedPath = (changedPath) => { + const addChangedPath = (changedPath?: string | string[]) => { if (Array.isArray(changedPath)) { for (const p of changedPath) addChangedPath(p); return; @@ -96,7 +124,7 @@ function createIndexerService({ return paths; }; - const runBuildNow = (reason = 'manual', paths = undefined) => { + const runBuildNow = (reason = "manual", paths: string[] | undefined = undefined) => { addChangedPath(paths); if (stopped) return idlePromise; if (running) { @@ -113,7 +141,7 @@ function createIndexerService({ .catch((error) => { // A build in flight when the service is stopped (e.g. a manual rebuild // tears down the worker) is a deliberate cancellation, not a failure. - if (!stopped) logger.warn?.(`Obelisk index build failed: ${error.message}`); + if (!stopped) logger.warn?.(`Obelisk index build failed: ${(error as Error).message}`); }) .finally(() => { running = false; @@ -125,7 +153,7 @@ function createIndexerService({ return idlePromise; }; - const scheduleBuild = (reason = 'change', changedPath = undefined) => { + const scheduleBuild = (reason = "change", changedPath: string | undefined = undefined) => { if (stopped) return; addChangedPath(changedPath); lastReason = reason; @@ -165,7 +193,7 @@ function createIndexerService({ try { writeHeartbeat(); } catch (error) { - logger.warn?.(`Obelisk heartbeat failed: ${error.message}`); + logger.warn?.(`Obelisk heartbeat failed: ${(error as Error).message}`); } }, heartbeatMs); } diff --git a/app/src/main/indexer-worker-client.js b/app/src/main/indexer-worker-client.ts similarity index 59% rename from app/src/main/indexer-worker-client.js rename to app/src/main/indexer-worker-client.ts index 989312f..5653558 100644 --- a/app/src/main/indexer-worker-client.js +++ b/app/src/main/indexer-worker-client.ts @@ -4,23 +4,41 @@ import { Worker } from 'node:worker_threads'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +interface WorkerMessage { + id: number; + result?: unknown; + error?: { message: string; stack?: string }; +} + +interface PendingBuild { + resolve: (value: unknown) => void; + reject: (error: Error) => void; +} + +interface WorkerBuildIndexOptions { + workerPath?: string; + WorkerImpl?: typeof Worker; +} + function createWorkerBuildIndex({ + // indexer-worker.js is the built worker output emitted next to this module. workerPath = path.join(__dirname, 'indexer-worker.js'), WorkerImpl = Worker, -} = {}) { - let worker = null; +}: WorkerBuildIndexOptions = {}) { + let worker: Worker | null = null; let nextId = 1; - const pending = new Map(); + const pending = new Map(); - const rejectPending = (error) => { + const rejectPending = (error: Error) => { for (const { reject } of pending.values()) reject(error); pending.clear(); }; - const ensureWorker = () => { + const ensureWorker = (): Worker => { if (worker) return worker; - worker = new WorkerImpl(workerPath, { type: 'module' }); - worker.on('message', (message) => { + const active = new WorkerImpl(workerPath, { type: 'module' } as ConstructorParameters[1]); + worker = active; + active.on('message', (message: WorkerMessage) => { const current = pending.get(message.id); if (!current) return; pending.delete(message.id); @@ -32,18 +50,18 @@ function createWorkerBuildIndex({ current.resolve(message.result); } }); - worker.on('error', (error) => { + active.on('error', (error: Error) => { rejectPending(error); worker = null; }); - worker.on('exit', (code) => { + active.on('exit', (code: number) => { if (pending.size) rejectPending(new Error(`Indexer worker exited with code ${code}`)); worker = null; }); - return worker; + return active; }; - const buildIndex = (args = {}) => new Promise((resolve, reject) => { + const buildIndex = (args: Record = {}) => new Promise((resolve, reject) => { const id = nextId++; pending.set(id, { resolve, reject }); ensureWorker().postMessage({ id, args }); diff --git a/app/src/main/indexer-worker.js b/app/src/main/indexer-worker.js deleted file mode 100644 index 2650dc0..0000000 --- a/app/src/main/indexer-worker.js +++ /dev/null @@ -1,17 +0,0 @@ -import { parentPort } from 'node:worker_threads'; -import { buildIndex } from './indexer.js'; - -parentPort.on('message', ({ id, args }) => { - try { - const result = buildIndex(args || {}); - parentPort.postMessage({ id, result }); - } catch (error) { - parentPort.postMessage({ - id, - error: { - message: error.message, - stack: error.stack, - }, - }); - } -}); diff --git a/app/src/main/indexer-worker.ts b/app/src/main/indexer-worker.ts new file mode 100644 index 0000000..7055afa --- /dev/null +++ b/app/src/main/indexer-worker.ts @@ -0,0 +1,20 @@ +import { parentPort } from 'node:worker_threads'; +import { buildIndex } from './indexer.ts'; + +if (!parentPort) throw new Error('indexer-worker must run as a worker thread'); +const port = parentPort; + +port.on('message', ({ id, args }: { id: number; args?: Record }) => { + try { + const result = buildIndex(args || {}); + port.postMessage({ id, result }); + } catch (error) { + port.postMessage({ + id, + error: { + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }, + }); + } +}); diff --git a/app/src/main/indexer.js b/app/src/main/indexer.ts similarity index 91% rename from app/src/main/indexer.js rename to app/src/main/indexer.ts index 8c92e77..fed07f1 100644 --- a/app/src/main/indexer.js +++ b/app/src/main/indexer.ts @@ -25,12 +25,22 @@ 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'), path.join(__dirname, '..', '..', '..', 'scripts', 'schema.sql'), process.resourcesPath ? path.join(process.resourcesPath, 'scripts', 'schema.sql') : null, - ].filter(Boolean); + ].filter((c): c is string => Boolean(c)); const found = candidates.find(p => fs.existsSync(p)); if (!found) throw new Error('Obelisk schema.sql not found'); return found; @@ -41,7 +51,7 @@ function installSchema(db, schemaPath = resolveSchemaPath()) { migrateDb(db); } -function openIndexDb({ dbPath = DEFAULT_DB_PATH, schemaPath = resolveSchemaPath(), DatabaseImpl = Database } = {}) { +function openIndexDb({ dbPath = DEFAULT_DB_PATH, schemaPath = resolveSchemaPath(), DatabaseImpl = Database }: { dbPath?: string; schemaPath?: string; DatabaseImpl?: new (dbPath: string) => any } = {}) { fs.mkdirSync(path.dirname(dbPath), { recursive: true }); const db = new DatabaseImpl(dbPath); db.pragma('journal_mode = WAL'); @@ -104,7 +114,7 @@ function copyMemoriesFromDb(db, sourceDbPath) { } } -function discoverJsonlFiles({ projectsDir = DEFAULT_PROJECTS_DIR, changedPaths = undefined } = {}) { +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; @@ -168,8 +178,8 @@ function dedupeFileInfos(files) { return [...byPath.values()]; } -function discoverJsonlFilesForChanges({ projectsDir = DEFAULT_PROJECTS_DIR, changedPaths = [] } = {}) { - const files = []; +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); @@ -178,7 +188,7 @@ function discoverJsonlFilesForChanges({ projectsDir = DEFAULT_PROJECTS_DIR, chan } function discoverJsonlFilesFull({ projectsDir = DEFAULT_PROJECTS_DIR } = {}) { - const files = []; + const files: FileInfo[] = []; if (!fs.existsSync(projectsDir)) return files; let projects; try { projects = fs.readdirSync(projectsDir); } catch { return files; } @@ -219,7 +229,7 @@ function discoverJsonlFilesFull({ projectsDir = DEFAULT_PROJECTS_DIR } = {}) { return files; } -function discoverCodexJsonlFiles({ codexDir = DEFAULT_CODEX_DIR, changedPaths = undefined } = {}) { +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; @@ -244,8 +254,8 @@ function isPathInside(rootDir, candidate) { return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel); } -function discoverCodexJsonlFilesForChanges({ codexDir = DEFAULT_CODEX_DIR, changedPaths = [] } = {}) { - const files = []; +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); @@ -255,7 +265,7 @@ function discoverCodexJsonlFilesForChanges({ codexDir = DEFAULT_CODEX_DIR, chang } const sessionRelativePath = normalizeChangedPathForRoot(sessionsDir, changedPath); const fp = isPathInside(sessionsDir, rootRelativePath) ? rootRelativePath : sessionRelativePath; - if (!fp.endsWith('.jsonl') || !isPathInside(sessionsDir, fp)) continue; + if (!fp || !fp.endsWith(".jsonl") || !isPathInside(sessionsDir, fp)) continue; if (!fs.existsSync(fp)) continue; files.push({ path: fp, source: 'codex' }); } @@ -264,11 +274,11 @@ function discoverCodexJsonlFilesForChanges({ codexDir = DEFAULT_CODEX_DIR, chang function discoverCodexJsonlFilesFull({ codexDir = DEFAULT_CODEX_DIR } = {}) { const root = codexSessionsDir(codexDir); - const files = []; + const files: FileInfo[] = []; if (!fs.existsSync(root)) return files; const stack = [root]; while (stack.length) { - const current = stack.pop(); + const current = stack.pop()!; let entries; try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { @@ -308,7 +318,7 @@ function indexClaudeFile(db, file) { } function codexSessionMeta(filePath) { - let meta = null; + let meta: any = null; readLines(filePath, (line) => { let obj; try { obj = JSON.parse(line); } catch { return; } @@ -328,7 +338,7 @@ function indexCodexFile(db, file) { if (!needed) { if (guardian) { persist(db, { key: file.path, sessionId: '' }, (function* () { - yield { kind: 'delete-session', sessionId: codexDbId(guardian.threadRawId) }; + yield { kind: "delete-session", sessionId: codexDbId(guardian.threadRawId) as string }; return null; })()); } @@ -352,7 +362,7 @@ function indexCodexSessionIndex(db, { codexDir = DEFAULT_CODEX_DIR } = {}) { 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}`); + console.warn(`Warning: malformed Codex session index line: ${(error as Error).message}`); } }); } @@ -387,7 +397,7 @@ function indexSubagentMeta(db, fi) { 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); } } catch (error) { - console.warn(`Warning: failed to read subagent meta ${mp}: ${error.message}`); + console.warn(`Warning: failed to read subagent meta ${mp}: ${(error as Error).message}`); } } @@ -423,7 +433,7 @@ function indexWorkflows(db, { projectsDir = DEFAULT_PROJECTS_DIR } = {}) { item.durationMs||null, item.tokens||null, item.toolCalls||null, 'agent-' + item.agentId); } } catch (error) { - console.warn(`Warning: failed to index workflow ${f}: ${error.message}`); + console.warn(`Warning: failed to index workflow ${f}: ${(error as Error).message}`); } } } @@ -437,7 +447,7 @@ function indexHistory(db, { historyPath = DEFAULT_HISTORY_PATH } = {}) { const o = JSON.parse(line); if (o.sessionId && o.title) db.prepare('UPDATE sessions SET title=? WHERE id=? AND title IS NULL').run(o.title, o.sessionId); } catch (error) { - console.warn(`Warning: malformed history line: ${error.message}`); + console.warn(`Warning: malformed history line: ${(error as Error).message}`); } }); } @@ -488,6 +498,26 @@ function writeHeartbeat({ dbPath = DEFAULT_DB_PATH, DatabaseImpl = Database } = } } +interface BuildIndexOptions { + claudeDir?: string; + codexDir?: string; + projectsDir?: string; + historyPath?: string; + dbPath?: string; + schemaPath?: string; + DatabaseImpl?: new (dbPath: string) => any; + force?: boolean; + changedPaths?: string[]; + preserveDbPath?: string | null; +} + +interface BuildIndexResult { + files: number; + latestSourceMtime: number; + affectedSessionIds: string[]; + ftsRebuilt: boolean; +} + function buildIndex({ claudeDir = DEFAULT_CLAUDE_DIR, codexDir = path.join(path.dirname(claudeDir), '.codex'), @@ -499,7 +529,7 @@ function buildIndex({ force = false, changedPaths = undefined, preserveDbPath = null, -} = {}) { +}: BuildIndexOptions = {}): BuildIndexResult { const db = openIndexDb({ dbPath, schemaPath, DatabaseImpl }); let messageFtsTriggersDropped = false; if (preserveDbPath && path.resolve(preserveDbPath) !== path.resolve(dbPath)) { @@ -531,7 +561,7 @@ function buildIndex({ db.prepare("DELETE FROM workflows").run(); db.prepare("DELETE FROM workflow_agents").run(); } - const affectedSessionIds = new Set(); + const affectedSessionIds = new Set(); if (Array.isArray(changedPaths)) { for (const changedPath of changedPaths) { const sessionId = sessionIdFromChangedPath(projectsDir, changedPath); @@ -547,7 +577,7 @@ function buildIndex({ db.exec('COMMIT'); } catch (error) { db.exec('ROLLBACK'); - console.warn(`Warning: failed to index ${file.path}: ${error.message}`); + console.warn(`Warning: failed to index ${file.path}: ${(error as Error).message}`); } } db.exec('BEGIN'); @@ -575,7 +605,7 @@ function buildIndex({ try { installSchema(db, schemaPath); } catch (error) { - console.warn(`Warning: failed to restore message FTS triggers: ${error.message}`); + console.warn(`Warning: failed to restore message FTS triggers: ${(error as Error).message}`); } } checkpointDb(db); diff --git a/app/src/main/recap-capture-query.js b/app/src/main/recap-capture-query.ts similarity index 71% rename from app/src/main/recap-capture-query.js rename to app/src/main/recap-capture-query.ts index 4e037c4..70a4d3c 100644 --- a/app/src/main/recap-capture-query.js +++ b/app/src/main/recap-capture-query.ts @@ -1,11 +1,17 @@ import path from 'node:path'; -function cleanRecapFilename(filename) { +function cleanRecapFilename(filename?: string | null): string { if (!filename) return ''; return path.basename(String(filename)); } -function buildRecapExportQuery({ cardIdx = 0, archetype = '', filename = '' } = {}) { +interface RecapExportQueryOptions { + cardIdx?: number | string; + archetype?: string; + filename?: string; +} + +function buildRecapExportQuery({ cardIdx = 0, archetype = '', filename = '' }: RecapExportQueryOptions = {}): string { const params = new URLSearchParams(); const cardNumber = Number(cardIdx); params.set('card', Number.isFinite(cardNumber) ? String(cardNumber) : '0'); diff --git a/app/src/preload/index.js b/app/src/preload/index.js deleted file mode 100644 index c03fcd5..0000000 --- a/app/src/preload/index.js +++ /dev/null @@ -1,46 +0,0 @@ -import { contextBridge, ipcRenderer } from 'electron'; - -contextBridge.exposeInMainWorld('obelisk', { - getSessions: (opts) => ipcRenderer.invoke('db:getSessions', opts), - getSessionMessages: (id) => ipcRenderer.invoke('db:getSessionMessages', id), - getSessionToolCalls: (id) => ipcRenderer.invoke('db:getSessionToolCalls', id), - getSessionToolResults: (id) => ipcRenderer.invoke('db:getSessionToolResults', id), - getSessionSubagents: (id) => ipcRenderer.invoke('db:getSessionSubagents', id), - getSessionWorkflows: (id) => ipcRenderer.invoke('db:getSessionWorkflows', id), - getSubagentMessages: (agentId) => ipcRenderer.invoke('db:getSubagentMessages', agentId), - getSubagentToolCalls: (agentId) => ipcRenderer.invoke('db:getSubagentToolCalls', agentId), - getSubagentToolResults: (agentId) => ipcRenderer.invoke('db:getSubagentToolResults', agentId), - getSessionSummaries: (id) => ipcRenderer.invoke('db:getSessionSummaries', id), - getMessageFullText: (uuid) => ipcRenderer.invoke('db:getMessageFullText', uuid), - getMemories: () => ipcRenderer.invoke('db:getMemories'), - readMemoryFile: (path) => ipcRenderer.invoke('db:readMemoryFile', path), - archiveMemory: (id, reason) => ipcRenderer.invoke('db:archiveMemory', id, reason), - restoreMemory: (id) => ipcRenderer.invoke('db:restoreMemory', id), - getProjects: () => ipcRenderer.invoke('db:getProjects'), - getStats: () => ipcRenderer.invoke('db:getStats'), - getUsageStats: () => ipcRenderer.invoke('db:getUsageStats'), - onIndexUpdated: (callback) => { - const listener = (_, payload) => callback(payload); - ipcRenderer.on('obelisk:index-updated', listener); - return () => ipcRenderer.removeListener('obelisk:index-updated', listener); - }, - onSessionUpdated: (callback) => { - const listener = (_, payload) => callback(payload); - ipcRenderer.on('obelisk:session-updated', listener); - return () => ipcRenderer.removeListener('obelisk:session-updated', listener); - }, - captureExport: (opts) => ipcRenderer.invoke('capture:export', opts), - copyImage: (opts) => ipcRenderer.invoke('capture:copy', opts), - recapList: () => ipcRenderer.invoke('recap:list'), - recapRead: (filename) => ipcRenderer.invoke('recap:read', filename), - onRecapUpdated: (callback) => { - const listener = (_, filePath) => callback(filePath); - ipcRenderer.on('obelisk:recap-updated', listener); - return () => ipcRenderer.removeListener('obelisk:recap-updated', listener); - }, - getSettings: () => ipcRenderer.invoke('settings:get'), - browseFolder: () => ipcRenderer.invoke('settings:browseFolder'), - setSetting: (key, value) => ipcRenderer.invoke('settings:set', key, value), - revealPath: (p) => ipcRenderer.invoke('settings:revealPath', p), - rebuildIndex: () => ipcRenderer.invoke('settings:rebuildIndex'), -}); diff --git a/app/src/preload/index.ts b/app/src/preload/index.ts new file mode 100644 index 0000000..5308f25 --- /dev/null +++ b/app/src/preload/index.ts @@ -0,0 +1,46 @@ +import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'; + +contextBridge.exposeInMainWorld('obelisk', { + getSessions: (opts?: unknown) => ipcRenderer.invoke('db:getSessions', opts), + getSessionMessages: (id: string) => ipcRenderer.invoke('db:getSessionMessages', id), + getSessionToolCalls: (id: string) => ipcRenderer.invoke('db:getSessionToolCalls', id), + getSessionToolResults: (id: string) => ipcRenderer.invoke('db:getSessionToolResults', id), + getSessionSubagents: (id: string) => ipcRenderer.invoke('db:getSessionSubagents', id), + getSessionWorkflows: (id: string) => ipcRenderer.invoke('db:getSessionWorkflows', id), + getSubagentMessages: (agentId: string) => ipcRenderer.invoke('db:getSubagentMessages', agentId), + getSubagentToolCalls: (agentId: string) => ipcRenderer.invoke('db:getSubagentToolCalls', agentId), + getSubagentToolResults: (agentId: string) => ipcRenderer.invoke('db:getSubagentToolResults', agentId), + getSessionSummaries: (id: string) => ipcRenderer.invoke('db:getSessionSummaries', id), + getMessageFullText: (uuid: string) => ipcRenderer.invoke('db:getMessageFullText', uuid), + getMemories: () => ipcRenderer.invoke('db:getMemories'), + readMemoryFile: (path: string) => ipcRenderer.invoke('db:readMemoryFile', path), + archiveMemory: (id: string, reason?: string) => ipcRenderer.invoke('db:archiveMemory', id, reason), + restoreMemory: (id: string) => ipcRenderer.invoke('db:restoreMemory', id), + getProjects: () => ipcRenderer.invoke('db:getProjects'), + getStats: () => ipcRenderer.invoke('db:getStats'), + getUsageStats: () => ipcRenderer.invoke('db:getUsageStats'), + onIndexUpdated: (callback: (payload: unknown) => void) => { + const listener = (_: IpcRendererEvent, payload: unknown) => callback(payload); + ipcRenderer.on('obelisk:index-updated', listener); + return () => ipcRenderer.removeListener('obelisk:index-updated', listener); + }, + onSessionUpdated: (callback: (payload: unknown) => void) => { + const listener = (_: IpcRendererEvent, payload: unknown) => callback(payload); + ipcRenderer.on('obelisk:session-updated', listener); + return () => ipcRenderer.removeListener('obelisk:session-updated', listener); + }, + captureExport: (opts?: unknown) => ipcRenderer.invoke('capture:export', opts), + copyImage: (opts?: unknown) => ipcRenderer.invoke('capture:copy', opts), + recapList: () => ipcRenderer.invoke('recap:list'), + recapRead: (filename: string) => ipcRenderer.invoke('recap:read', filename), + onRecapUpdated: (callback: (filePath: unknown) => void) => { + const listener = (_: IpcRendererEvent, filePath: unknown) => callback(filePath); + ipcRenderer.on('obelisk:recap-updated', listener); + return () => ipcRenderer.removeListener('obelisk:recap-updated', listener); + }, + getSettings: () => ipcRenderer.invoke('settings:get'), + browseFolder: () => ipcRenderer.invoke('settings:browseFolder'), + setSetting: (key: string, value: unknown) => ipcRenderer.invoke('settings:set', key, value), + revealPath: (p: string) => ipcRenderer.invoke('settings:revealPath', p), + rebuildIndex: () => ipcRenderer.invoke('settings:rebuildIndex'), +}); diff --git a/app/tsconfig.json b/app/tsconfig.json new file mode 100644 index 0000000..5d183db --- /dev/null +++ b/app/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2023"], + "types": ["node"], + "strict": true, + "noImplicitAny": false, + "noEmit": true, + "allowImportingTsExtensions": true, + "allowJs": true, + "checkJs": false, + "erasableSyntaxOnly": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/main/**/*", "src/preload/**/*"], + "exclude": ["node_modules", "out", "dist", "release", "src/renderer"] +} diff --git a/docs/adr/0005-app-electron-vite-ts-esm.md b/docs/adr/0005-app-electron-vite-ts-esm.md index 5484d15..8a6bfcb 100644 --- a/docs/adr/0005-app-electron-vite-ts-esm.md +++ b/docs/adr/0005-app-electron-vite-ts-esm.md @@ -27,11 +27,32 @@ decisions within this: (ADR-0003) remains for the skill artifact; the app does not need it. - **better-sqlite3 stays the app's binding**, externalized (not bundled) and unpacked from the asar. +- **The app main + preload source is TypeScript with types at its seams**, but + under a *deliberately more lenient* project than the runtime core. `app/tsconfig.json` + keeps `strict` on yet sets `noImplicitAny: false`, because the app mostly + orchestrates the already-strictly-typed core (`scripts/`), and annotating every + internal SQLite-handle helper would be high-cost, low-value churn. Types are + added where they matter: the core-consumption seam (`BuildIndexOptions`/ + `BuildIndexResult`, `FileInfo`), the service/worker factories, and the IPC + bridge. Module-to-module specifiers use the real `.ts` extension (mirroring + `scripts/`, since Node's type-stripping does not rewrite `.js`→`.ts`), which + needs `allowImportingTsExtensions` (safe under the project's `noEmit`); the + worker's *runtime* path stays `indexer-worker.js` because that is the built + output. `@types/better-sqlite3` is a devDependency for the injected binding. + +**Two-tier typechecking.** `npm run typecheck` runs the root project (`scripts/` + +`tests/`, fully strict including `noImplicitAny`) and then the app project. The +root project **excludes the app-importing tests** (`tests/app-*.test.mjs`, +`tests/recap-capture-query.test.mjs`): those tests import app source, which would +otherwise drag the lenient app files into the strict root program and fail on +implicit `any`. The app source is instead covered by `app/tsconfig.json`, so +nothing loses type coverage — the strict core and the lenient app are checked by +the project that owns each, and never mixed. **Consequences.** The app is restructured into `src/{main,preload,renderer}` with -`electron.vite.config.ts`; each main module is a build input so CommonJS-style -require resolution and the indexer worker (`{ type: 'module' }`) resolve at -runtime. `npm run dev` is `electron-vite dev`. Tests that loaded app modules moved +`electron.vite.config.ts`; each main module is a build input so relative imports +between them and the indexer worker (`{ type: 'module' }`) resolve at runtime. +`npm run dev` is `electron-vite dev`. Tests that loaded app modules moved to ESM imports, and `app-main-settings` was rewritten from CJS `Module._load` mocking to `node:test` `mock.module` (needs `--experimental-test-module-mocks`). A future contributor may be tempted to make the preload ESM or disable the diff --git a/package.json b/package.json index 21af195..0285b46 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "license": "AGPL-3.0", "scripts": { "test": "node --experimental-test-module-mocks --test tests/*.test.mjs", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit && tsc --noEmit -p app/tsconfig.json", "lint": "eslint .", "build:core": "rm -rf dist && tsc -p tsconfig.build.json" }, diff --git a/tests/app-indexer-service.test.mjs b/tests/app-indexer-service.test.mjs index 7e23903..52b92ac 100644 --- a/tests/app-indexer-service.test.mjs +++ b/tests/app-indexer-service.test.mjs @@ -6,7 +6,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; const require = createRequire(import.meta.url); -import { createIndexerService } from '../app/src/main/indexer-service.js'; +import { createIndexerService } from '../app/src/main/indexer-service.ts'; function manualTimers() { const timers = new Set(); diff --git a/tests/app-indexer-worker-client.test.mjs b/tests/app-indexer-worker-client.test.mjs index 83233e1..2d074be 100644 --- a/tests/app-indexer-worker-client.test.mjs +++ b/tests/app-indexer-worker-client.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); -import { createWorkerBuildIndex } from '../app/src/main/indexer-worker-client.js'; +import { createWorkerBuildIndex } from '../app/src/main/indexer-worker-client.ts'; test('worker build client resolves build results from a worker thread', async () => { const instances = []; diff --git a/tests/app-indexer.test.mjs b/tests/app-indexer.test.mjs index 1095c85..9599986 100644 --- a/tests/app-indexer.test.mjs +++ b/tests/app-indexer.test.mjs @@ -6,7 +6,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; const require = createRequire(import.meta.url); -import { buildIndex } from '../app/src/main/indexer.js'; +import { buildIndex } from '../app/src/main/indexer.ts'; const { DatabaseSync } = require('node:sqlite'); class TestDatabase { diff --git a/tests/app-main-settings.test.mjs b/tests/app-main-settings.test.mjs index fb99aa0..2010e2e 100644 --- a/tests/app-main-settings.test.mjs +++ b/tests/app-main-settings.test.mjs @@ -22,7 +22,7 @@ const require = createRequire(import.meta.url); // resolve each bare specifier exactly as the main module sees it (ESM resolution // relative to the main module's directory) and mock that URL. Relative deps are // resolved against the main module URL directly. -const mainUrl = new URL('../app/src/main/index.js', import.meta.url); +const mainUrl = new URL('../app/src/main/index.ts', import.meta.url); const mainPath = fileURLToPath(mainUrl); const mainDir = fileURLToPath(new URL('.', mainUrl)); @@ -37,9 +37,9 @@ function esmResolve(specifier) { const ELECTRON_URL = esmResolve('electron'); const DATABASE_URL = esmResolve('better-sqlite3'); const CHOKIDAR_URL = esmResolve('chokidar'); -const INDEXER_URL = new URL('./indexer.js', mainUrl).href; -const INDEXER_SERVICE_URL = new URL('./indexer-service.js', mainUrl).href; -const INDEXER_WORKER_URL = new URL('./indexer-worker-client.js', mainUrl).href; +const INDEXER_URL = new URL('./indexer.ts', mainUrl).href; +const INDEXER_SERVICE_URL = new URL('./indexer-service.ts', mainUrl).href; +const INDEXER_WORKER_URL = new URL('./indexer-worker-client.ts', mainUrl).href; let importCounter = 0; diff --git a/tests/recap-capture-query.test.mjs b/tests/recap-capture-query.test.mjs index 1d7d4dd..0186f7b 100644 --- a/tests/recap-capture-query.test.mjs +++ b/tests/recap-capture-query.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); -import { buildRecapExportQuery, cleanRecapFilename } from '../app/src/main/recap-capture-query.js'; +import { buildRecapExportQuery, cleanRecapFilename } from '../app/src/main/recap-capture-query.ts'; test('recap export query includes the selected recap filename', () => { const query = buildRecapExportQuery({ diff --git a/tsconfig.json b/tsconfig.json index e28485c..95cd83d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,5 +16,12 @@ "forceConsistentCasingInFileNames": true }, "include": ["scripts/**/*", "tests/**/*"], - "exclude": ["node_modules", "app", "dist", "release"] + "exclude": [ + "node_modules", + "app", + "dist", + "release", + "tests/app-*.test.mjs", + "tests/recap-capture-query.test.mjs" + ] }