refactor(core): finish TypeScript workspace migration

This commit is contained in:
tommy0103
2026-07-12 00:37:32 +08:00
parent 83d5703f7b
commit 535d8edb4d
31 changed files with 326 additions and 199 deletions
+6 -7
View File
@@ -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 };
@@ -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')");
}
@@ -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<string, any>;
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<IndexRecord, Cursor> {
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
@@ -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<string, any>;
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<string>();
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>[^<]+<\/command-name>|<(?:task-notification|system-reminder)\b|<local-command(?:\b|-))/;
function extractMessageIsMeta(record, text = extractText(record?.message?.content)) {
function extractMessageIsMeta(record: JsonRecord, text: string | null = extractText(record?.message?.content)): 0 | 1 {
const msg = record?.message || {};
if (record?.isMeta === true || msg.isMeta === true) return 1;
return typeof text === 'string' && COMMAND_ENVELOPE_RE.test(text) ? 1 : 0;
}
function filePath(name, input) {
function filePath(name: string, input: JsonRecord | null | undefined): string | null {
if (!input) return null;
return ['Read', 'Edit', 'Write', 'NotebookEdit'].includes(name) ? (input.file_path || null) : null;
}
function isDir(p) { try { return fs.statSync(p).isDirectory(); } catch { return false; } }
function isDir(p: string): boolean { try { return fs.statSync(p).isDirectory(); } catch { return false; } }
function readLines(filePath, callback) {
function readLines(filePath: string, callback: (line: string) => 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<string, { path: string; count: number; first: number }>();
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);
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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';
@@ -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<string, any>;
interface QueryOptions extends Record<string, any> {
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);
@@ -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 <file.js>\n node runtime.mjs --attune <file.js>\n');
process.stderr.write('Usage:\n node runtime.js --build\n node runtime.js --search "text"\n node runtime.js --query <file.js>\n node runtime.js --attune <file.js>\n');
process.exitCode = 1;
}
+1 -1
View File
@@ -7,5 +7,5 @@
"declaration": true,
"rewriteRelativeImportExtensions": true
},
"include": ["src/**/*.ts", "src/**/*.mjs"]
"include": ["src/**/*.ts"]
}