refactor(core): finish TypeScript workspace migration
This commit is contained in:
@@ -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);
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user