feat(app): add Electron desktop UI and evolve memory/retrieval layer
Introduce an Electron app with session browser, memory list, and usage views (vanilla JS + Vue scaffolding). On the data layer: add content_type and is_meta to messages for transcript control-plane filtering, introduce FTS5-backed memory recall with safe tokenization, support memory archival via forget() through the renamed --attune runtime, and expose anchors on memory records.
This commit is contained in:
+269
@@ -0,0 +1,269 @@
|
||||
const { app, BrowserWindow, ipcMain } = require('electron');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const fs = require('fs');
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
const DB_PATH = path.join(os.homedir(), '.claude', 'obelisk.sqlite');
|
||||
|
||||
let db;
|
||||
|
||||
function openDb() {
|
||||
if (!fs.existsSync(DB_PATH)) return null;
|
||||
db = new Database(DB_PATH, { readonly: false });
|
||||
db.pragma('journal_mode = WAL');
|
||||
return db;
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
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'));
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
openDb();
|
||||
createWindow();
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
});
|
||||
|
||||
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, 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 };
|
||||
});
|
||||
Reference in New Issue
Block a user