From 535d8edb4d6147517d2c464c23ec7300087fa87d Mon Sep 17 00:00:00 2001 From: tommy0103 Date: Sun, 12 Jul 2026 00:37:32 +0800 Subject: [PATCH] refactor(core): finish TypeScript workspace migration --- CONTEXT.md | 2 +- SKILL.md | 6 +- app/src/main/index.ts | 1 + app/src/main/indexer.ts | 4 +- .../adr/0001-parse-core-and-persist-layers.md | 5 +- .../0003-core-typescript-esm-precompiled.md | 5 +- docs/adr/0005-app-electron-vite-ts-esm.md | 11 +- ...te-transaction-rollback-and-concurrency.md | 5 +- eslint.config.js | 2 +- packages/core/src/core.ts | 13 +- packages/core/src/{db.mjs => db.ts} | 18 ++- packages/core/src/{indexer.mjs => indexer.ts} | 83 +++++++--- packages/core/src/{parsing.mjs => parsing.ts} | 129 +++++++++------ packages/core/src/providers/claude.ts | 2 +- packages/core/src/providers/codex.ts | 2 +- packages/core/src/{query.mjs => query.ts} | 150 +++++++++++------- packages/core/src/{runtime.mjs => runtime.ts} | 13 +- packages/core/tsconfig.build.json | 2 +- references/api-reference.md | 12 +- references/query-patterns.md | 6 +- tests/build-skill.test.mjs | 26 ++- tests/codex-index.test.mjs | 2 +- tests/contract-helper-shapes.test.mjs | 2 +- tests/daemon-arbitration.test.mjs | 8 +- tests/db-schema.test.mjs | 4 +- tests/incremental-index.test.mjs | 2 +- tests/indexer.test.mjs | 2 +- tests/query.test.mjs | 2 +- tests/runtime-cli-envelope.test.mjs | 2 +- tests/runtime.test.mjs | 2 +- tsconfig.skill.json | 2 +- 31 files changed, 326 insertions(+), 199 deletions(-) rename packages/core/src/{db.mjs => db.ts} (85%) rename packages/core/src/{indexer.mjs => indexer.ts} (84%) rename packages/core/src/{parsing.mjs => parsing.ts} (72%) rename packages/core/src/{query.mjs => query.ts} (84%) rename packages/core/src/{runtime.mjs => runtime.ts} (71%) diff --git a/CONTEXT.md b/CONTEXT.md index 04cca63..0b07070 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -31,7 +31,7 @@ promoted to an external tool surface. 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 -parse/discover helpers live in `scripts/parsing.mjs`, which imports only +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`). _Avoid_: parse core, parser, ingest diff --git a/SKILL.md b/SKILL.md index 34b3702..e1aead9 100644 --- a/SKILL.md +++ b/SKILL.md @@ -37,7 +37,7 @@ The skill directory is provided as `$SKILL_DIR` at invocation time. Fast keyword search: ```bash -node $SKILL_DIR/scripts/runtime.mjs --search "keyword" +node $SKILL_DIR/scripts/runtime.js --search "keyword" ``` Custom query: @@ -46,7 +46,7 @@ Custom query: 2. Run: ```bash - node $SKILL_DIR/scripts/runtime.mjs --query /tmp/q.mjs + node $SKILL_DIR/scripts/runtime.js --query /tmp/q.mjs ``` 3. Parse JSON stdout and answer with concise evidence. @@ -315,7 +315,7 @@ return remember({ Run the registration script with: ```bash -node $SKILL_DIR/scripts/runtime.mjs --attune /tmp/register-memory.mjs +node $SKILL_DIR/scripts/runtime.js --attune /tmp/register-memory.mjs ``` `--attune` exposes only memory mutation helpers: `remember()` and `forget()`. diff --git a/app/src/main/index.ts b/app/src/main/index.ts index e44ce86..4c576f1 100644 --- a/app/src/main/index.ts +++ b/app/src/main/index.ts @@ -130,6 +130,7 @@ function replaceDbWithTemp(tempDbPath, dbPath) { function resolveSchemaPath() { const candidates = [ path.join(__dirname, 'schema.sql'), + path.join(__dirname, '..', '..', '..', 'packages', 'core', 'src', 'schema.sql'), path.join(__dirname, '..', 'scripts', 'schema.sql'), process.resourcesPath ? path.join(process.resourcesPath, 'scripts', 'schema.sql') : null, ].filter((c): c is string => Boolean(c)); diff --git a/app/src/main/indexer.ts b/app/src/main/indexer.ts index 0493af9..56fe626 100644 --- a/app/src/main/indexer.ts +++ b/app/src/main/indexer.ts @@ -17,7 +17,7 @@ import { codexRawId, codexParentThreadId, readCodexGuardianThreadInfo, -} from '../../../packages/core/src/parsing.mjs'; +} from '../../../packages/core/src/parsing.ts'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -41,7 +41,7 @@ interface FileInfo { function resolveSchemaPath() { const candidates = [ path.join(__dirname, 'schema.sql'), - path.join(__dirname, '..', '..', '..', 'scripts', 'schema.sql'), + path.join(__dirname, '..', '..', '..', 'packages', 'core', 'src', 'schema.sql'), process.resourcesPath ? path.join(process.resourcesPath, 'scripts', 'schema.sql') : null, ].filter((c): c is string => Boolean(c)); const found = candidates.find(p => fs.existsSync(p)); diff --git a/docs/adr/0001-parse-core-and-persist-layers.md b/docs/adr/0001-parse-core-and-persist-layers.md index 79efdf1..0e91924 100644 --- a/docs/adr/0001-parse-core-and-persist-layers.md +++ b/docs/adr/0001-parse-core-and-persist-layers.md @@ -6,8 +6,9 @@ > adapters* (driven by the multi-provider roadmap), and there is *one* shared > persist layer, not one per binding. -**Context.** Obelisk had two divergent full indexers — `scripts/indexer.mjs` -(`node:sqlite`, skill/runtime) and `app/indexer.js` (`better-sqlite3`, Electron +**Context.** Obelisk had two divergent full indexers — the former +`scripts/indexer.mjs` (`node:sqlite`, skill/runtime) and `app/indexer.js` +(`better-sqlite3`, Electron app) — that duplicated the same Claude and Codex JSONL parsing and had silently diverged in write semantics (`INSERT OR REPLACE` vs `ON CONFLICT DO UPDATE`, message-count accumulation). Two forces shape the fix: (1) the roadmap will add diff --git a/docs/adr/0003-core-typescript-esm-precompiled.md b/docs/adr/0003-core-typescript-esm-precompiled.md index 059278c..fa4c9be 100644 --- a/docs/adr/0003-core-typescript-esm-precompiled.md +++ b/docs/adr/0003-core-typescript-esm-precompiled.md @@ -7,7 +7,8 @@ the skill artifact must install with **zero build step** on the user's machine its checkable contracts, but raises how the compiled output is shipped and which module format it targets. -**Decision.** Author all of Core in TypeScript and compile it ahead-of-time to +**Decision.** Author all of Core in the `@obelisk/core` npm workspace +(`packages/core`) in TypeScript and compile it ahead-of-time to **ESM JavaScript plus `.d.ts`**. The skill/CLI runtime ships the *precompiled* ESM JS, so installing the skill never runs a build. Rather than have Core dual-publish CJS+ESM, the Electron main process migrates to ESM at Phase 5 so it @@ -20,3 +21,5 @@ in exchange for no dual-build maintenance and a single module format across skil CLI, and app. The shipped skill artifact contains compiled JS, not TS. The renderer (Vue) is out of scope and stays JavaScript. Phase 3's TS baseline only adds root tooling (package.json, tsconfig, ESLint); it does not touch the app. +The app imports Core source so electron-vite can bundle it, while package and +skill builds compile the same workspace source to JavaScript. diff --git a/docs/adr/0005-app-electron-vite-ts-esm.md b/docs/adr/0005-app-electron-vite-ts-esm.md index 8a6bfcb..97ac1a5 100644 --- a/docs/adr/0005-app-electron-vite-ts-esm.md +++ b/docs/adr/0005-app-electron-vite-ts-esm.md @@ -20,8 +20,9 @@ decisions within this: security) does not support ESM preload. Source stays ESM; only the preload output format is CJS. `main` loads `../preload/index.js`. - **The app consumes the Core from source**: electron-vite/rollup bundles - `scripts/providers/*` + `scripts/persist` (and their `scripts/parsing.mjs` - dependency) into the app's main/worker build, injecting `better-sqlite3`. This + `packages/core/src/providers/*` + `packages/core/src/persist.ts` (and their + `packages/core/src/parsing.ts` dependency) into the app's main/worker build, + injecting `better-sqlite3`. This works because the provider→parsing import graph is node:sqlite-free (ADR-0001), so nothing drags `node:sqlite` into the app. The `dist/` from `build:core` (ADR-0003) remains for the skill artifact; the app does not need it. @@ -30,17 +31,17 @@ decisions within this: - **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 + orchestrates the already-strictly-typed core (`packages/core/src/`), 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 + Core source, 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/` + +**Two-tier typechecking.** `npm run typecheck` runs the root project (`packages/core/src/` + `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 diff --git a/docs/adr/0006-write-transaction-rollback-and-concurrency.md b/docs/adr/0006-write-transaction-rollback-and-concurrency.md index 5fe27fb..447f9fa 100644 --- a/docs/adr/0006-write-transaction-rollback-and-concurrency.md +++ b/docs/adr/0006-write-transaction-rollback-and-concurrency.md @@ -17,14 +17,15 @@ failed statement can replay part of a transaction. **Decision.** Use one transaction primitive plus two explicit coordination layers. -- `scripts/tx.ts` owns the binding-agnostic `runWriteTransaction(db, work)`. +- `packages/core/src/tx.ts` owns the binding-agnostic + `runWriteTransaction(db, work)`. Adapters expose transaction state from better-sqlite3's `inTransaction` and node:sqlite's `isTransaction`. The primitive performs `BEGIN IMMEDIATE`, runs `work` exactly once, commits, and attempts rollback only when the binding says a transaction is active or its state is unknown. Cleanup never masks the primary exception. Diagnostics record phase, SQLite code, rollback outcome, transaction state, label, and attempts. -- Retry is an upper-layer policy in `scripts/write-coordinator.ts`, never hidden +- Retry is an upper-layer policy in `packages/core/src/write-coordinator.ts`, never hidden inside the transaction primitive. Only an idempotent whole transaction that failed during work/commit with `SQLITE_BUSY*` and is confirmed inactive may be retried. The default is three attempts within a one-second budget with short diff --git a/eslint.config.js b/eslint.config.js index d158081..0ddb69d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,5 +1,5 @@ // Flat ESLint config for the Obelisk root (Core + skill runtime + tests). -// Scope: the ESM/TS sources under scripts/ and tests/. The Electron app has its +// Scope: the ESM/TS sources under packages/core/src/ and tests/. The Electron app has its // own package and toolchain and is intentionally excluded (see docs/adr/0003). import js from '@eslint/js'; diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index 1ea1aef..cea1174 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -1,19 +1,18 @@ // Obelisk Core package (see docs/adr/0003-core-typescript-esm-precompiled.md). // -// The single shared implementation behind every transport. runtime.mjs (skill), +// The single shared implementation behind every transport. runtime.js (skill), // and later the CLI and MCP server, are thin shells over these four functions; // none of them re-implement retrieval or own the DB lifecycle. // // Authored in TypeScript with erasable-only syntax so Node can run it directly -// via type stripping in development, while the skill artifact ships the tsc -// output (Phase 6). The heavy internals (db/indexer/query) remain .mjs for now -// and are migrated in later phases; Core is the typed seam over them. +// via type stripping in development, while the skill artifact ships readable, +// non-bundled tsc output. Core source lives in the @obelisk/core workspace. import { createContext, runInNewContext } from 'node:vm'; -import { DB_PATH, openDb, openReadDb, openWriterLeaseDb } from './db.mjs'; -import { buildIndex, shouldSkipBuild } from './indexer.mjs'; -import { createQueryApi, createAttuneApi } from './query.mjs'; +import { DB_PATH, openDb, openReadDb, openWriterLeaseDb } from './db.ts'; +import { buildIndex, shouldSkipBuild } from './indexer.ts'; +import { createQueryApi, createAttuneApi } from './query.ts'; import { acquireWriterLease, writerLockPathFor } from './writer-lease.ts'; export { buildIndex, DB_PATH }; diff --git a/packages/core/src/db.mjs b/packages/core/src/db.ts similarity index 85% rename from packages/core/src/db.mjs rename to packages/core/src/db.ts index a698a10..9247fa8 100644 --- a/packages/core/src/db.mjs +++ b/packages/core/src/db.ts @@ -1,6 +1,6 @@ // node:sqlite lifecycle and migrations for the Core package. import { createRequire } from 'node:module'; -import { CLAUDE_DIR, CODEX_DIR, TEXT_LIMIT, trunc, truncJson, extractText, extractContentType, extractMessageIsMeta, filePath, isDir, readLines } from './parsing.mjs'; +import { CLAUDE_DIR, CODEX_DIR, TEXT_LIMIT, trunc, truncJson, extractText, extractContentType, extractMessageIsMeta, filePath, isDir, readLines } from './parsing.ts'; import { configureConnection } from './tx.ts'; const require = createRequire(import.meta.url); const fs = require('node:fs'); @@ -8,6 +8,8 @@ const path = require('node:path'); const os = require('node:os'); const { DatabaseSync } = require('node:sqlite'); +type SqliteDb = any; + const OBELISK_DIR = path.join(os.homedir(), '.obelisk'); const LEGACY_DB_PATH = path.join(CLAUDE_DIR, 'obelisk.sqlite'); const DB_PATH = path.join(OBELISK_DIR, 'obelisk.sqlite'); @@ -39,20 +41,20 @@ function openReadDb() { return db; } -function openWriterLeaseDb(lockPath) { +function openWriterLeaseDb(lockPath: string): SqliteDb { return new DatabaseSync(lockPath); } -function ensureColumn(db, table, column, definition) { - const columns = db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name); +function ensureColumn(db: SqliteDb, table: string, column: string, definition: string): void { + const columns = db.prepare(`PRAGMA table_info(${table})`).all().map((c: { name: string }) => c.name); if (!columns.includes(column)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`); } -function tableExists(db, table) { +function tableExists(db: SqliteDb, table: string): boolean { return Boolean(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(table)); } -function migrateExistingColumns(db) { +function migrateExistingColumns(db: SqliteDb): void { if (tableExists(db, 'sessions')) ensureColumn(db, 'sessions', 'source', "TEXT DEFAULT 'claude'"); if (tableExists(db, 'messages')) { ensureColumn(db, 'messages', 'content_type', 'TEXT'); @@ -66,11 +68,11 @@ function migrateExistingColumns(db) { } } -function migrateDb(db) { +function migrateDb(db: SqliteDb): void { migrateExistingColumns(db); } -function rebuildMemoryFts(db) { +function rebuildMemoryFts(db: SqliteDb): void { db.exec("INSERT INTO memories_fts(memories_fts) VALUES('rebuild')"); } diff --git a/packages/core/src/indexer.mjs b/packages/core/src/indexer.ts similarity index 84% rename from packages/core/src/indexer.mjs rename to packages/core/src/indexer.ts index 5c42f50..0a0d186 100644 --- a/packages/core/src/indexer.mjs +++ b/packages/core/src/indexer.ts @@ -1,20 +1,48 @@ // Passive-pull indexing orchestration for the Core package. -import { DB_PATH, openDb, openReadDb, openWriterLeaseDb, rebuildMemoryFts } from './db.mjs'; +import { DB_PATH, openDb, openReadDb, openWriterLeaseDb, rebuildMemoryFts } from './db.ts'; import { CLAUDE_DIR, CODEX_DIR, PROJECTS_DIR, fs, path, isDir, readLines, inferProjectPath, discoverJsonlFiles, discoverCodexJsonlFiles, codexDbId, readCodexGuardianThreadInfo, -} from './parsing.mjs'; +} from './parsing.ts'; import { persist } from './persist.ts'; import { nodeSqliteTransactionAdapter } from './tx.ts'; import { acquireWriterLease, writerLockPathFor } from './writer-lease.ts'; import { runRetryableWriteTransaction, isBeginBusyFailure, hasUnusableTransaction } from './write-coordinator.ts'; import { parse as claudeParse } from './providers/claude.ts'; import { parse as codexParse } from './providers/codex.ts'; +import type { Cursor, IndexRecord } from './providers/types.ts'; const HISTORY_PATH = path.join(CLAUDE_DIR, 'history.jsonl'); +type SqliteDb = any; +type JsonRecord = Record; -function needsReindex(db, fp) { +interface ClaudeFileInfo { + path: string; + sessionId: string; + project: string; + isSubagent: boolean; + agentId?: string; + workflowRunId?: string; +} + +interface SkippedFile { + path: string; + error: string; + diagnostics?: unknown; +} + +interface BuildCheckOptions { + now?: number; + ignoreRecentBuild?: boolean; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + + +function needsReindex(db: SqliteDb, fp: string) { 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 }; @@ -22,15 +50,15 @@ function needsReindex(db, fp) { } -function indexCodexSessionIndex(db) { +function indexCodexSessionIndex(db: SqliteDb): void { const indexPath = path.join(CODEX_DIR, 'session_index.jsonl'); if (!fs.existsSync(indexPath)) return; readLines(indexPath, (line) => { - let item; + let item: JsonRecord; try { item = JSON.parse(line); } catch (e) { - process.stderr.write(`Warning: malformed Codex session index line: ${e.message}\n`); + process.stderr.write(`Warning: malformed Codex session index line: ${errorMessage(e)}\n`); return; } if (!item.id || !item.thread_name) return; @@ -39,7 +67,7 @@ function indexCodexSessionIndex(db) { }); } -function refreshSessionProjectPaths(db) { +function refreshSessionProjectPaths(db: SqliteDb): void { const sessions = db.prepare('SELECT id, project FROM sessions').all(); const cwdStmt = db.prepare(` SELECT cwd @@ -49,21 +77,21 @@ function refreshSessionProjectPaths(db) { `); const update = db.prepare('UPDATE sessions SET project_path = ? WHERE id = ?'); for (const session of sessions) { - const cwds = cwdStmt.all(session.id).map(row => row.cwd); + const cwds = cwdStmt.all(session.id).map((row: JsonRecord) => row.cwd); const projectPath = inferProjectPath(session.project, cwds); if (projectPath) update.run(projectPath, session.id); } } -function indexSubagentMeta(db, fi) { +function indexSubagentMeta(db: SqliteDb, fi: ClaudeFileInfo): void { if (!fi.isSubagent) return; const mp = fi.path.replace('.jsonl', '.meta.json'); if (!fs.existsSync(mp)) return; - let meta; + let meta: JsonRecord; try { meta = JSON.parse(fs.readFileSync(mp, 'utf8')); } catch (e) { - process.stderr.write(`Warning: failed to read subagent meta ${mp}: ${e.message}\n`); + process.stderr.write(`Warning: failed to read subagent meta ${mp}: ${errorMessage(e)}\n`); return; } 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); @@ -76,7 +104,7 @@ function indexSubagentMeta(db, fi) { } } -function indexWorkflows(db) { +function indexWorkflows(db: SqliteDb): void { if (!fs.existsSync(PROJECTS_DIR)) return; let projects; try { projects = fs.readdirSync(PROJECTS_DIR); } catch { return; } @@ -92,11 +120,11 @@ function indexWorkflows(db) { try { wfFiles = fs.readdirSync(wd); } catch { continue; } for (const f of wfFiles) { if (!f.endsWith('.json')) continue; - let wf; + let wf: JsonRecord; try { wf = JSON.parse(fs.readFileSync(path.join(wd, f), 'utf8')); } catch (e) { - process.stderr.write(`Warning: failed to read workflow ${f}: ${e.message}\n`); + process.stderr.write(`Warning: failed to read workflow ${f}: ${errorMessage(e)}\n`); continue; } if (!wf.runId) continue; @@ -117,14 +145,14 @@ function indexWorkflows(db) { } } -function indexHistory(db) { +function indexHistory(db: SqliteDb): void { if (!fs.existsSync(HISTORY_PATH)) return; readLines(HISTORY_PATH, (line) => { - let item; + let item: JsonRecord; try { item = JSON.parse(line); } catch (e) { - process.stderr.write(`Warning: malformed history line: ${e.message}\n`); + process.stderr.write(`Warning: malformed history line: ${errorMessage(e)}\n`); return; } if (item.sessionId && item.title) db.prepare('UPDATE sessions SET title=? WHERE id=? AND title IS NULL').run(item.title, item.sessionId); @@ -134,7 +162,7 @@ function indexHistory(db) { const BUILD_DEBOUNCE_MS = 30000; const APP_HEARTBEAT_FRESH_MS = 60000; -function shouldSkipBuild(db, { now = Date.now(), ignoreRecentBuild = false } = {}) { +function shouldSkipBuild(db: SqliteDb, { now = Date.now(), ignoreRecentBuild = false }: BuildCheckOptions = {}) { const appHeartbeat = db.prepare("SELECT mtime FROM index_state WHERE jsonl_path='__app_heartbeat__'").get(); if (appHeartbeat && now - appHeartbeat.mtime < APP_HEARTBEAT_FRESH_MS) { return { skip: true, reason: 'daemon_active' }; @@ -148,12 +176,12 @@ function shouldSkipBuild(db, { now = Date.now(), ignoreRecentBuild = false } = { return { skip: false }; } -function isMissingIndexStateTable(error) { +function isMissingIndexStateTable(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); return /no such table:\s*(?:main\.)?index_state\b/i.test(message); } -function inspectBuildOwnership({ force = false } = {}) { +function inspectBuildOwnership({ force = false }: { force?: boolean } = {}) { if (!fs.existsSync(DB_PATH)) return { skip: false }; const db = openReadDb(); try { @@ -170,12 +198,12 @@ function inspectBuildOwnership({ force = false } = {}) { // A one-shot record stream that retracts a session, for routing guardian sweeps // through persist (the single db writer) instead of deleting rows directly. -function* guardianDelete(sessionId) { +function* guardianDelete(sessionId: string): Generator { yield { kind: 'delete-session', sessionId }; return null; } -function buildIndex({ force = false } = {}) { +function buildIndex({ force = false }: { force?: boolean } = {}) { const ownership = inspectBuildOwnership({ force }); if (ownership.skip) return ownership; const lease = acquireWriterLease({ @@ -190,7 +218,7 @@ function buildIndex({ force = false } = {}) { const db = openDb(); const txDb = nodeSqliteTransactionAdapter(db); - const skippedFiles = []; + const skippedFiles: SkippedFile[] = []; try { try { if (force) { @@ -232,7 +260,8 @@ function buildIndex({ force = false } = {}) { } else { const guardian = readCodexGuardianThreadInfo(f.path); if (guardian) { - persist(db, { key: f.path, sessionId: '' }, guardianDelete(codexDbId(guardian.threadRawId))); + const sessionId = codexDbId(guardian.threadRawId); + if (sessionId) persist(db, { key: f.path, sessionId: '' }, guardianDelete(sessionId)); } } } else { @@ -253,8 +282,10 @@ function buildIndex({ force = false } = {}) { } if (hasUnusableTransaction(e)) throw e; // A per-file failure is skippable: log and move on. - skippedFiles.push({ path: f.path, error: e.message, diagnostics: e.obelisk }); - process.stderr.write(`Warning: failed to index ${f.path}: ${e.message}\n`); + const error = e as { message?: unknown; obelisk?: unknown } | null; + const message = errorMessage(e); + skippedFiles.push({ path: f.path, error: message, diagnostics: error?.obelisk }); + process.stderr.write(`Warning: failed to index ${f.path}: ${message}\n`); } } // Finalize is one transaction and is NOT swallowed: a finalize failure fails diff --git a/packages/core/src/parsing.mjs b/packages/core/src/parsing.ts similarity index 72% rename from packages/core/src/parsing.mjs rename to packages/core/src/parsing.ts index 9217026..8ad4502 100644 --- a/packages/core/src/parsing.mjs +++ b/packages/core/src/parsing.ts @@ -1,7 +1,7 @@ // Core's pure parse/discover helpers — node:sqlite-free by construction, so the compiled // providers can be consumed by the app (better-sqlite3 / a Node without -// node:sqlite). Moved verbatim from db.mjs and indexer.mjs (Phase 5d-1); they use -// only node:fs/path/os. Kept as .mjs (plain ESM) for a low-risk verbatim move. +// node:sqlite). Originally extracted verbatim from db/indexer; it now exposes a +// typed seam while remaining limited to node:fs/path/os. import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const fs = require('node:fs'); @@ -14,18 +14,41 @@ const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects'); const CODEX_SESSIONS_DIR = path.join(CODEX_DIR, 'sessions'); const TEXT_LIMIT = 10000; -// ---- from db.mjs (message/text helpers) ---- -function trunc(s) { +type JsonRecord = Record; +type JsonValue = any; + +interface ClaudeJsonlFile { + path: string; + sessionId: string; + project: string; + isSubagent: boolean; + agentId?: string; + workflowRunId?: string; + source?: 'claude'; +} + +interface CodexJsonlFile { + path: string; + source: 'codex'; +} + +interface CodexLineRecord { + lineNum: number; + obj: JsonRecord; +} + +// ---- message/text helpers ---- +function trunc(s: any): any { return typeof s === 'string' && s.length > TEXT_LIMIT ? s.slice(0, TEXT_LIMIT) : s; } -function truncJson(obj, limit = TEXT_LIMIT) { +function truncJson(obj: JsonValue, limit = TEXT_LIMIT): string | null { if (obj === null || obj === undefined) return null; - const walk = (v) => { + const walk = (v: JsonValue): JsonValue => { if (typeof v === 'string') return v.length > limit ? v.slice(0, limit) + '...[truncated]' : v; if (Array.isArray(v)) return v.map(walk); if (typeof v === 'object' && v !== null) { - const out = {}; + const out: JsonRecord = {}; for (const [k, val] of Object.entries(v)) out[k] = walk(val); return out; } @@ -34,10 +57,10 @@ function truncJson(obj, limit = TEXT_LIMIT) { return JSON.stringify(walk(obj)); } -function extractText(content) { +function extractText(content: JsonValue): string | null { if (typeof content === 'string') return trunc(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); @@ -45,10 +68,10 @@ function extractText(content) { return parts.length ? trunc(parts.join('\n')) : null; } -function extractContentType(content) { +function extractContentType(content: JsonValue): string { if (typeof content === 'string') return 'text'; if (!Array.isArray(content) || !content.length) return 'unknown'; - const types = new Set(); + const types = new Set(); let sawUnknown = false; for (const b of content) { if (!b || typeof b !== 'object') { sawUnknown = true; continue; } @@ -63,20 +86,20 @@ function extractContentType(content) { const COMMAND_ENVELOPE_RE = /^\s*([^<]+<\/command-name>|<(?:task-notification|system-reminder)\b| boolean | void): void { const fd = fs.openSync(filePath, 'r'); const bufSize = 64 * 1024; const buf = Buffer.alloc(bufSize); @@ -86,7 +109,7 @@ function readLines(filePath, callback) { while ((bytesRead = fs.readSync(fd, buf, 0, bufSize)) > 0) { const chunk = remainder + buf.toString('utf8', 0, bytesRead); const lines = chunk.split('\n'); - remainder = lines.pop(); + remainder = lines.pop() ?? ''; for (const line of lines) { if (line && callback(line) === false) return; } @@ -97,25 +120,25 @@ function readLines(filePath, callback) { } } -// ---- from indexer.mjs (project-path + discovery helpers) ---- -function legacyProjectPathFromSlug(project) { +// ---- project-path + discovery helpers ---- +function legacyProjectPathFromSlug(project: string | null | undefined): string | null { if (!project) return null; return '/' + project.replace(/-/g, '/').replace(/^\//, ''); } -function normalizeObservedCwd(cwd) { +function normalizeObservedCwd(cwd: unknown): string | null { if (typeof cwd !== 'string' || !cwd.trim() || !path.isAbsolute(cwd)) return null; return path.normalize(cwd); } -function projectSlugFromPath(projectPath) { +function projectSlugFromPath(projectPath: string | null): string | null { const normalized = normalizeObservedCwd(projectPath); if (!normalized) return null; return '-' + normalized.replace(/^[\\/]+/, '').replace(/[\\/]+/g, '-'); } -function inferProjectPath(project, observedCwds = []) { - const byPath = new Map(); +function inferProjectPath(project: string | null | undefined, observedCwds: unknown[] = []): string | null { + const byPath = new Map(); for (const cwd of observedCwds) { const normalized = normalizeObservedCwd(cwd); if (!normalized) continue; @@ -127,11 +150,11 @@ function inferProjectPath(project, observedCwds = []) { return best?.path || legacyProjectPathFromSlug(project); } -function discoverJsonlFiles() { - const files = []; +function discoverJsonlFiles(): ClaudeJsonlFile[] { + const files: ClaudeJsonlFile[] = []; if (!fs.existsSync(PROJECTS_DIR)) return files; let projects; - try { projects = fs.readdirSync(PROJECTS_DIR); } catch (e) { process.stderr.write(`Warning: cannot read projects dir: ${e.message}\n`); return files; } + try { projects = fs.readdirSync(PROJECTS_DIR); } catch (e) { process.stderr.write(`Warning: cannot read projects dir: ${e instanceof Error ? e.message : String(e)}\n`); return files; } for (const proj of projects) { const projPath = path.join(PROJECTS_DIR, proj); if (!isDir(projPath)) continue; @@ -169,10 +192,10 @@ function discoverJsonlFiles() { return files; } -function discoverCodexJsonlFiles() { - const files = []; +function discoverCodexJsonlFiles(): CodexJsonlFile[] { + const files: CodexJsonlFile[] = []; if (!fs.existsSync(CODEX_SESSIONS_DIR)) return files; - const walk = (dir) => { + const walk = (dir: string): void => { let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { @@ -188,27 +211,27 @@ function discoverCodexJsonlFiles() { return files; } -// ---- from indexer.mjs (codex pure helpers) ---- -function codexDbId(id) { +// ---- Codex pure helpers ---- +function codexDbId(id: unknown): string | null { if (!id) return null; const raw = String(id).replace(/^codex:/, ''); return `codex:${raw}`; } -function codexRawId(id) { +function codexRawId(id: unknown): string | null { return id ? String(id).replace(/^codex:/, '') : null; } -function codexLineUuid(threadId, lineNum) { +function codexLineUuid(threadId: unknown, lineNum: number): string { return `codex:${codexRawId(threadId)}:${String(lineNum).padStart(6, '0')}`; } -function codexCallId(callId) { +function codexCallId(callId: unknown): string | null { if (!callId) return null; return `codex:${String(callId).replace(/^codex:/, '')}`; } -function codexParentThreadId(meta) { +function codexParentThreadId(meta: JsonRecord): string | null { const subagent = meta?.source?.subagent; return subagent?.thread_spawn?.parent_thread_id || meta?.forked_from_id @@ -216,20 +239,20 @@ function codexParentThreadId(meta) { || null; } -function codexIsGuardianThread(meta, records = []) { +function codexIsGuardianThread(meta: JsonRecord, records: CodexLineRecord[] = []): boolean { 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 readCodexGuardianThreadInfo(filePath) { - const records = []; - let metaRecord = null; +function readCodexGuardianThreadInfo(filePath: string): { threadRawId: string; lineNum: number } | null { + const records: CodexLineRecord[] = []; + let metaRecord: CodexLineRecord | null = null; let lineNum = 0; readLines(filePath, (line) => { lineNum++; - let obj; + let obj: JsonRecord; try { obj = JSON.parse(line); } catch { @@ -243,30 +266,32 @@ function readCodexGuardianThreadInfo(filePath) { } if (metaRecord && codexIsGuardianThread(metaRecord.obj.payload, records)) return false; }); - const meta = metaRecord?.obj?.payload; + const capturedMeta = metaRecord as CodexLineRecord | null; + const meta = capturedMeta?.obj?.payload; if (!meta || !codexIsGuardianThread(meta, records)) return null; - return { threadRawId: codexRawId(meta.id), lineNum }; + const threadRawId = codexRawId(meta.id); + return threadRawId ? { threadRawId, lineNum } : null; } -function codexAgentNickname(meta) { +function codexAgentNickname(meta: JsonRecord): string | null { return meta?.agent_nickname || meta?.source?.subagent?.thread_spawn?.agent_nickname || null; } -function codexAgentRole(meta) { +function codexAgentRole(meta: JsonRecord): string | null { return meta?.agent_role || meta?.source?.subagent?.thread_spawn?.agent_role || null; } -function parseCodexJsonInput(value) { +function parseCodexJsonInput(value: JsonValue): JsonValue { if (value === null || value === undefined || value === '') return {}; if (typeof value !== 'string') return value; try { return JSON.parse(value); } catch { return value; } } -function codexUsage(payload) { +function codexUsage(payload: JsonRecord) { const usage = payload?.info?.last_token_usage || payload?.info?.total_token_usage || payload?.last_token_usage || null; if (!usage) return {}; return { @@ -275,37 +300,37 @@ function codexUsage(payload) { }; } -function codexEventText(payload) { +function codexEventText(payload: JsonRecord): string | null { 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); + const parts = payload.text_elements.map((item: JsonValue) => 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) { +function codexMessagePayloadText(payload: JsonRecord): string | null { if (!Array.isArray(payload?.content)) return null; - const parts = []; + const parts: string[] = []; 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) { +function codexVisibleMessageKey(role: unknown, text: unknown): string { return `${role || ''}\u0000${text || ''}`; } -function codexToolInput(payload) { +function codexToolInput(payload: JsonRecord): JsonValue { 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) { +function codexToolOutput(payload: JsonRecord): string | null { 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); diff --git a/packages/core/src/providers/claude.ts b/packages/core/src/providers/claude.ts index d7002e5..52715f8 100644 --- a/packages/core/src/providers/claude.ts +++ b/packages/core/src/providers/claude.ts @@ -13,7 +13,7 @@ const fs = require('node:fs'); import { extractText, extractContentType, extractMessageIsMeta, filePath, trunc, truncJson, readLines, discoverJsonlFiles, -} from '../parsing.mjs'; +} from '../parsing.ts'; import type { Cursor, DiscoverContext, IndexRecord, IndexUnit, Provider } from './types.ts'; diff --git a/packages/core/src/providers/codex.ts b/packages/core/src/providers/codex.ts index b14c75a..05ecd4c 100644 --- a/packages/core/src/providers/codex.ts +++ b/packages/core/src/providers/codex.ts @@ -19,7 +19,7 @@ import { codexIsGuardianThread, codexAgentNickname, codexAgentRole, codexUsage, codexEventText, codexMessagePayloadText, codexVisibleMessageKey, codexToolInput, codexToolOutput, -} from '../parsing.mjs'; +} from '../parsing.ts'; import type { Cursor, DiscoverContext, IndexRecord, IndexUnit, MessageRecord, Provider } from './types.ts'; diff --git a/packages/core/src/query.mjs b/packages/core/src/query.ts similarity index 84% rename from packages/core/src/query.mjs rename to packages/core/src/query.ts index 7127984..05bbdc4 100644 --- a/packages/core/src/query.mjs +++ b/packages/core/src/query.ts @@ -1,16 +1,58 @@ // Query and attune sandbox helpers for the Core package. -import { readLines, fs, path } from './db.mjs'; +import { readLines, fs, path } from './db.ts'; -function normalizeOpts(optsOrScalar, scalarKey = 'sessionId') { +type SqliteDb = any; +type DbRow = Record; + +interface QueryOptions extends Record { + limit?: number; + sessionId?: string; + sessions?: string[]; + project?: string; + after?: string; + before?: string; + cwd?: string; + branch?: string; + source?: string; + includeMeta?: boolean; + query?: string; + projectLimit?: number; + memoryLimit?: number; +} + +interface ColumnAliases { + sessionId: string; + project: string; + timestamp: string; + branch: string; + source?: string; +} + +interface RememberInput { + path: string; + session_id?: string; + message_start?: string; + message_end?: string; + summary: string; + project?: string; + anchors?: unknown; +} + +interface ForgetInput { + id: string; + reason: string; +} + +function normalizeOpts(optsOrScalar: QueryOptions | string | number | null | undefined, scalarKey = 'sessionId'): QueryOptions { if (optsOrScalar == null) return {}; if (typeof optsOrScalar === 'string') return { [scalarKey]: optsOrScalar }; if (typeof optsOrScalar === 'number') return { limit: optsOrScalar }; return optsOrScalar; } -function buildWhere(opts, aliases) { - const clauses = []; - const params = []; +function buildWhere(opts: QueryOptions, aliases: ColumnAliases) { + const clauses: string[] = []; + const params: any[] = []; if (opts.sessionId) { clauses.push(`${aliases.sessionId} = ?`); params.push(opts.sessionId); } if (opts.sessions?.length) { clauses.push(`${aliases.sessionId} IN (${opts.sessions.map(() => '?').join(',')})`); @@ -29,7 +71,7 @@ function buildWhere(opts, aliases) { const BASH_EXIT_PAT = 'Exit code %'; -function assertReadOnlySql(sql) { +function assertReadOnlySql(sql: unknown): void { const text = String(sql || '').trim(); if (!/^(SELECT|WITH)\b/i.test(text)) { throw new Error('sql() only supports read-only SELECT/WITH queries'); @@ -41,7 +83,7 @@ function assertReadOnlySql(sql) { const CJK_TEXT_RE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u; -function assertEnglishMemoryText(value, label) { +function assertEnglishMemoryText(value: unknown, label: string): void { const text = String(value || ''); if (!text.trim()) return; if (CJK_TEXT_RE.test(text)) { @@ -50,7 +92,7 @@ function assertEnglishMemoryText(value, label) { } } -function buildSafeFtsQuery(text) { +function buildSafeFtsQuery(text: unknown): string { const tokens = String(text || '').match(/[\p{Letter}\p{Number}]+/gu) || []; return tokens .slice(0, 12) @@ -58,23 +100,23 @@ function buildSafeFtsQuery(text) { .join(' '); } -function createQueryApi(db) { - const q = (sql, ...p) => { +function createQueryApi(db: SqliteDb) { + const q = (sql: string, ...p: any[]) => { assertReadOnlySql(sql); return db.prepare(sql).all(...p); }; - const normalizeOverviewOpts = (optsOrScalar) => { + const normalizeOverviewOpts = (optsOrScalar: QueryOptions | string | number | null | undefined): QueryOptions => { if (optsOrScalar == null) return {}; if (typeof optsOrScalar === 'string') return { project: optsOrScalar }; if (typeof optsOrScalar === 'number') return { limit: optsOrScalar }; return optsOrScalar; }; - const search = (text, opts = {}) => { + const search = (text: string, opts: QueryOptions = {}) => { const { limit = 20, sessionId, project, after, before, cwd, source, includeMeta = false } = opts; let where = 'WHERE mf.text MATCH ?'; - const filterParams = []; + const filterParams: any[] = []; if (sessionId) { where += ' AND mf.session_id=?'; filterParams.push(sessionId); } if (project) { where += ' AND s.project LIKE ?'; filterParams.push(project); } if (after) { where += ' AND m.timestamp>?'; filterParams.push(after); } @@ -89,7 +131,7 @@ function createQueryApi(db) { rank FROM messages_fts mf JOIN messages m ON m.uuid=mf.uuid LEFT JOIN sessions s ON s.id=m.session_id ${where} ORDER BY rank LIMIT ?`); - const runMatch = (matchText) => stmt.all(matchText, ...filterParams, limit); + const runMatch = (matchText: string): DbRow[] => stmt.all(matchText, ...filterParams, limit); // Honor raw FTS5 syntax when the query is valid, but never crash on ordinary // input (hyphens, punctuation) that FTS5 would parse as operators: fall back // to safe per-token quoting, the same tokenization memories() uses. @@ -100,11 +142,11 @@ function createQueryApi(db) { const safe = buildSafeFtsQuery(text); rows = safe ? runMatch(safe) : []; } - return rows.map(r => { + return rows.map((r: DbRow) => { const metaClause = includeMeta ? '' : 'AND COALESCE(is_meta,0)=0'; const ctx = db.prepare( `SELECT uuid,text,content_type,is_meta,role,timestamp,model,COALESCE(source, 'claude') as source FROM messages WHERE session_id=? AND uuid!=? ${metaClause} ORDER BY ABS(JULIANDAY(timestamp)-JULIANDAY(?)) LIMIT 6` - ).all(r.session_id, r.uuid, r.timestamp).sort((a,b) => a.timestamp < b.timestamp ? -1 : 1); + ).all(r.session_id, r.uuid, r.timestamp).sort((a: DbRow, b: DbRow) => a.timestamp < b.timestamp ? -1 : 1); const sourceValue = r.m_source || r.s_source || 'claude'; return { message: { uuid: r.uuid, text: r.text, content_type: r.content_type, is_meta: r.is_meta || 0, role: r.role, timestamp: r.timestamp, model: r.model, cwd: r.cwd, source: sourceValue }, @@ -115,14 +157,14 @@ function createQueryApi(db) { }); }; - const context = (uuid) => { + const context = (uuid: string) => { const msg = db.prepare('SELECT * FROM messages WHERE uuid=?').get(uuid); if (!msg) return null; const session = db.prepare('SELECT * FROM sessions WHERE id=?').get(msg.session_id); - const chain = []; + const chain: DbRow[] = []; let cur = msg; while (cur?.parent_uuid) { cur = db.prepare('SELECT * FROM messages WHERE uuid=?').get(cur.parent_uuid); if (cur) chain.unshift(cur); } - let subagent = msg.agent_id ? db.prepare('SELECT * FROM subagents WHERE agent_id=?').get(msg.agent_id) : null; + const subagent = msg.agent_id ? db.prepare('SELECT * FROM subagents WHERE agent_id=?').get(msg.agent_id) : null; let workflow = null; if (msg.agent_id) { const wa = db.prepare('SELECT * FROM workflow_agents WHERE agent_id=?').get(msg.agent_id); @@ -131,33 +173,33 @@ function createQueryApi(db) { return { message: msg, parentChain: chain, session, subagent, workflow }; }; - const trace = (uuid) => { - const chain = []; + const trace = (uuid: string) => { + const chain: DbRow[] = []; let cur = db.prepare('SELECT * FROM messages WHERE uuid=?').get(uuid); while (cur) { chain.unshift(cur); cur = cur.parent_uuid ? db.prepare('SELECT * FROM messages WHERE uuid=?').get(cur.parent_uuid) : null; } return chain; }; - const thread = (sid, opts = {}) => { + const thread = (sid: string, opts: QueryOptions = {}) => { const includeMeta = opts?.includeMeta === true; const metaClause = includeMeta ? '' : 'AND COALESCE(is_meta,0)=0'; return db.prepare(`SELECT * FROM messages WHERE session_id=? ${metaClause} ORDER BY timestamp`).all(sid); }; - const subagents = (optsOrSid) => { + const subagents = (optsOrSid?: QueryOptions | string) => { const opts = normalizeOpts(optsOrSid); const { limit = 100 } = opts; const needsJoin = opts.project || opts.branch || opts.source; const { where, params } = buildWhere(opts, { sessionId: 'sa.session_id', project: 's.project', timestamp: 'sa.session_id', branch: 's.git_branch', source: 's.source' }); params.push(limit); const join = needsJoin ? 'LEFT JOIN sessions s ON s.id=sa.session_id' : ''; - return db.prepare(`SELECT sa.* FROM subagents sa ${join} WHERE ${where} LIMIT ?`).all(...params).map(r => { + return db.prepare(`SELECT sa.* FROM subagents sa ${join} WHERE ${where} LIMIT ?`).all(...params).map((r: DbRow) => { const c = db.prepare('SELECT COUNT(*) as c FROM messages WHERE agent_id=?').get(r.agent_id); return { ...r, messageCount: c?.c || 0 }; }); }; - const workflows = (optsOrSid) => { + const workflows = (optsOrSid?: QueryOptions | string) => { const opts = normalizeOpts(optsOrSid); const { limit = 100 } = opts; const needsJoin = opts.project || opts.branch || opts.source; @@ -167,36 +209,36 @@ function createQueryApi(db) { return db.prepare(`SELECT w.* FROM workflows w ${join} WHERE ${where} ORDER BY w.timestamp DESC LIMIT ?`).all(...params); }; - const workflowTree = (runId) => { + const workflowTree = (runId: string) => { const wf = db.prepare('SELECT * FROM workflows WHERE run_id=?').get(runId); if (!wf) return null; let result = null; - try { result = JSON.parse(wf.result_json); } catch {} - const agents = db.prepare('SELECT * FROM workflow_agents WHERE run_id=?').all(runId).map(a => { + try { result = JSON.parse(wf.result_json); } catch { /* keep the raw result nullable */ } + const agents = db.prepare('SELECT * FROM workflow_agents WHERE run_id=?').all(runId).map((a: DbRow) => { const mc = db.prepare('SELECT COUNT(*) as c FROM messages WHERE agent_id=?').get(a.agent_id); return { ...a, messageCount: mc?.c || 0 }; }); return { ...wf, result, agents }; }; - const fileHistory = (fp, opts = {}) => { + const fileHistory = (fp: string, opts: QueryOptions = {}) => { const { limit = 200, after, before, source } = opts; let where = 'tc.file_path=?'; - const params = [fp]; + const params: any[] = [fp]; if (after) { where += ' AND m.timestamp > ?'; params.push(after); } if (before) { where += ' AND m.timestamp < ?'; params.push(before); } if (source && source !== 'all') { where += " AND COALESCE(s.source, 'claude') = ?"; params.push(source); } params.push(limit); return db.prepare( `SELECT tc.*,s.title as s_title,s.project as s_project,m.timestamp as ts FROM tool_calls tc LEFT JOIN sessions s ON s.id=tc.session_id LEFT JOIN messages m ON m.uuid=tc.message_uuid WHERE ${where} ORDER BY m.timestamp LIMIT ?` - ).all(...params).map(r => ({ + ).all(...params).map((r: DbRow) => ({ toolCall: { id: r.id, message_uuid: r.message_uuid, name: r.name, input_json: r.input_json }, session: { id: r.session_id, title: r.s_title, project: r.s_project }, timestamp: r.ts, })); }; - const failures = (optsOrSid) => { + const failures = (optsOrSid?: QueryOptions | string) => { const opts = normalizeOpts(optsOrSid); const { limit = 50 } = opts; const needsJoin = opts.project || opts.branch || opts.source; @@ -205,7 +247,7 @@ function createQueryApi(db) { const errorCond = `(tr.is_error = 1 OR tr.content LIKE '${BASH_EXIT_PAT}')`; const allParams = [...filterParams, limit]; const rows = db.prepare(`SELECT tr.* FROM tool_results tr ${join} LEFT JOIN messages rm ON rm.uuid=tr.message_uuid WHERE ${errorCond} AND ${where} ORDER BY rm.timestamp DESC LIMIT ?`).all(...allParams); - return rows.map(r => { + return rows.map((r: DbRow) => { const tc = db.prepare('SELECT * FROM tool_calls WHERE id=?').get(r.tool_use_id); const session = db.prepare('SELECT * FROM sessions WHERE id=?').get(r.session_id); const rm = db.prepare('SELECT * FROM messages WHERE uuid=?').get(r.message_uuid); @@ -214,7 +256,7 @@ function createQueryApi(db) { }); }; - const sessions = (optsOrN) => { + const sessions = (optsOrN?: QueryOptions | number | string) => { const opts = normalizeOpts(optsOrN, 'sessionId'); const { limit = 50 } = opts; const { where, params } = buildWhere(opts, { sessionId: 's.id', project: 's.project', timestamp: 's.started_at', branch: 's.git_branch', source: 's.source' }); @@ -224,7 +266,7 @@ function createQueryApi(db) { const recent = (n = 10) => sessions({ limit: n }); - const summaries = (optsOrSid) => { + const summaries = (optsOrSid?: QueryOptions | string) => { const opts = normalizeOpts(optsOrSid); const { limit = 100 } = opts; const { where, params } = buildWhere(opts, { sessionId: 'su.session_id', project: 's.project', timestamp: 'su.timestamp', branch: 's.git_branch', source: 's.source' }); @@ -232,21 +274,21 @@ function createQueryApi(db) { return db.prepare(`SELECT su.*, s.title as session_title, s.project FROM summaries su LEFT JOIN sessions s ON s.id=su.session_id WHERE ${where} ORDER BY su.timestamp DESC LIMIT ?`).all(...params); }; - const overview = (optsOrScalar) => { + const overview = (optsOrScalar?: QueryOptions | string | number) => { const opts = normalizeOverviewOpts(optsOrScalar); const cwd = process.cwd(); const sessionLimit = opts.limit ?? 8; const projectLimit = opts.projectLimit ?? 20; const memoryLimit = opts.memoryLimit ?? 100; - const projectDescriptor = (row, source, confidence) => row ? ({ + const projectDescriptor = (row: DbRow | null, source: string, confidence: string) => row ? ({ project: row.project, project_path: row.project_path || null, source, confidence, }) : null; - const latestProjectByPattern = (pattern) => { + const latestProjectByPattern = (pattern: string): DbRow | undefined => { const fromSessions = db.prepare(` SELECT project, project_path FROM sessions @@ -278,8 +320,8 @@ function createQueryApi(db) { GROUP BY project, project_path `).all(); const byProjectPath = paths - .filter(r => cwd === r.project_path || cwd.startsWith(r.project_path + path.sep)) - .sort((a, b) => b.project_path.length - a.project_path.length || String(b.last_seen || '').localeCompare(String(a.last_seen || '')))[0]; + .filter((r: DbRow) => cwd === r.project_path || cwd.startsWith(r.project_path + path.sep)) + .sort((a: DbRow, b: DbRow) => b.project_path.length - a.project_path.length || String(b.last_seen || '').localeCompare(String(a.last_seen || '')))[0]; if (byProjectPath) return projectDescriptor(byProjectPath, 'cwd_project_path', 'exact'); const byMessageCwd = db.prepare(` @@ -332,7 +374,7 @@ function createQueryApi(db) { LEFT JOIN memory_stats ms ON ms.project = n.project ORDER BY COALESCE(ss.last_session_at, ms.last_memory_at) DESC LIMIT ? - `).all(projectLimit).map(row => { + `).all(projectLimit).map((row: DbRow) => { const branches = db.prepare(` SELECT git_branch FROM sessions @@ -340,7 +382,7 @@ function createQueryApi(db) { GROUP BY git_branch ORDER BY MAX(COALESCE(ended_at, started_at)) DESC LIMIT 5 - `).all(row.project).map(r => r.git_branch); + `).all(row.project).map((r: DbRow) => r.git_branch); return { ...row, recent_branches: branches }; }); @@ -408,7 +450,7 @@ function createQueryApi(db) { }; }; - const resolveJsonlPath = (messageUuid) => { + const resolveJsonlPath = (messageUuid: string): string | null => { const msg = db.prepare('SELECT session_id, agent_id, source FROM messages WHERE uuid=?').get(messageUuid); if (!msg) return null; if (msg.source === 'codex' || String(messageUuid).startsWith('codex:')) { @@ -444,7 +486,7 @@ function createQueryApi(db) { return null; }; - const findCodexRawLine = (jsonlPath, uuid) => { + const findCodexRawLine = (jsonlPath: string | null, uuid: string): string | null => { const match = /^codex:[^:]+:(\d+)$/.exec(String(uuid)); if (!match || !jsonlPath || !fs.existsSync(jsonlPath)) return null; const targetLine = Number(match[1]); @@ -459,18 +501,18 @@ function createQueryApi(db) { return found; }; - const findRawLine = (jsonlPath, uuid) => { + const findRawLine = (jsonlPath: string | null, uuid: string): string | null => { if (!jsonlPath || !fs.existsSync(jsonlPath)) return null; if (String(uuid).startsWith('codex:')) return findCodexRawLine(jsonlPath, uuid); let found = null; readLines(jsonlPath, (line) => { if (!line.includes(uuid)) return; - try { const obj = JSON.parse(line); if (obj.uuid === uuid) { found = line; return false; } } catch {} + try { const obj = JSON.parse(line); if (obj.uuid === uuid) { found = line; return false; } } catch { /* skip malformed JSONL lines */ } }); return found; }; - const raw = (messageUuid, opts = {}) => { + const raw = (messageUuid: string, opts: { offset?: number; limit?: number } = {}) => { const { offset = 0, limit = 10000 } = opts; const jsonlPath = resolveJsonlPath(messageUuid); const line = findRawLine(jsonlPath, messageUuid); @@ -484,7 +526,7 @@ function createQueryApi(db) { }; }; - const memories = (optsOrSid) => { + const memories = (optsOrSid?: QueryOptions | string) => { const opts = normalizeOpts(optsOrSid); const { limit = 50, query } = opts; assertEnglishMemoryText(query, 'memories() query'); @@ -496,7 +538,7 @@ function createQueryApi(db) { branch: 's.git_branch', source: 's.source', }); - let where = baseWhere + ' AND mem.deleted_at IS NULL'; + const where = baseWhere + ' AND mem.deleted_at IS NULL'; const join = needsJoin ? 'LEFT JOIN sessions s ON s.id=mem.session_id' : ''; const hasQuery = String(query || '').trim().length > 0; const ftsQuery = buildSafeFtsQuery(query); @@ -521,8 +563,8 @@ function createQueryApi(db) { return { sql: q, search, context, trace, thread, subagents, workflows, workflowTree, fileHistory, failures, sessions, recent, summaries, raw, memories, overview }; } -function createAttuneApi(db) { - const resolveMemoryPath = (memoryPath, sessionId) => { +function createAttuneApi(db: SqliteDb) { + const resolveMemoryPath = (memoryPath: string, sessionId?: string): string => { let base = null; if (sessionId) { base = db.prepare('SELECT project_path FROM sessions WHERE id=?').get(sessionId)?.project_path || null; @@ -540,7 +582,7 @@ function createAttuneApi(db) { return resolved; }; - const normalizeAnchors = (anchors) => { + const normalizeAnchors = (anchors: unknown): string | null => { if (anchors == null) return null; let parsed = anchors; if (typeof anchors === 'string') { @@ -561,7 +603,7 @@ function createAttuneApi(db) { return parsed.length ? JSON.stringify(parsed) : null; }; - const remember = ({ path: memoryPath, session_id, message_start, message_end, summary, project, anchors }) => { + const remember = ({ path: memoryPath, session_id, message_start, message_end, summary, project, anchors }: RememberInput) => { if (!memoryPath || !summary) throw new Error('remember() requires path and summary'); assertEnglishMemoryText(summary, 'remember() summary'); const normalizedPath = resolveMemoryPath(memoryPath, session_id); @@ -574,7 +616,7 @@ function createAttuneApi(db) { return { id, path: normalizedPath, project: proj, anchors: normalizedAnchors, created_at }; }; - const forget = ({ id, reason }) => { + const forget = ({ id, reason }: ForgetInput) => { const deletionReason = String(reason || '').trim(); if (!id || !deletionReason) throw new Error('forget() requires id and reason'); const row = db.prepare('SELECT id, deleted_at, deleted_reason FROM memories WHERE id=?').get(id); diff --git a/packages/core/src/runtime.mjs b/packages/core/src/runtime.ts similarity index 71% rename from packages/core/src/runtime.mjs rename to packages/core/src/runtime.ts index 7494fc8..b9c7af5 100644 --- a/packages/core/src/runtime.mjs +++ b/packages/core/src/runtime.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -// Skill transport: a thin CLI shell over the Obelisk Core package. +// Skill transport: a typed thin CLI shell over the Obelisk Core package. // It only parses args, reads script files, prints JSON, and owns the uniform // { error, stack } + exit-1 error envelope. All logic lives in Core. @@ -14,11 +14,14 @@ async function main() { const args = process.argv.slice(2); // Uniform error envelope across all four verbs: a failure is reported as // { error, stack } on stdout with exit code 1, never a raw crash on stderr. - const fail = (e) => { - process.stdout.write(JSON.stringify({ error: e.message, stack: e.stack }) + '\n'); + const fail = (e: unknown): void => { + const error = e instanceof Error ? e : new Error(String(e)); + process.stdout.write(JSON.stringify({ error: error.message, stack: error.stack }) + '\n'); process.exitCode = 1; }; - const emit = (r) => process.stdout.write(JSON.stringify(r, null, 2) + '\n'); + const emit = (r: unknown): void => { + process.stdout.write(JSON.stringify(r, null, 2) + '\n'); + }; if (args[0] === '--build') { try { @@ -39,7 +42,7 @@ async function main() { try { emit(await executeAttune(fs.readFileSync(path.resolve(args[1]), 'utf8'))); } catch (e) { fail(e); } return; } - process.stderr.write('Usage:\n node runtime.mjs --build\n node runtime.mjs --search "text"\n node runtime.mjs --query \n node runtime.mjs --attune \n'); + process.stderr.write('Usage:\n node runtime.js --build\n node runtime.js --search "text"\n node runtime.js --query \n node runtime.js --attune \n'); process.exitCode = 1; } diff --git a/packages/core/tsconfig.build.json b/packages/core/tsconfig.build.json index 3dc3216..ca53dab 100644 --- a/packages/core/tsconfig.build.json +++ b/packages/core/tsconfig.build.json @@ -7,5 +7,5 @@ "declaration": true, "rewriteRelativeImportExtensions": true }, - "include": ["src/**/*.ts", "src/**/*.mjs"] + "include": ["src/**/*.ts"] } diff --git a/references/api-reference.md b/references/api-reference.md index a3ce6db..dc3def0 100644 --- a/references/api-reference.md +++ b/references/api-reference.md @@ -1,7 +1,7 @@ # Obelisk -- Helper API Reference -Detailed reference for globals available inside `runtime.mjs --query` and -`runtime.mjs --attune` scripts. +Detailed reference for globals available inside `runtime.js --query` and +`runtime.js --attune` scripts. - Use `references/schema.md` for raw SQL table/field/join checks. - Use `references/query-patterns.md` for copyable retrieval plans. @@ -16,7 +16,7 @@ memory mutation helpers. ### Read Helpers -These globals are available only in `runtime.mjs --query` scripts: +These globals are available only in `runtime.js --query` scripts: ```js sql, search, context, trace, thread, raw, @@ -31,7 +31,7 @@ helpers is treated as `sessionId`; passing a number is treated as `limit`. ### Mutation Helpers -These globals are available only in `runtime.mjs --attune` scripts: +These globals are available only in `runtime.js --attune` scripts: ```js remember, forget @@ -413,7 +413,7 @@ not a counting primitive. #### `remember(record)` Register a human-approved markdown memory file. Available only in -`runtime.mjs --attune` scripts. +`runtime.js --attune` scripts. | Param | Type | Description | | --- | --- | --- | @@ -439,7 +439,7 @@ Returns: #### `forget(record)` Archive a human-approved memory record. Available only in -`runtime.mjs --attune` scripts. +`runtime.js --attune` scripts. | Param | Type | Description | | --- | --- | --- | diff --git a/references/query-patterns.md b/references/query-patterns.md index a4b462b..cece720 100644 --- a/references/query-patterns.md +++ b/references/query-patterns.md @@ -1,6 +1,6 @@ # Obelisk Query Patterns -These are copyable CodeAct patterns for `runtime.mjs --query` scripts plus +These are copyable CodeAct patterns for `runtime.js --query` scripts plus `--attune` memory mutation patterns. They are not new APIs. Adapt them to the user's scope and return compact evidence. @@ -179,7 +179,7 @@ Use this only after the user approves writing memory and the markdown file already exists. `remember()` validates the file and stores a normalized absolute path, so keep the script small and return the registered record. -Run this script with `runtime.mjs --attune