Files
obelisk/app/main.js
T
tommy0103 9fc7f202f0 feat(app): add weekly recap cards with swipeable story UI and export
Introduce a Spotify-Wrapped-style recap feature: five themed cards
  (Cover, Path, Vibe, Workflow, Closing) rendered per archetype palette,
  with keyboard/swipe navigation and image export via capture IPC. Add
  RecapList, RecapDetail, RecapExport views and recap component library.
  Wire recap:list/read/updated IPC channels through preload, document the
  retrieval-to-card contract in references/recap-patterns.md, and bundle
  dist-renderer for production use.
2026-06-14 03:16:49 +08:00

418 lines
13 KiB
JavaScript

const { app, BrowserWindow, ipcMain, clipboard, dialog, nativeImage } = require('electron');
const path = require('path');
const os = require('os');
const fs = require('fs');
const Database = require('better-sqlite3');
const { writeHeartbeat } = require('./indexer');
const { createIndexerService } = require('./indexer-service');
const { createWorkerBuildIndex } = require('./indexer-worker-client');
const DB_PATH = path.join(os.homedir(), '.claude', 'obelisk.sqlite');
let db;
let indexerService;
let indexerWorker;
function openDb() {
if (!fs.existsSync(DB_PATH)) return null;
if (db) db.close();
db = new Database(DB_PATH, { readonly: false });
db.pragma('journal_mode = WAL');
return db;
}
function notifyIndexUpdated() {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send('obelisk:index-updated');
}
}
function startIndexerService() {
indexerService = createIndexerService({
buildIndex: async ({ reason }) => {
const result = await indexerWorker.buildIndex({ reason });
openDb();
notifyIndexUpdated();
return result;
},
writeHeartbeat,
});
indexerService.start({ buildOnStart: false });
return indexerService;
}
function createWindow() {
const win = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 500,
titleBarStyle: 'hiddenInset',
trafficLightPosition: { x: 14, y: 10 },
backgroundColor: '#0a0b14',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
});
// 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);
}
});
const isDev = process.argv.includes('--dev');
if (isDev) {
win.loadURL('http://localhost:5173');
win.webContents.openDevTools();
} else {
win.loadFile(path.join(__dirname, 'dist-renderer', 'index.html'));
}
}
const OBELISK_DIR = path.join(os.homedir(), '.obelisk');
const RECAP_DIR = path.join(OBELISK_DIR, 'recap');
let obeliskWatcher = null;
function startObeliskWatcher() {
const chokidar = require('chokidar');
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);
}
function onObeliskChange(filePath) {
if (filePath.startsWith(RECAP_DIR)) {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send('obelisk:recap-updated', filePath);
}
}
}
app.whenReady().then(() => {
indexerWorker = createWorkerBuildIndex();
openDb();
createWindow();
startIndexerService().runBuildNow('startup');
startObeliskWatcher();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on('before-quit', () => {
if (indexerService) indexerService.stop();
if (indexerWorker) indexerWorker.stop();
if (obeliskWatcher) obeliskWatcher.close();
});
app.on('window-all-closed', () => {
if (db) db.close();
if (process.platform !== 'darwin') app.quit();
});
// --- IPC Handlers ---
ipcMain.handle('db:getSessions', (_, opts = {}) => {
if (!db) return [];
const { project, limit = 200 } = opts;
let sql = `SELECT id, title, project, project_path, started_at, ended_at, git_branch, version, message_count, jsonl_path FROM sessions`;
const params = [];
if (project) { sql += ` WHERE project LIKE ?`; params.push(project); }
sql += ` ORDER BY COALESCE(ended_at, started_at) DESC LIMIT ?`;
params.push(limit);
return db.prepare(sql).all(...params);
});
ipcMain.handle('db:getSessionMessages', (_, sessionId) => {
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
FROM messages m WHERE m.session_id = ? AND m.agent_id IS NULL ORDER BY m.timestamp
`).all(sessionId);
});
ipcMain.handle('db:getSessionToolCalls', (_, sessionId) => {
if (!db) return [];
return db.prepare(`SELECT * FROM tool_calls WHERE session_id = ?`).all(sessionId);
});
ipcMain.handle('db:getSessionToolResults', (_, sessionId) => {
if (!db) return [];
return db.prepare(`SELECT * FROM tool_results WHERE session_id = ?`).all(sessionId);
});
ipcMain.handle('db:getSessionSubagents', (_, sessionId) => {
if (!db) return [];
return db.prepare(`SELECT * FROM subagents WHERE session_id = ?`).all(sessionId);
});
ipcMain.handle('db:getSessionWorkflows', (_, sessionId) => {
if (!db) return [];
const workflows = db.prepare(`SELECT * FROM workflows WHERE session_id = ?`).all(sessionId);
for (const wf of workflows) {
wf.agents = db.prepare(`SELECT * FROM workflow_agents WHERE run_id = ?`).all(wf.run_id);
}
return workflows;
});
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,
m.content_type, m.is_meta
FROM messages m WHERE m.agent_id = ? ORDER BY m.timestamp
`).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) => {
if (!db) return [];
return db.prepare(`SELECT * FROM summaries WHERE session_id = ?`).all(sessionId);
});
ipcMain.handle('db:getMemories', () => {
if (!db) return [];
return db.prepare(`
SELECT id, session_id, project, message_start, message_end, path, anchors, summary, created_at, deleted_at, deleted_reason
FROM memories ORDER BY created_at DESC
`).all();
});
ipcMain.handle('db:getMessageFullText', (_, uuid) => {
if (!db) return null;
const msg = db.prepare('SELECT session_id, agent_id FROM messages WHERE uuid=?').get(uuid);
if (!msg) return null;
// Resolve JSONL path
let jsonlPath = null;
if (msg.agent_id) {
const wa = db.prepare('SELECT agent_id, run_id, session_id FROM workflow_agents WHERE agent_id=?').get(msg.agent_id);
if (wa) {
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 readline = require('readline');
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;
const parts = [];
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) => {
if (!db) return false;
db.prepare(`UPDATE memories SET deleted_at = ?, deleted_reason = ? WHERE id = ?`)
.run(new Date().toISOString(), reason || 'Archived via panel', id);
return true;
});
ipcMain.handle('db:restoreMemory', (_, id) => {
if (!db) return false;
db.prepare(`UPDATE memories SET deleted_at = NULL, deleted_reason = NULL WHERE id = ?`).run(id);
return true;
});
ipcMain.handle('db:getProjects', () => {
if (!db) return [];
return db.prepare(`
SELECT project, project_path, COUNT(*) as session_count,
MAX(COALESCE(ended_at, started_at)) as last_active
FROM sessions WHERE project IS NOT NULL
GROUP BY project ORDER BY last_active DESC
`).all();
});
ipcMain.handle('db:getStats', () => {
if (!db) return { sessions: 0, memories: 0, memoriesArchived: 0 };
const sessions = db.prepare('SELECT COUNT(*) as c FROM sessions').get()?.c || 0;
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 };
});
ipcMain.handle('db:getUsageStats', () => {
if (!db) return { daily: [], totalTokens: 0, peakDay: null, longestTurn: null };
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)
GROUP BY DATE(timestamp)
ORDER BY day
`).all();
const totalTokens = db.prepare(`
SELECT SUM(COALESCE(input_tokens,0) + COALESCE(output_tokens,0)) as total
FROM messages
`).get()?.total || 0;
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)
GROUP BY DATE(timestamp)
ORDER BY tokens DESC
LIMIT 1
`).get() || null;
const longestTurn = db.prepare(`
SELECT turn_duration_ms, uuid, session_id, timestamp
FROM messages
WHERE turn_duration_ms IS NOT NULL
ORDER BY turn_duration_ms DESC
LIMIT 1
`).get() || null;
return { daily, totalTokens, peakDay, longestTurn };
});
// --- 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: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
offscreen: true,
deviceScaleFactor: 2,
},
});
const isDev = process.argv.includes('--dev');
const url = isDev
? `http://localhost:5173/#/recap-export?${query}`
: `file://${path.join(__dirname, 'dist-renderer', 'index.html')}#/recap-export?${query}`;
await exportWin.loadURL(url);
await new Promise(r => setTimeout(r, 500));
const image = await exportWin.webContents.capturePage({
x: 0, y: 0, width: EXPORT_WIDTH, height: EXPORT_HEIGHT,
});
exportWin.close();
return image;
}
ipcMain.handle('capture:export', async (event, { cardIdx, archetype }) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win) return null;
const query = `card=${cardIdx}&arch=${archetype}`;
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;
});
ipcMain.handle('capture:copy', async (event, { cardIdx, archetype }) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win) return false;
const query = `card=${cardIdx}&arch=${archetype}`;
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; }
});