2026-07-14 18:11:51 +08:00
|
|
|
import { app, BrowserWindow, ipcMain, clipboard, dialog, shell, type IpcMainInvokeEvent } from 'electron';
|
2026-07-09 11:02:20 +08:00
|
|
|
import path from 'node:path';
|
|
|
|
|
import os from 'node:os';
|
|
|
|
|
import fs from 'node:fs';
|
|
|
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
|
import Database from 'better-sqlite3';
|
|
|
|
|
import chokidar from 'chokidar';
|
2026-07-09 16:25:59 +08:00
|
|
|
import { writeHeartbeat } from './indexer.ts';
|
|
|
|
|
import { createIndexerService } from './indexer-service.ts';
|
|
|
|
|
import { createWorkerBuildIndex } from './indexer-worker-client.ts';
|
|
|
|
|
import { buildRecapExportQuery } from './recap-capture-query.ts';
|
2026-07-12 00:28:17 +08:00
|
|
|
import { acquireWriterLease, writerLockPathFor } from '../../../packages/core/src/writer-lease.ts';
|
2026-07-14 18:11:51 +08:00
|
|
|
import type {
|
|
|
|
|
SessionPatchCursor,
|
|
|
|
|
SessionPatchSnapshot,
|
|
|
|
|
SourceQueryOptions,
|
|
|
|
|
} from '../shared/ipc-types.ts';
|
|
|
|
|
import type {
|
|
|
|
|
SessionDetailAssemblyInput,
|
|
|
|
|
SessionMessageRow,
|
|
|
|
|
SessionSubagentRow,
|
|
|
|
|
SessionSummaryRow,
|
|
|
|
|
SessionToolCallRow,
|
|
|
|
|
SessionToolResultRow,
|
|
|
|
|
SessionWorkflowRow,
|
|
|
|
|
} from '../shared/session-detail-types.ts';
|
|
|
|
|
import { createSessionPatch } from '../shared/session-patch.mjs';
|
|
|
|
|
import { assembleSessionMessages } from '../shared/session-detail-assembly.mjs';
|
2026-07-09 11:02:20 +08:00
|
|
|
|
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
2026-06-12 22:20:29 +08:00
|
|
|
|
2026-06-15 01:44:32 +08:00
|
|
|
function detectClaudeDir() {
|
|
|
|
|
// macOS / Linux: ~/.claude
|
|
|
|
|
if (process.platform !== 'win32') {
|
|
|
|
|
return path.join(os.homedir(), '.claude');
|
|
|
|
|
}
|
|
|
|
|
// Windows: Claude Code runs in WSL, data lives at \\wsl.localhost\<distro>\home\<user>\.claude
|
|
|
|
|
const distros = ['Ubuntu', 'Ubuntu-24.04', 'Ubuntu-22.04', 'Debian', 'openSUSE-Leap', 'kali-linux'];
|
|
|
|
|
for (const distro of distros) {
|
|
|
|
|
const homePath = path.join('\\\\wsl.localhost', distro, 'home');
|
|
|
|
|
if (!fs.existsSync(homePath)) continue;
|
|
|
|
|
try {
|
|
|
|
|
const users = fs.readdirSync(homePath);
|
|
|
|
|
for (const user of users) {
|
|
|
|
|
const claudeDir = path.join(homePath, user, '.claude');
|
|
|
|
|
if (fs.existsSync(claudeDir)) return claudeDir;
|
|
|
|
|
}
|
|
|
|
|
} catch {}
|
|
|
|
|
}
|
|
|
|
|
// Fallback: native Windows path (for future native Claude Code on Windows)
|
|
|
|
|
return path.join(os.homedir(), '.claude');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const DEFAULT_CLAUDE_DIR = detectClaudeDir();
|
2026-06-17 23:40:36 +08:00
|
|
|
const DEFAULT_CODEX_DIR = path.join(os.homedir(), '.codex');
|
2026-06-12 22:20:29 +08:00
|
|
|
|
|
|
|
|
let db;
|
2026-06-13 03:42:01 +08:00
|
|
|
let indexerService;
|
|
|
|
|
let indexerWorker;
|
2026-06-12 22:20:29 +08:00
|
|
|
|
2026-07-10 18:10:45 +08:00
|
|
|
type WriterLeaseMode = 'acquire' | 'caller-held';
|
|
|
|
|
|
|
|
|
|
function acquireAppWriterLease(dbPath: string, waitMs = 0) {
|
|
|
|
|
return acquireWriterLease({
|
|
|
|
|
lockPath: writerLockPathFor(dbPath),
|
|
|
|
|
openDb: lockPath => new Database(lockPath),
|
|
|
|
|
waitMs,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 01:44:32 +08:00
|
|
|
function getConfiguredClaudeDir() {
|
|
|
|
|
const persisted = loadPersistedSettings();
|
|
|
|
|
return persisted.claudeDir || DEFAULT_CLAUDE_DIR;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 23:40:36 +08:00
|
|
|
function getConfiguredCodexDir() {
|
|
|
|
|
const persisted = loadPersistedSettings();
|
|
|
|
|
return persisted.codexDir || DEFAULT_CODEX_DIR;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getPathsForClaudeDir(claudeDir = getConfiguredClaudeDir(), codexDir = getConfiguredCodexDir()) {
|
2026-06-15 01:44:32 +08:00
|
|
|
return {
|
|
|
|
|
claudeDir,
|
2026-06-17 23:40:36 +08:00
|
|
|
codexDir,
|
|
|
|
|
dbPath: path.join(OBELISK_DIR, 'obelisk.sqlite'),
|
2026-06-15 01:44:32 +08:00
|
|
|
projectsDir: path.join(claudeDir, 'projects'),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-10 18:10:45 +08:00
|
|
|
function migrateLegacyDbIfNeeded(
|
|
|
|
|
paths = getPathsForClaudeDir(),
|
|
|
|
|
{ writerLeaseMode = 'acquire' }: { writerLeaseMode?: WriterLeaseMode } = {},
|
|
|
|
|
) {
|
2026-06-17 23:40:36 +08:00
|
|
|
if (fs.existsSync(paths.dbPath)) return;
|
|
|
|
|
const legacyDbPath = path.join(paths.claudeDir, 'obelisk.sqlite');
|
|
|
|
|
if (!fs.existsSync(legacyDbPath)) return;
|
2026-07-10 18:10:45 +08:00
|
|
|
const lease = writerLeaseMode === 'acquire' ? acquireAppWriterLease(paths.dbPath) : null;
|
|
|
|
|
if (writerLeaseMode === 'acquire' && !lease) return false;
|
2026-06-17 23:40:36 +08:00
|
|
|
try {
|
2026-07-10 18:10:45 +08:00
|
|
|
if (fs.existsSync(paths.dbPath)) return true;
|
2026-06-17 23:40:36 +08:00
|
|
|
fs.mkdirSync(path.dirname(paths.dbPath), { recursive: true });
|
|
|
|
|
fs.copyFileSync(legacyDbPath, paths.dbPath);
|
2026-07-10 18:10:45 +08:00
|
|
|
return true;
|
2026-06-17 23:40:36 +08:00
|
|
|
} catch (error) {
|
2026-07-09 16:25:59 +08:00
|
|
|
console.warn?.(`Obelisk legacy DB migration skipped: ${(error as Error).message}`);
|
2026-07-10 18:10:45 +08:00
|
|
|
return false;
|
|
|
|
|
} finally {
|
|
|
|
|
lease?.release();
|
2026-06-17 23:40:36 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function rebuildTempDbPath(dbPath) {
|
|
|
|
|
return path.join(
|
|
|
|
|
path.dirname(dbPath),
|
|
|
|
|
`${path.basename(dbPath)}.rebuild-${process.pid}-${Date.now()}.tmp`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function dbFileSet(dbPath) {
|
|
|
|
|
return [dbPath, `${dbPath}-wal`, `${dbPath}-shm`];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cleanupDbFiles(dbPath) {
|
|
|
|
|
for (const filePath of dbFileSet(dbPath)) {
|
|
|
|
|
try {
|
|
|
|
|
fs.rmSync(filePath, { force: true });
|
|
|
|
|
} catch {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function replaceDbWithTemp(tempDbPath, dbPath) {
|
|
|
|
|
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
|
|
|
for (const sidecar of [`${dbPath}-wal`, `${dbPath}-shm`]) {
|
|
|
|
|
try {
|
|
|
|
|
fs.rmSync(sidecar, { force: true });
|
|
|
|
|
} catch {}
|
|
|
|
|
}
|
|
|
|
|
fs.renameSync(tempDbPath, dbPath);
|
|
|
|
|
for (const suffix of ['-wal', '-shm']) {
|
|
|
|
|
const tempSidecar = `${tempDbPath}${suffix}`;
|
|
|
|
|
if (!fs.existsSync(tempSidecar)) continue;
|
|
|
|
|
fs.renameSync(tempSidecar, `${dbPath}${suffix}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolveSchemaPath() {
|
|
|
|
|
const candidates = [
|
|
|
|
|
path.join(__dirname, 'schema.sql'),
|
2026-07-12 00:37:32 +08:00
|
|
|
path.join(__dirname, '..', '..', '..', 'packages', 'core', 'src', 'schema.sql'),
|
2026-06-17 23:40:36 +08:00
|
|
|
path.join(__dirname, '..', 'scripts', 'schema.sql'),
|
|
|
|
|
process.resourcesPath ? path.join(process.resourcesPath, 'scripts', 'schema.sql') : null,
|
2026-07-09 16:25:59 +08:00
|
|
|
].filter((c): c is string => Boolean(c));
|
2026-06-17 23:40:36 +08:00
|
|
|
return candidates.find(p => fs.existsSync(p));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ensureColumn(db, table, column, definition) {
|
|
|
|
|
const columns = db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name);
|
|
|
|
|
if (!columns.includes(column)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function tableExists(db, table) {
|
|
|
|
|
return Boolean(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(table));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function migrateExistingColumns(db) {
|
|
|
|
|
if (tableExists(db, 'sessions')) ensureColumn(db, 'sessions', 'source', "TEXT DEFAULT 'claude'");
|
|
|
|
|
if (tableExists(db, 'messages')) {
|
|
|
|
|
ensureColumn(db, 'messages', 'content_type', 'TEXT');
|
|
|
|
|
ensureColumn(db, 'messages', 'is_meta', 'INTEGER DEFAULT 0');
|
|
|
|
|
ensureColumn(db, 'messages', 'source', "TEXT DEFAULT 'claude'");
|
|
|
|
|
}
|
|
|
|
|
if (tableExists(db, 'memories')) {
|
|
|
|
|
ensureColumn(db, 'memories', 'anchors', 'TEXT');
|
|
|
|
|
ensureColumn(db, 'memories', 'deleted_at', 'TEXT');
|
|
|
|
|
ensureColumn(db, 'memories', 'deleted_reason', 'TEXT');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function migrateDb(db) {
|
|
|
|
|
if (typeof db.exec !== 'function' || typeof db.prepare !== 'function') return;
|
|
|
|
|
migrateExistingColumns(db);
|
|
|
|
|
const schemaPath = resolveSchemaPath();
|
|
|
|
|
if (schemaPath) db.exec(fs.readFileSync(schemaPath, 'utf8'));
|
|
|
|
|
migrateExistingColumns(db);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 01:44:32 +08:00
|
|
|
function closeDb() {
|
2026-06-13 03:42:01 +08:00
|
|
|
if (db) db.close();
|
2026-06-15 01:44:32 +08:00
|
|
|
db = null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-10 18:10:45 +08:00
|
|
|
function openDb(
|
|
|
|
|
dbPath = getPathsForClaudeDir().dbPath,
|
|
|
|
|
{ writerLeaseMode = 'acquire' }: { writerLeaseMode?: WriterLeaseMode } = {},
|
|
|
|
|
) {
|
2026-06-15 01:44:32 +08:00
|
|
|
closeDb();
|
|
|
|
|
if (!fs.existsSync(dbPath)) return null;
|
|
|
|
|
db = new Database(dbPath, { readonly: false });
|
2026-07-10 18:10:45 +08:00
|
|
|
db.pragma('busy_timeout = 5000');
|
|
|
|
|
const lease = writerLeaseMode === 'acquire' ? acquireAppWriterLease(dbPath) : null;
|
|
|
|
|
if (writerLeaseMode === 'caller-held' || lease) {
|
|
|
|
|
try {
|
|
|
|
|
db.pragma('journal_mode = WAL');
|
|
|
|
|
migrateDb(db);
|
|
|
|
|
} finally {
|
|
|
|
|
lease?.release();
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-12 22:20:29 +08:00
|
|
|
return db;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-10 18:10:45 +08:00
|
|
|
function runAppDbWrite(work: () => void): boolean {
|
|
|
|
|
if (!db) return false;
|
|
|
|
|
const lease = acquireAppWriterLease(getPathsForClaudeDir().dbPath, 250);
|
|
|
|
|
if (!lease) {
|
|
|
|
|
throw new Error('Obelisk index writer is busy; memory change was not applied');
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
work();
|
|
|
|
|
return true;
|
|
|
|
|
} finally {
|
|
|
|
|
lease.release();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-09 16:25:59 +08:00
|
|
|
function notifyIndexUpdated(result: { affectedSessionIds?: unknown } = {}) {
|
2026-06-15 01:44:32 +08:00
|
|
|
const affectedSessionIds = Array.isArray(result.affectedSessionIds)
|
|
|
|
|
? [...new Set(result.affectedSessionIds.filter(Boolean))]
|
|
|
|
|
: [];
|
|
|
|
|
const payload = { affectedSessionIds };
|
2026-06-13 03:42:01 +08:00
|
|
|
for (const win of BrowserWindow.getAllWindows()) {
|
2026-06-15 01:44:32 +08:00
|
|
|
win.webContents.send('obelisk:index-updated', payload);
|
|
|
|
|
for (const sessionId of affectedSessionIds) {
|
|
|
|
|
win.webContents.send('obelisk:session-updated', { sessionId });
|
|
|
|
|
}
|
2026-06-13 03:42:01 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 15:15:48 +08:00
|
|
|
function sourceWhereClause(opts: SourceQueryOptions = {}, column = "source"): { sql: string; params: unknown[] } {
|
|
|
|
|
if (opts.source === 'all') return { sql: '', params: [] };
|
2026-06-17 23:40:36 +08:00
|
|
|
if (opts.source) return { sql: `COALESCE(${column}, 'claude') = ?`, params: [opts.source] };
|
|
|
|
|
return { sql: `COALESCE(${column}, 'claude') = 'claude'`, params: [] };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function appendWhere(sql, params, clause) {
|
|
|
|
|
if (!clause) return sql;
|
|
|
|
|
return `${sql}${sql.includes(' WHERE ') ? ' AND ' : ' WHERE '}${clause}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 01:44:32 +08:00
|
|
|
function startIndexerService({ buildOnStart = false } = {}) {
|
|
|
|
|
const paths = getPathsForClaudeDir();
|
2026-06-17 23:40:36 +08:00
|
|
|
migrateLegacyDbIfNeeded(paths);
|
|
|
|
|
const codexSessionsDir = path.join(paths.codexDir, 'sessions');
|
2026-06-13 03:42:01 +08:00
|
|
|
indexerService = createIndexerService({
|
2026-06-15 01:44:32 +08:00
|
|
|
projectsDir: paths.projectsDir,
|
2026-06-17 23:40:36 +08:00
|
|
|
watchDirs: [paths.projectsDir, codexSessionsDir],
|
2026-06-15 01:44:32 +08:00
|
|
|
buildIndex: async ({ reason, changedPaths }) => {
|
|
|
|
|
const result = await indexerWorker.buildIndex({
|
|
|
|
|
reason,
|
|
|
|
|
changedPaths,
|
|
|
|
|
claudeDir: paths.claudeDir,
|
2026-06-17 23:40:36 +08:00
|
|
|
codexDir: paths.codexDir,
|
2026-06-15 01:44:32 +08:00
|
|
|
projectsDir: paths.projectsDir,
|
|
|
|
|
dbPath: paths.dbPath,
|
|
|
|
|
});
|
2026-07-10 18:10:45 +08:00
|
|
|
if (result?.deferred) {
|
|
|
|
|
if (Array.isArray(result.affectedSessionIds) && result.affectedSessionIds.length) {
|
|
|
|
|
notifyIndexUpdated(result);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
openDb(paths.dbPath);
|
|
|
|
|
notifyIndexUpdated(result);
|
|
|
|
|
}
|
2026-06-13 03:42:01 +08:00
|
|
|
return result;
|
|
|
|
|
},
|
2026-06-15 01:44:32 +08:00
|
|
|
writeHeartbeat: () => writeHeartbeat({ dbPath: paths.dbPath }),
|
2026-06-13 03:42:01 +08:00
|
|
|
});
|
2026-06-15 01:44:32 +08:00
|
|
|
indexerService.start({ buildOnStart });
|
2026-06-13 03:42:01 +08:00
|
|
|
return indexerService;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 01:44:32 +08:00
|
|
|
function startBackgroundResources({ runStartupBuild = false } = {}) {
|
|
|
|
|
if (!indexerWorker) indexerWorker = createWorkerBuildIndex();
|
2026-06-17 23:40:36 +08:00
|
|
|
const paths = getPathsForClaudeDir();
|
|
|
|
|
migrateLegacyDbIfNeeded(paths);
|
|
|
|
|
openDb(paths.dbPath);
|
2026-06-15 01:44:32 +08:00
|
|
|
if (!indexerService) {
|
|
|
|
|
const service = startIndexerService({ buildOnStart: false });
|
|
|
|
|
if (runStartupBuild) service.runBuildNow('startup');
|
|
|
|
|
}
|
|
|
|
|
if (!obeliskWatcher) startObeliskWatcher();
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 23:40:36 +08:00
|
|
|
async function stopIndexerServiceAndWait({ waitForIdle = true } = {}) {
|
2026-06-15 01:44:32 +08:00
|
|
|
const service = indexerService;
|
|
|
|
|
if (!service) return;
|
|
|
|
|
service.stop();
|
2026-06-17 23:40:36 +08:00
|
|
|
if (waitForIdle && typeof service.idle === 'function') await service.idle();
|
2026-06-15 01:44:32 +08:00
|
|
|
if (indexerService === service) indexerService = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function stopBackgroundResources({ stopWorker = false } = {}) {
|
|
|
|
|
await stopIndexerServiceAndWait();
|
|
|
|
|
if (stopWorker && indexerWorker) {
|
|
|
|
|
indexerWorker.stop();
|
|
|
|
|
indexerWorker = null;
|
|
|
|
|
}
|
|
|
|
|
if (obeliskWatcher) {
|
|
|
|
|
const watcher = obeliskWatcher;
|
|
|
|
|
obeliskWatcher = null;
|
|
|
|
|
if (typeof watcher.close === 'function') await Promise.resolve(watcher.close());
|
|
|
|
|
}
|
|
|
|
|
closeDb();
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-12 22:20:29 +08:00
|
|
|
function createWindow() {
|
2026-07-09 10:20:18 +08:00
|
|
|
const isDev = process.argv.includes('--dev') || !!process.env.ELECTRON_RENDERER_URL;
|
2026-06-15 01:44:32 +08:00
|
|
|
const shouldOpenDevTools = process.argv.includes('--devtools');
|
|
|
|
|
|
2026-06-12 22:20:29 +08:00
|
|
|
const win = new BrowserWindow({
|
|
|
|
|
width: 1200,
|
|
|
|
|
height: 800,
|
|
|
|
|
minWidth: 800,
|
|
|
|
|
minHeight: 500,
|
|
|
|
|
titleBarStyle: 'hiddenInset',
|
|
|
|
|
trafficLightPosition: { x: 14, y: 10 },
|
|
|
|
|
backgroundColor: '#0a0b14',
|
|
|
|
|
webPreferences: {
|
2026-07-09 10:20:18 +08:00
|
|
|
preload: path.join(__dirname, '..', 'preload', 'index.js'),
|
2026-06-12 22:20:29 +08:00
|
|
|
contextIsolation: true,
|
|
|
|
|
nodeIntegration: false,
|
2026-06-15 01:44:32 +08:00
|
|
|
devTools: isDev || shouldOpenDevTools,
|
2026-06-12 22:20:29 +08:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-14 03:16:49 +08:00
|
|
|
// Prevent Electron's built-in zoom so Cmd+=/- reaches the renderer
|
|
|
|
|
win.webContents.on('before-input-event', (event, input) => {
|
|
|
|
|
if ((input.meta || input.control) && ['+', '=', '-', '0'].includes(input.key)) {
|
|
|
|
|
win.webContents.setZoomLevel(0);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-12 22:20:29 +08:00
|
|
|
if (isDev) {
|
2026-07-09 10:20:18 +08:00
|
|
|
win.loadURL(process.env.ELECTRON_RENDERER_URL || process.env.OBELISK_DEV_SERVER_URL || 'http://localhost:5173');
|
2026-06-15 01:44:32 +08:00
|
|
|
if (shouldOpenDevTools) {
|
|
|
|
|
win.webContents.openDevTools();
|
|
|
|
|
}
|
2026-06-12 22:20:29 +08:00
|
|
|
} else {
|
2026-07-09 10:20:18 +08:00
|
|
|
win.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'));
|
2026-06-12 22:20:29 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-14 03:16:49 +08:00
|
|
|
const OBELISK_DIR = path.join(os.homedir(), '.obelisk');
|
|
|
|
|
const RECAP_DIR = path.join(OBELISK_DIR, 'recap');
|
2026-07-09 16:25:59 +08:00
|
|
|
let obeliskWatcher: import("chokidar").FSWatcher | null = null;
|
2026-06-14 03:16:49 +08:00
|
|
|
|
|
|
|
|
function startObeliskWatcher() {
|
2026-06-15 01:44:32 +08:00
|
|
|
if (obeliskWatcher) return obeliskWatcher;
|
2026-06-14 03:16:49 +08:00
|
|
|
if (!fs.existsSync(OBELISK_DIR)) {
|
|
|
|
|
fs.mkdirSync(OBELISK_DIR, { recursive: true });
|
|
|
|
|
}
|
|
|
|
|
obeliskWatcher = chokidar.watch(OBELISK_DIR, {
|
|
|
|
|
ignoreInitial: true,
|
|
|
|
|
awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100 },
|
|
|
|
|
ignored: (p, stats) => {
|
|
|
|
|
if (stats?.isDirectory()) return false;
|
|
|
|
|
if (!stats) return false;
|
|
|
|
|
return !p.endsWith('.md') && !p.endsWith('.json');
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
obeliskWatcher.on('add', onObeliskChange);
|
|
|
|
|
obeliskWatcher.on('change', onObeliskChange);
|
|
|
|
|
obeliskWatcher.on('unlink', onObeliskChange);
|
2026-06-15 01:44:32 +08:00
|
|
|
return obeliskWatcher;
|
2026-06-14 03:16:49 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onObeliskChange(filePath) {
|
|
|
|
|
if (filePath.startsWith(RECAP_DIR)) {
|
|
|
|
|
for (const win of BrowserWindow.getAllWindows()) {
|
|
|
|
|
win.webContents.send('obelisk:recap-updated', filePath);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-12 22:20:29 +08:00
|
|
|
app.whenReady().then(() => {
|
2026-06-15 01:44:32 +08:00
|
|
|
startBackgroundResources({ runStartupBuild: true });
|
2026-06-12 22:20:29 +08:00
|
|
|
createWindow();
|
|
|
|
|
|
|
|
|
|
app.on('activate', () => {
|
2026-06-15 01:44:32 +08:00
|
|
|
if (BrowserWindow.getAllWindows().length === 0) {
|
|
|
|
|
startBackgroundResources({ runStartupBuild: true });
|
|
|
|
|
createWindow();
|
|
|
|
|
}
|
2026-06-12 22:20:29 +08:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-13 03:42:01 +08:00
|
|
|
app.on('before-quit', () => {
|
2026-06-15 01:44:32 +08:00
|
|
|
void stopBackgroundResources({ stopWorker: true });
|
2026-06-13 03:42:01 +08:00
|
|
|
});
|
|
|
|
|
|
2026-06-12 22:20:29 +08:00
|
|
|
app.on('window-all-closed', () => {
|
2026-06-15 01:44:32 +08:00
|
|
|
void stopBackgroundResources({ stopWorker: true });
|
2026-06-12 22:20:29 +08:00
|
|
|
if (process.platform !== 'darwin') app.quit();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// --- IPC Handlers ---
|
|
|
|
|
|
2026-07-14 18:11:51 +08:00
|
|
|
function querySessionMessages(sessionId: string): SessionMessageRow[] {
|
|
|
|
|
if (!db) return [];
|
|
|
|
|
return db.prepare(`
|
|
|
|
|
SELECT m.uuid, m.session_id, m.type, m.parent_uuid, m.timestamp, m.role, m.text, m.model,
|
|
|
|
|
m.is_sidechain, m.agent_id, m.input_tokens, m.output_tokens, m.cwd, m.skill, m.turn_duration_ms,
|
|
|
|
|
m.content_type, m.is_meta, m.source
|
|
|
|
|
FROM messages m WHERE m.session_id = ? AND m.agent_id IS NULL ORDER BY m.timestamp, m.uuid
|
|
|
|
|
`).all(sessionId) as SessionMessageRow[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function querySessionToolCalls(sessionId: string): SessionToolCallRow[] {
|
|
|
|
|
if (!db) return [];
|
|
|
|
|
return db.prepare(`SELECT * FROM tool_calls WHERE session_id = ?`).all(sessionId) as SessionToolCallRow[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function querySessionToolResults(sessionId: string): SessionToolResultRow[] {
|
|
|
|
|
if (!db) return [];
|
|
|
|
|
return db.prepare(`SELECT * FROM tool_results WHERE session_id = ?`).all(sessionId) as SessionToolResultRow[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function querySessionSubagents(sessionId: string): SessionSubagentRow[] {
|
|
|
|
|
if (!db) return [];
|
|
|
|
|
return db.prepare(`SELECT * FROM subagents WHERE session_id = ?`).all(sessionId) as SessionSubagentRow[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function querySessionWorkflows(sessionId: string): SessionWorkflowRow[] {
|
|
|
|
|
if (!db) return [];
|
|
|
|
|
const workflows = db.prepare(`SELECT * FROM workflows WHERE session_id = ?`).all(sessionId) as SessionWorkflowRow[];
|
|
|
|
|
for (const workflow of workflows) {
|
|
|
|
|
workflow.agents = db.prepare(`SELECT * FROM workflow_agents WHERE run_id = ?`).all(workflow.run_id) as SessionWorkflowRow['agents'];
|
|
|
|
|
}
|
|
|
|
|
return workflows;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function querySessionSummaries(sessionId: string): SessionSummaryRow[] {
|
|
|
|
|
if (!db) return [];
|
|
|
|
|
return db.prepare(`SELECT * FROM summaries WHERE session_id = ?`).all(sessionId) as SessionSummaryRow[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function querySessionSnapshot(sessionId: string): SessionDetailAssemblyInput {
|
|
|
|
|
return {
|
|
|
|
|
messages: querySessionMessages(sessionId),
|
|
|
|
|
toolCalls: querySessionToolCalls(sessionId),
|
|
|
|
|
toolResults: querySessionToolResults(sessionId),
|
|
|
|
|
subagents: querySessionSubagents(sessionId),
|
|
|
|
|
workflows: querySessionWorkflows(sessionId),
|
|
|
|
|
summaries: querySessionSummaries(sessionId),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function querySessionDisplaySnapshot(sessionId: string): SessionPatchSnapshot {
|
|
|
|
|
const snapshot = querySessionSnapshot(sessionId);
|
|
|
|
|
return {
|
|
|
|
|
messages: assembleSessionMessages(snapshot),
|
|
|
|
|
workflows: snapshot.workflows,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-12 22:20:29 +08:00
|
|
|
ipcMain.handle('db:getSessions', (_, opts = {}) => {
|
|
|
|
|
if (!db) return [];
|
|
|
|
|
const { project, limit = 200 } = opts;
|
2026-06-17 23:40:36 +08:00
|
|
|
let sql = `SELECT id, title, project, project_path, started_at, ended_at, git_branch, version, message_count, jsonl_path, source FROM sessions`;
|
2026-07-09 16:25:59 +08:00
|
|
|
const params: unknown[] = [];
|
2026-06-17 23:40:36 +08:00
|
|
|
const sourceFilter = sourceWhereClause(opts);
|
|
|
|
|
if (sourceFilter.sql) {
|
|
|
|
|
sql = appendWhere(sql, params, sourceFilter.sql);
|
|
|
|
|
params.push(...sourceFilter.params);
|
|
|
|
|
}
|
|
|
|
|
if (project) { sql = appendWhere(sql, params, `project LIKE ?`); params.push(project); }
|
2026-06-12 22:20:29 +08:00
|
|
|
sql += ` ORDER BY COALESCE(ended_at, started_at) DESC LIMIT ?`;
|
|
|
|
|
params.push(limit);
|
|
|
|
|
return db.prepare(sql).all(...params);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ipcMain.handle('db:getSessionMessages', (_, sessionId) => {
|
2026-07-14 18:11:51 +08:00
|
|
|
return querySessionMessages(sessionId);
|
2026-06-12 22:20:29 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ipcMain.handle('db:getSessionToolCalls', (_, sessionId) => {
|
2026-07-14 18:11:51 +08:00
|
|
|
return querySessionToolCalls(sessionId);
|
2026-06-12 22:20:29 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ipcMain.handle('db:getSessionToolResults', (_, sessionId) => {
|
2026-07-14 18:11:51 +08:00
|
|
|
return querySessionToolResults(sessionId);
|
2026-06-12 22:20:29 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ipcMain.handle('db:getSessionSubagents', (_, sessionId) => {
|
2026-07-14 18:11:51 +08:00
|
|
|
return querySessionSubagents(sessionId);
|
2026-06-12 22:20:29 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ipcMain.handle('db:getSessionWorkflows', (_, sessionId) => {
|
2026-07-14 18:11:51 +08:00
|
|
|
return querySessionWorkflows(sessionId);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ipcMain.handle('db:getSessionPatch', (
|
|
|
|
|
_event: IpcMainInvokeEvent,
|
|
|
|
|
sessionId: string,
|
|
|
|
|
cursor: SessionPatchCursor,
|
|
|
|
|
) => {
|
|
|
|
|
if (!db) return null;
|
|
|
|
|
return createSessionPatch(querySessionDisplaySnapshot(sessionId), cursor);
|
2026-06-12 22:20:29 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ipcMain.handle('db:getSubagentMessages', (_, agentId) => {
|
|
|
|
|
if (!db) return [];
|
|
|
|
|
return db.prepare(`
|
|
|
|
|
SELECT m.uuid, m.session_id, m.type, m.parent_uuid, m.timestamp, m.role, m.text, m.model,
|
|
|
|
|
m.is_sidechain, m.agent_id, m.input_tokens, m.output_tokens, m.cwd, m.skill, m.turn_duration_ms,
|
2026-06-17 23:40:36 +08:00
|
|
|
m.content_type, m.is_meta, m.source
|
|
|
|
|
FROM messages m WHERE m.agent_id = ? ORDER BY m.timestamp, m.uuid
|
2026-06-12 22:20:29 +08:00
|
|
|
`).all(agentId);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ipcMain.handle('db:getSubagentToolCalls', (_, agentId) => {
|
|
|
|
|
if (!db) return [];
|
|
|
|
|
return db.prepare(`
|
|
|
|
|
SELECT tc.* FROM tool_calls tc
|
|
|
|
|
JOIN messages m ON m.uuid = tc.message_uuid
|
|
|
|
|
WHERE m.agent_id = ?
|
|
|
|
|
`).all(agentId);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ipcMain.handle('db:getSubagentToolResults', (_, agentId) => {
|
|
|
|
|
if (!db) return [];
|
|
|
|
|
return db.prepare(`
|
|
|
|
|
SELECT tr.* FROM tool_results tr
|
|
|
|
|
JOIN messages m ON m.uuid = tr.message_uuid
|
|
|
|
|
WHERE m.agent_id = ?
|
|
|
|
|
`).all(agentId);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ipcMain.handle('db:getSessionSummaries', (_, sessionId) => {
|
2026-07-14 18:11:51 +08:00
|
|
|
return querySessionSummaries(sessionId);
|
2026-06-12 22:20:29 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ipcMain.handle('db:getMemories', () => {
|
|
|
|
|
if (!db) return [];
|
|
|
|
|
return db.prepare(`
|
2026-06-13 03:42:01 +08:00
|
|
|
SELECT id, session_id, project, message_start, message_end, path, anchors, summary, created_at, deleted_at, deleted_reason
|
2026-06-12 22:20:29 +08:00
|
|
|
FROM memories ORDER BY created_at DESC
|
|
|
|
|
`).all();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ipcMain.handle('db:getMessageFullText', (_, uuid) => {
|
|
|
|
|
if (!db) return null;
|
2026-06-17 23:40:36 +08:00
|
|
|
const msg = db.prepare('SELECT session_id, agent_id, source FROM messages WHERE uuid=?').get(uuid);
|
2026-06-12 22:20:29 +08:00
|
|
|
if (!msg) return null;
|
|
|
|
|
|
2026-06-17 23:40:36 +08:00
|
|
|
if (msg.source === 'codex' || String(uuid).startsWith('codex:')) {
|
|
|
|
|
const match = /^codex:([^:]+):(\d+)$/.exec(String(uuid));
|
|
|
|
|
if (!match) return null;
|
|
|
|
|
const rawThreadId = match[1];
|
|
|
|
|
const targetLine = Number(match[2]);
|
2026-07-09 16:25:59 +08:00
|
|
|
let jsonlPath: string | null = null;
|
2026-06-17 23:40:36 +08:00
|
|
|
if (!msg.agent_id) {
|
|
|
|
|
jsonlPath = db.prepare('SELECT jsonl_path FROM sessions WHERE id=?').get(msg.session_id)?.jsonl_path || null;
|
|
|
|
|
}
|
|
|
|
|
if (!jsonlPath) {
|
|
|
|
|
jsonlPath = db.prepare(`
|
|
|
|
|
SELECT jsonl_path FROM index_state
|
|
|
|
|
WHERE jsonl_path LIKE ? AND jsonl_path LIKE '%.jsonl'
|
|
|
|
|
ORDER BY length(jsonl_path) ASC
|
|
|
|
|
LIMIT 1
|
|
|
|
|
`).get(`%${rawThreadId}.jsonl`)?.jsonl_path || null;
|
|
|
|
|
}
|
|
|
|
|
if (!jsonlPath || !fs.existsSync(jsonlPath)) return null;
|
|
|
|
|
const lines = fs.readFileSync(jsonlPath, 'utf-8').split('\n').filter(Boolean);
|
|
|
|
|
const line = lines[targetLine - 1];
|
|
|
|
|
if (!line) return null;
|
|
|
|
|
try {
|
|
|
|
|
const obj = JSON.parse(line);
|
|
|
|
|
const payload = obj.payload || {};
|
|
|
|
|
if (obj.type === 'event_msg') {
|
|
|
|
|
if (typeof payload.message === 'string') return payload.message;
|
|
|
|
|
if (typeof payload.text === 'string') return payload.text;
|
|
|
|
|
}
|
|
|
|
|
if (obj.type === 'response_item' && payload.type === 'message' && Array.isArray(payload.content)) {
|
|
|
|
|
const parts = payload.content.map(b => b.text).filter(Boolean);
|
|
|
|
|
return parts.join('\n') || null;
|
|
|
|
|
}
|
|
|
|
|
} catch {}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-12 22:20:29 +08:00
|
|
|
// Resolve JSONL path
|
2026-07-09 16:25:59 +08:00
|
|
|
let jsonlPath: string | null = null;
|
2026-06-12 22:20:29 +08:00
|
|
|
if (msg.agent_id) {
|
|
|
|
|
const wa = db.prepare('SELECT agent_id, run_id, session_id FROM workflow_agents WHERE agent_id=?').get(msg.agent_id);
|
|
|
|
|
if (wa) {
|
|
|
|
|
const ses = db.prepare('SELECT jsonl_path FROM sessions WHERE id=?').get(wa.session_id);
|
|
|
|
|
if (ses) jsonlPath = path.join(path.dirname(ses.jsonl_path), wa.session_id, 'subagents', 'workflows', wa.run_id, wa.agent_id + '.jsonl');
|
|
|
|
|
}
|
|
|
|
|
if (!jsonlPath) {
|
|
|
|
|
const sa = db.prepare('SELECT agent_id, session_id FROM subagents WHERE agent_id=?').get(msg.agent_id);
|
|
|
|
|
if (sa) {
|
|
|
|
|
const ses = db.prepare('SELECT jsonl_path FROM sessions WHERE id=?').get(sa.session_id);
|
|
|
|
|
if (ses) jsonlPath = path.join(path.dirname(ses.jsonl_path), sa.session_id, 'subagents', sa.agent_id + '.jsonl');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!jsonlPath) {
|
|
|
|
|
const ses = db.prepare('SELECT jsonl_path FROM sessions WHERE id=?').get(msg.session_id);
|
|
|
|
|
if (ses) jsonlPath = ses.jsonl_path;
|
|
|
|
|
}
|
|
|
|
|
if (!jsonlPath || !fs.existsSync(jsonlPath)) return null;
|
|
|
|
|
|
|
|
|
|
// Scan JSONL for the message UUID and extract full text
|
|
|
|
|
const data = fs.readFileSync(jsonlPath, 'utf-8');
|
|
|
|
|
const lines = data.split('\n');
|
|
|
|
|
for (const line of lines) {
|
|
|
|
|
if (!line.includes(uuid)) continue;
|
|
|
|
|
try {
|
|
|
|
|
const obj = JSON.parse(line);
|
|
|
|
|
if (obj.uuid !== uuid) continue;
|
|
|
|
|
const content = obj.message?.content;
|
|
|
|
|
if (typeof content === 'string') return content;
|
|
|
|
|
if (!Array.isArray(content)) return null;
|
2026-07-09 16:25:59 +08:00
|
|
|
const parts: string[] = [];
|
2026-06-12 22:20:29 +08:00
|
|
|
for (const b of content) {
|
|
|
|
|
if (b.type === 'text' && b.text) parts.push(b.text);
|
|
|
|
|
else if (b.type === 'thinking' && b.thinking) parts.push(b.thinking);
|
|
|
|
|
}
|
|
|
|
|
return parts.join('\n') || null;
|
|
|
|
|
} catch { continue; }
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ipcMain.handle('db:readMemoryFile', (_, filePath) => {
|
|
|
|
|
try {
|
|
|
|
|
if (fs.existsSync(filePath)) return fs.readFileSync(filePath, 'utf-8');
|
|
|
|
|
return null;
|
|
|
|
|
} catch { return null; }
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ipcMain.handle('db:archiveMemory', (_, id, reason) => {
|
2026-07-10 18:10:45 +08:00
|
|
|
return runAppDbWrite(() => {
|
|
|
|
|
db.prepare(`UPDATE memories SET deleted_at = ?, deleted_reason = ? WHERE id = ?`)
|
|
|
|
|
.run(new Date().toISOString(), reason || 'Archived via panel', id);
|
|
|
|
|
});
|
2026-06-12 22:20:29 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ipcMain.handle('db:restoreMemory', (_, id) => {
|
2026-07-10 18:10:45 +08:00
|
|
|
return runAppDbWrite(() => {
|
|
|
|
|
db.prepare(`UPDATE memories SET deleted_at = NULL, deleted_reason = NULL WHERE id = ?`).run(id);
|
|
|
|
|
});
|
2026-06-12 22:20:29 +08:00
|
|
|
});
|
|
|
|
|
|
2026-06-17 23:40:36 +08:00
|
|
|
ipcMain.handle('db:getProjects', (_, opts = {}) => {
|
2026-06-12 22:20:29 +08:00
|
|
|
if (!db) return [];
|
2026-06-17 23:40:36 +08:00
|
|
|
const sourceFilter = sourceWhereClause(opts);
|
|
|
|
|
const where = sourceFilter.sql ? `WHERE ${sourceFilter.sql}` : '';
|
2026-06-12 22:20:29 +08:00
|
|
|
return db.prepare(`
|
|
|
|
|
SELECT project, project_path, COUNT(*) as session_count,
|
|
|
|
|
MAX(COALESCE(ended_at, started_at)) as last_active
|
2026-06-17 23:40:36 +08:00
|
|
|
FROM sessions ${where ? `${where} AND` : 'WHERE'} project IS NOT NULL
|
2026-06-12 22:20:29 +08:00
|
|
|
GROUP BY project ORDER BY last_active DESC
|
2026-06-17 23:40:36 +08:00
|
|
|
`).all(...sourceFilter.params);
|
2026-06-12 22:20:29 +08:00
|
|
|
});
|
|
|
|
|
|
2026-06-17 23:40:36 +08:00
|
|
|
ipcMain.handle('db:getStats', (_, opts = {}) => {
|
2026-06-12 22:20:29 +08:00
|
|
|
if (!db) return { sessions: 0, memories: 0, memoriesArchived: 0 };
|
2026-06-17 23:40:36 +08:00
|
|
|
const sourceFilter = sourceWhereClause(opts);
|
|
|
|
|
const where = sourceFilter.sql ? `WHERE ${sourceFilter.sql}` : '';
|
|
|
|
|
const sessions = db.prepare(`SELECT COUNT(*) as c FROM sessions ${where}`).get(...sourceFilter.params)?.c || 0;
|
2026-06-12 22:20:29 +08:00
|
|
|
const memories = db.prepare('SELECT COUNT(*) as c FROM memories WHERE deleted_at IS NULL').get()?.c || 0;
|
|
|
|
|
const memoriesArchived = db.prepare('SELECT COUNT(*) as c FROM memories WHERE deleted_at IS NOT NULL').get()?.c || 0;
|
|
|
|
|
return { sessions, memories, memoriesArchived };
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-17 23:40:36 +08:00
|
|
|
ipcMain.handle('db:getUsageStats', (_, opts = {}) => {
|
2026-06-12 22:20:29 +08:00
|
|
|
if (!db) return { daily: [], totalTokens: 0, peakDay: null, longestTurn: null };
|
2026-06-17 23:40:36 +08:00
|
|
|
const sourceFilter = sourceWhereClause(opts, 'source');
|
|
|
|
|
const sourceSql = sourceFilter.sql ? `AND ${sourceFilter.sql}` : '';
|
2026-06-12 22:20:29 +08:00
|
|
|
|
|
|
|
|
const daily = db.prepare(`
|
|
|
|
|
SELECT DATE(timestamp) as day,
|
|
|
|
|
SUM(COALESCE(input_tokens,0) + COALESCE(output_tokens,0)) as tokens
|
|
|
|
|
FROM messages
|
|
|
|
|
WHERE timestamp IS NOT NULL AND (input_tokens IS NOT NULL OR output_tokens IS NOT NULL)
|
2026-06-17 23:40:36 +08:00
|
|
|
${sourceSql}
|
2026-06-12 22:20:29 +08:00
|
|
|
GROUP BY DATE(timestamp)
|
|
|
|
|
ORDER BY day
|
2026-06-17 23:40:36 +08:00
|
|
|
`).all(...sourceFilter.params);
|
2026-06-12 22:20:29 +08:00
|
|
|
|
|
|
|
|
const totalTokens = db.prepare(`
|
|
|
|
|
SELECT SUM(COALESCE(input_tokens,0) + COALESCE(output_tokens,0)) as total
|
|
|
|
|
FROM messages
|
2026-06-17 23:40:36 +08:00
|
|
|
${sourceFilter.sql ? `WHERE ${sourceFilter.sql}` : ''}
|
|
|
|
|
`).get(...sourceFilter.params)?.total || 0;
|
2026-06-12 22:20:29 +08:00
|
|
|
|
|
|
|
|
const peakDay = db.prepare(`
|
|
|
|
|
SELECT DATE(timestamp) as day,
|
|
|
|
|
SUM(COALESCE(input_tokens,0) + COALESCE(output_tokens,0)) as tokens
|
|
|
|
|
FROM messages
|
|
|
|
|
WHERE timestamp IS NOT NULL AND (input_tokens IS NOT NULL OR output_tokens IS NOT NULL)
|
2026-06-17 23:40:36 +08:00
|
|
|
${sourceSql}
|
2026-06-12 22:20:29 +08:00
|
|
|
GROUP BY DATE(timestamp)
|
|
|
|
|
ORDER BY tokens DESC
|
|
|
|
|
LIMIT 1
|
2026-06-17 23:40:36 +08:00
|
|
|
`).get(...sourceFilter.params) || null;
|
2026-06-12 22:20:29 +08:00
|
|
|
|
|
|
|
|
const longestTurn = db.prepare(`
|
|
|
|
|
SELECT turn_duration_ms, uuid, session_id, timestamp
|
|
|
|
|
FROM messages
|
|
|
|
|
WHERE turn_duration_ms IS NOT NULL
|
2026-06-17 23:40:36 +08:00
|
|
|
${sourceSql}
|
2026-06-12 22:20:29 +08:00
|
|
|
ORDER BY turn_duration_ms DESC
|
|
|
|
|
LIMIT 1
|
2026-06-17 23:40:36 +08:00
|
|
|
`).get(...sourceFilter.params) || null;
|
2026-06-12 22:20:29 +08:00
|
|
|
|
|
|
|
|
return { daily, totalTokens, peakDay, longestTurn };
|
|
|
|
|
});
|
2026-06-14 03:16:49 +08:00
|
|
|
|
|
|
|
|
// --- Capture ---
|
|
|
|
|
|
|
|
|
|
const EXPORT_WIDTH = 540;
|
|
|
|
|
const EXPORT_HEIGHT = 675;
|
|
|
|
|
|
|
|
|
|
async function createExportCapture(parentWin, query) {
|
|
|
|
|
const exportWin = new BrowserWindow({
|
|
|
|
|
width: EXPORT_WIDTH,
|
|
|
|
|
height: EXPORT_HEIGHT,
|
|
|
|
|
show: false,
|
|
|
|
|
webPreferences: {
|
2026-07-09 10:20:18 +08:00
|
|
|
preload: path.join(__dirname, '..', 'preload', 'index.js'),
|
2026-06-14 03:16:49 +08:00
|
|
|
contextIsolation: true,
|
|
|
|
|
nodeIntegration: false,
|
|
|
|
|
offscreen: true,
|
|
|
|
|
deviceScaleFactor: 2,
|
2026-07-09 16:25:59 +08:00
|
|
|
} as Electron.WebPreferences,
|
2026-06-14 03:16:49 +08:00
|
|
|
});
|
|
|
|
|
|
2026-07-09 10:20:18 +08:00
|
|
|
const isDev = process.argv.includes('--dev') || !!process.env.ELECTRON_RENDERER_URL;
|
2026-06-14 03:16:49 +08:00
|
|
|
const url = isDev
|
2026-07-09 10:20:18 +08:00
|
|
|
? `${process.env.ELECTRON_RENDERER_URL || 'http://localhost:5173'}/#/recap-export?${query}`
|
|
|
|
|
: `file://${path.join(__dirname, '..', 'renderer', 'index.html')}#/recap-export?${query}`;
|
2026-06-14 03:16:49 +08:00
|
|
|
|
|
|
|
|
await exportWin.loadURL(url);
|
2026-06-15 01:44:32 +08:00
|
|
|
await waitForExportReady(exportWin.webContents);
|
2026-06-14 03:16:49 +08:00
|
|
|
|
|
|
|
|
const image = await exportWin.webContents.capturePage({
|
|
|
|
|
x: 0, y: 0, width: EXPORT_WIDTH, height: EXPORT_HEIGHT,
|
|
|
|
|
});
|
|
|
|
|
exportWin.close();
|
|
|
|
|
return image;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 01:44:32 +08:00
|
|
|
async function waitForExportReady(webContents, timeoutMs = 2500) {
|
|
|
|
|
const started = Date.now();
|
|
|
|
|
while (Date.now() - started < timeoutMs) {
|
|
|
|
|
try {
|
|
|
|
|
const ready = await webContents.executeJavaScript('window.__OBELISK_RECAP_EXPORT_READY__ === true', true);
|
|
|
|
|
if (ready) return true;
|
|
|
|
|
} catch {}
|
|
|
|
|
await new Promise(r => setTimeout(r, 50));
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ipcMain.handle('capture:export', async (event, { cardIdx, archetype, filename } = {}) => {
|
2026-06-14 03:16:49 +08:00
|
|
|
const win = BrowserWindow.fromWebContents(event.sender);
|
|
|
|
|
if (!win) return null;
|
2026-06-15 01:44:32 +08:00
|
|
|
const query = buildRecapExportQuery({ cardIdx, archetype, filename });
|
2026-06-14 03:16:49 +08:00
|
|
|
const image = await createExportCapture(win, query);
|
|
|
|
|
const { filePath } = await dialog.showSaveDialog(win, {
|
|
|
|
|
defaultPath: `obelisk-recap-${cardIdx + 1}.png`,
|
|
|
|
|
filters: [{ name: 'PNG', extensions: ['png'] }],
|
|
|
|
|
});
|
|
|
|
|
if (!filePath) return null;
|
|
|
|
|
fs.writeFileSync(filePath, image.toPNG());
|
|
|
|
|
return filePath;
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-15 01:44:32 +08:00
|
|
|
ipcMain.handle('capture:copy', async (event, { cardIdx, archetype, filename } = {}) => {
|
2026-06-14 03:16:49 +08:00
|
|
|
const win = BrowserWindow.fromWebContents(event.sender);
|
|
|
|
|
if (!win) return false;
|
2026-06-15 01:44:32 +08:00
|
|
|
const query = buildRecapExportQuery({ cardIdx, archetype, filename });
|
2026-06-14 03:16:49 +08:00
|
|
|
const image = await createExportCapture(win, query);
|
|
|
|
|
clipboard.writeImage(image);
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// --- Recap files ---
|
|
|
|
|
|
|
|
|
|
ipcMain.handle('recap:list', () => {
|
|
|
|
|
if (!fs.existsSync(RECAP_DIR)) return [];
|
|
|
|
|
return fs.readdirSync(RECAP_DIR)
|
|
|
|
|
.filter(f => f.endsWith('.json'))
|
|
|
|
|
.sort()
|
|
|
|
|
.reverse();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ipcMain.handle('recap:read', (_, filename) => {
|
|
|
|
|
const filePath = path.join(RECAP_DIR, path.basename(filename));
|
|
|
|
|
if (!fs.existsSync(filePath)) return null;
|
|
|
|
|
try {
|
|
|
|
|
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
|
|
|
} catch { return null; }
|
|
|
|
|
});
|
2026-06-15 01:44:32 +08:00
|
|
|
|
|
|
|
|
// --- Settings ---
|
|
|
|
|
|
|
|
|
|
const SETTINGS_PATH = path.join(OBELISK_DIR, 'settings.json');
|
|
|
|
|
|
|
|
|
|
function loadPersistedSettings() {
|
|
|
|
|
try {
|
|
|
|
|
if (fs.existsSync(SETTINGS_PATH)) return JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf-8'));
|
|
|
|
|
} catch {}
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function savePersistedSettings(settings) {
|
|
|
|
|
if (!fs.existsSync(OBELISK_DIR)) fs.mkdirSync(OBELISK_DIR, { recursive: true });
|
|
|
|
|
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ipcMain.handle('settings:get', () => {
|
|
|
|
|
const persisted = loadPersistedSettings();
|
2026-06-17 23:40:36 +08:00
|
|
|
const { claudeDir, codexDir, dbPath: dbFile } = getPathsForClaudeDir(
|
|
|
|
|
persisted.claudeDir || DEFAULT_CLAUDE_DIR,
|
|
|
|
|
persisted.codexDir || DEFAULT_CODEX_DIR,
|
|
|
|
|
);
|
2026-06-15 01:44:32 +08:00
|
|
|
const recapDir = persisted.recapDir || RECAP_DIR;
|
2026-06-17 23:40:36 +08:00
|
|
|
const claudeExists = fs.existsSync(claudeDir);
|
|
|
|
|
const codexExists = fs.existsSync(codexDir);
|
|
|
|
|
let claudeSessionCount = 0;
|
|
|
|
|
let codexSessionCount = 0;
|
2026-06-15 01:44:32 +08:00
|
|
|
let memoryCount = 0;
|
2026-06-17 23:40:36 +08:00
|
|
|
let claudeLastIndexed = '';
|
|
|
|
|
let codexLastIndexed = '';
|
2026-06-15 01:44:32 +08:00
|
|
|
|
|
|
|
|
if (db) {
|
|
|
|
|
try {
|
2026-06-17 23:40:36 +08:00
|
|
|
claudeSessionCount = db.prepare("SELECT COUNT(*) as c FROM sessions WHERE COALESCE(source, 'claude') = 'claude'").get()?.c || 0;
|
|
|
|
|
codexSessionCount = db.prepare("SELECT COUNT(*) as c FROM sessions WHERE source = 'codex'").get()?.c || 0;
|
2026-06-15 01:44:32 +08:00
|
|
|
memoryCount = db.prepare('SELECT COUNT(*) as c FROM memories WHERE deleted_at IS NULL').get()?.c || 0;
|
2026-06-17 23:40:36 +08:00
|
|
|
const claudeLatest = db.prepare("SELECT MAX(started_at) as t FROM sessions WHERE COALESCE(source, 'claude') = 'claude'").get();
|
|
|
|
|
claudeLastIndexed = claudeLatest?.t || '';
|
|
|
|
|
const codexLatest = db.prepare("SELECT MAX(started_at) as t FROM sessions WHERE source = 'codex'").get();
|
|
|
|
|
codexLastIndexed = codexLatest?.t || '';
|
2026-06-15 01:44:32 +08:00
|
|
|
} catch {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
claudeDir,
|
2026-06-17 23:40:36 +08:00
|
|
|
codexDir,
|
2026-06-15 01:44:32 +08:00
|
|
|
dbPath: dbFile,
|
|
|
|
|
recapDir,
|
|
|
|
|
autoRefresh: persisted.autoRefresh !== false,
|
2026-06-17 23:40:36 +08:00
|
|
|
sources: [
|
|
|
|
|
{
|
|
|
|
|
id: 'claude',
|
|
|
|
|
name: 'Claude Code',
|
|
|
|
|
vendor: 'Anthropic',
|
|
|
|
|
path: claudeDir,
|
|
|
|
|
exists: claudeExists,
|
|
|
|
|
sessionCount: claudeSessionCount,
|
|
|
|
|
lastIndexed: claudeLastIndexed,
|
|
|
|
|
status: claudeExists ? 'ok' : 'error',
|
|
|
|
|
statusText: claudeExists ? 'Connected' : 'Folder not found',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'codex',
|
|
|
|
|
name: 'Codex',
|
|
|
|
|
vendor: 'OpenAI',
|
|
|
|
|
path: codexDir,
|
|
|
|
|
exists: codexExists,
|
|
|
|
|
sessionCount: codexSessionCount,
|
|
|
|
|
lastIndexed: codexLastIndexed,
|
|
|
|
|
status: codexExists ? (codexSessionCount > 0 ? 'ok' : 'warn') : 'error',
|
|
|
|
|
statusText: codexExists ? (codexSessionCount > 0 ? 'Connected' : 'No sessions found') : 'Folder not found',
|
|
|
|
|
},
|
|
|
|
|
],
|
2026-06-15 01:44:32 +08:00
|
|
|
memoryCount,
|
2026-06-17 23:40:36 +08:00
|
|
|
sessionCount: claudeSessionCount + codexSessionCount,
|
|
|
|
|
lastIndexed: claudeLastIndexed,
|
|
|
|
|
status: claudeExists ? 'ok' : 'error',
|
|
|
|
|
statusText: claudeExists ? 'Connected' : 'Folder not found',
|
2026-06-15 01:44:32 +08:00
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ipcMain.handle('settings:set', async (_, key, value) => {
|
|
|
|
|
const persisted = loadPersistedSettings();
|
|
|
|
|
if (value === null) {
|
|
|
|
|
delete persisted[key];
|
|
|
|
|
} else {
|
|
|
|
|
persisted[key] = value;
|
|
|
|
|
}
|
|
|
|
|
savePersistedSettings(persisted);
|
|
|
|
|
|
|
|
|
|
if (key === 'autoRefresh') {
|
|
|
|
|
if (value === false && indexerService) {
|
|
|
|
|
await stopIndexerServiceAndWait();
|
|
|
|
|
} else if (value !== false && indexerService) {
|
|
|
|
|
await stopIndexerServiceAndWait();
|
|
|
|
|
startIndexerService({ buildOnStart: false });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 23:40:36 +08:00
|
|
|
if (key === 'claudeDir' || key === 'codexDir') {
|
2026-06-15 01:44:32 +08:00
|
|
|
await stopIndexerServiceAndWait();
|
2026-06-17 23:40:36 +08:00
|
|
|
const paths = getPathsForClaudeDir(
|
|
|
|
|
persisted.claudeDir || DEFAULT_CLAUDE_DIR,
|
|
|
|
|
persisted.codexDir || DEFAULT_CODEX_DIR,
|
|
|
|
|
);
|
|
|
|
|
migrateLegacyDbIfNeeded(paths);
|
|
|
|
|
openDb(paths.dbPath);
|
2026-06-15 01:44:32 +08:00
|
|
|
if (persisted.autoRefresh !== false) {
|
|
|
|
|
startIndexerService({ buildOnStart: true });
|
|
|
|
|
}
|
|
|
|
|
notifyIndexUpdated();
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ipcMain.handle('settings:browseFolder', async (event) => {
|
|
|
|
|
const win = BrowserWindow.fromWebContents(event.sender);
|
2026-07-09 16:25:59 +08:00
|
|
|
if (!win) return null;
|
2026-06-15 01:44:32 +08:00
|
|
|
const { filePaths } = await dialog.showOpenDialog(win, {
|
|
|
|
|
properties: ['openDirectory'],
|
|
|
|
|
title: 'Select Claude Code data folder',
|
|
|
|
|
});
|
|
|
|
|
if (filePaths && filePaths[0]) return filePaths[0];
|
|
|
|
|
return null;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ipcMain.handle('settings:revealPath', (_, p) => {
|
|
|
|
|
if (fs.existsSync(p)) shell.showItemInFolder(p);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ipcMain.handle('settings:rebuildIndex', async () => {
|
|
|
|
|
if (!indexerWorker) return null;
|
|
|
|
|
const persisted = loadPersistedSettings();
|
2026-06-17 23:40:36 +08:00
|
|
|
const paths = getPathsForClaudeDir(
|
|
|
|
|
persisted.claudeDir || DEFAULT_CLAUDE_DIR,
|
|
|
|
|
persisted.codexDir || DEFAULT_CODEX_DIR,
|
|
|
|
|
);
|
|
|
|
|
const tempDbPath = rebuildTempDbPath(paths.dbPath);
|
2026-06-15 01:44:32 +08:00
|
|
|
const shouldRestartWatcher = persisted.autoRefresh !== false;
|
2026-06-17 23:40:36 +08:00
|
|
|
await stopIndexerServiceAndWait({ waitForIdle: false });
|
|
|
|
|
if (indexerWorker) {
|
|
|
|
|
await Promise.resolve(indexerWorker.stop());
|
|
|
|
|
indexerWorker = createWorkerBuildIndex();
|
|
|
|
|
}
|
|
|
|
|
cleanupDbFiles(tempDbPath);
|
2026-07-10 18:10:45 +08:00
|
|
|
let writerLease: ReturnType<typeof acquireWriterLease> = null;
|
2026-06-15 01:44:32 +08:00
|
|
|
try {
|
2026-07-10 18:10:45 +08:00
|
|
|
const writerLeasePath = writerLockPathFor(paths.dbPath);
|
|
|
|
|
writerLease = acquireWriterLease({
|
|
|
|
|
lockPath: writerLeasePath,
|
|
|
|
|
openDb: lockPath => new Database(lockPath),
|
|
|
|
|
waitMs: 2000,
|
|
|
|
|
});
|
|
|
|
|
if (!writerLease) {
|
|
|
|
|
return {
|
|
|
|
|
files: 0,
|
|
|
|
|
latestSourceMtime: 0,
|
|
|
|
|
affectedSessionIds: [],
|
|
|
|
|
ftsRebuilt: false,
|
|
|
|
|
skipped: 0,
|
|
|
|
|
skippedFiles: [],
|
|
|
|
|
deferred: true,
|
|
|
|
|
reason: 'writer_busy',
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
migrateLegacyDbIfNeeded(paths, { writerLeaseMode: 'caller-held' });
|
2026-06-15 01:44:32 +08:00
|
|
|
const result = await indexerWorker.buildIndex({
|
|
|
|
|
reason: 'manual-rebuild',
|
|
|
|
|
force: true,
|
|
|
|
|
claudeDir: paths.claudeDir,
|
2026-06-17 23:40:36 +08:00
|
|
|
codexDir: paths.codexDir,
|
2026-06-15 01:44:32 +08:00
|
|
|
projectsDir: paths.projectsDir,
|
2026-06-17 23:40:36 +08:00
|
|
|
dbPath: tempDbPath,
|
|
|
|
|
preserveDbPath: fs.existsSync(paths.dbPath) ? paths.dbPath : null,
|
2026-07-10 18:10:45 +08:00
|
|
|
writerLeasePath,
|
|
|
|
|
writerLeaseMode: 'caller-held',
|
2026-06-15 01:44:32 +08:00
|
|
|
});
|
2026-07-10 18:10:45 +08:00
|
|
|
if (result?.deferred) return result;
|
2026-06-17 23:40:36 +08:00
|
|
|
closeDb();
|
|
|
|
|
replaceDbWithTemp(tempDbPath, paths.dbPath);
|
2026-07-10 18:10:45 +08:00
|
|
|
openDb(paths.dbPath, { writerLeaseMode: 'caller-held' });
|
2026-06-15 01:44:32 +08:00
|
|
|
notifyIndexUpdated(result);
|
|
|
|
|
return result;
|
|
|
|
|
} finally {
|
2026-07-10 18:10:45 +08:00
|
|
|
try {
|
|
|
|
|
cleanupDbFiles(tempDbPath);
|
|
|
|
|
if (!db) {
|
|
|
|
|
try {
|
|
|
|
|
openDb(paths.dbPath, {
|
|
|
|
|
writerLeaseMode: writerLease ? 'caller-held' : 'acquire',
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn?.(`Obelisk DB reopen after rebuild failed: ${(error as Error).message}`);
|
|
|
|
|
}
|
2026-06-17 23:40:36 +08:00
|
|
|
}
|
2026-07-10 18:10:45 +08:00
|
|
|
} finally {
|
|
|
|
|
writerLease?.release();
|
|
|
|
|
if (shouldRestartWatcher) startIndexerService({ buildOnStart: false });
|
2026-06-17 23:40:36 +08:00
|
|
|
}
|
2026-06-15 01:44:32 +08:00
|
|
|
}
|
|
|
|
|
});
|