feat(app): embed indexer in Electron with file-watching service and UI refinements

Extract schema DDL into scripts/schema.sql shared between CLI and app.
  Add an in-process chokidar-based indexer-service that watches ~/.claude/projects
  for JSONL changes, debounces, and triggers background rebuilds via a worker
  thread. Rename Usage view to Activity, flesh out MemoryDetail and SubagentDetail
  views, and refine App.vue layout/routing. The main process now starts/stops the
  indexer lifecycle and notifies renderer windows on index updates.
This commit is contained in:
tommy0103
2026-06-13 03:42:01 +08:00
parent b524339d85
commit 4eec6b38c9
29 changed files with 1370 additions and 314 deletions
+11 -1
View File
@@ -123,8 +123,12 @@ same SQLite data.
- `references/schema.md` — full SQLite schema and API reference - `references/schema.md` — full SQLite schema and API reference
- `references/query-patterns.md` — copyable CodeAct recipes for common retrieval tasks - `references/query-patterns.md` — copyable CodeAct recipes for common retrieval tasks
- `references/retrieval-semantics.md` — query design frame for scoped and synthesis retrieval
- `references/pitfalls.md` — scope, FTS, ordering, compact/raw, and field-name traps - `references/pitfalls.md` — scope, FTS, ordering, compact/raw, and field-name traps
The executable SQLite schema lives in `scripts/schema.sql`; `references/schema.md`
is the human/agent explanation of that contract.
The design is progressive disclosure with guardrails: the main skill keeps the The design is progressive disclosure with guardrails: the main skill keeps the
core contract and high-risk pitfalls visible, while longer recipes and the full core contract and high-risk pitfalls visible, while longer recipes and the full
schema stay out of the first prompt until the agent needs them. schema stay out of the first prompt until the agent needs them.
@@ -149,16 +153,22 @@ Full-text search via FTS5 covers message text across every session layer and ran
.claude/skills/obelisk/ .claude/skills/obelisk/
├── SKILL.md # Skill definition + simple API + examples ├── SKILL.md # Skill definition + simple API + examples
├── scripts/ ├── scripts/
── runtime.mjs # Indexer + query runtime (400 lines, zero deps) ── schema.sql # Executable SQLite schema
│ └── runtime.mjs # Indexer + query runtime (zero deps)
└── references/ └── references/
├── schema.md # Full table schema + advanced API reference ├── schema.md # Full table schema + advanced API reference
├── query-patterns.md # Copyable retrieval recipes ├── query-patterns.md # Copyable retrieval recipes
├── retrieval-semantics.md # Query design frame for retrieval semantics
└── pitfalls.md # Scope, FTS, ordering, and compactness traps └── pitfalls.md # Scope, FTS, ordering, and compactness traps
``` ```
## Implementation Notes ## Implementation Notes
The index rebuilds incrementally — only new or modified JSONL files are re-parsed. The index rebuilds incrementally — only new or modified JSONL files are re-parsed.
When the optional app is running, it is the active indexer: it watches Claude
project files, builds in a worker thread, writes `__app_heartbeat__` plus
`__app_last_successful_build__` into `index_state`, and the skill-side lazy
build skips work only while both markers are fresh.
Zero npm dependencies. Uses Node 22's built-in node:sqlite with FTS5. The entire runtime is ~400 lines. Zero npm dependencies. Uses Node 22's built-in node:sqlite with FTS5. The entire runtime is ~400 lines.
+3 -1
View File
@@ -144,7 +144,9 @@ Read-only SQL SELECT/WITH with `?` placeholders. Returns array rows. SQL is an
escape hatch for exact structured joins and aggregations after the helper-first escape hatch for exact structured joins and aggregations after the helper-first
surface is insufficient; it is not the default retrieval entry point. surface is insufficient; it is not the default retrieval entry point.
Before writing non-trivial SQL, read `references/schema.md`. Common safe joins: Before writing non-trivial SQL, read `references/schema.md`. The executable DDL
lives in `scripts/schema.sql`; use the reference for query semantics and the SQL
file for schema-source alignment. Common safe joins:
- `tool_calls` does not have timestamps. Join `messages m ON m.uuid = tc.message_uuid`. - `tool_calls` does not have timestamps. Join `messages m ON m.uuid = tc.message_uuid`.
- `tool_results` does not have timestamps. Join `messages m ON m.uuid = tr.message_uuid`. - `tool_results` does not have timestamps. Join `messages m ON m.uuid = tr.message_uuid`.
+163
View File
@@ -0,0 +1,163 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const DEFAULT_PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
const DEFAULT_DEBOUNCE_MS = 2000;
const DEFAULT_STABILITY_MS = 500;
const DEFAULT_HEARTBEAT_MS = 30000;
const DEFAULT_WATCH_RETRY_MS = 5000;
function createIndexerService({
projectsDir = DEFAULT_PROJECTS_DIR,
debounceMs = DEFAULT_DEBOUNCE_MS,
stabilityMs = DEFAULT_STABILITY_MS,
heartbeatMs = DEFAULT_HEARTBEAT_MS,
watchRetryMs = DEFAULT_WATCH_RETRY_MS,
buildIndex,
writeHeartbeat = () => {},
watchProjects,
chokidar,
timers = {
setTimeout,
clearTimeout,
setInterval,
clearInterval,
},
logger = console,
} = {}) {
if (typeof buildIndex !== 'function') throw new Error('createIndexerService() requires buildIndex');
const watch = watchProjects || ((onChange) => {
if (!fs.existsSync(projectsDir)) return null;
const watcher = (chokidar || require('chokidar')).watch(projectsDir, {
cwd: projectsDir,
ignoreInitial: true,
awaitWriteFinish: {
stabilityThreshold: Math.max(stabilityMs, 500),
pollInterval: 100,
},
ignored: (targetPath, stats) => {
if (stats?.isDirectory()) return false;
if (!stats) return false;
return !String(targetPath).endsWith('.jsonl') && !String(targetPath).endsWith('.json');
},
});
const onFileChange = (filename) => {
const name = filename ? String(filename) : '';
if (!name || name.endsWith('.jsonl') || name.endsWith('.json')) onChange(name);
};
return watcher
.on('add', onFileChange)
.on('change', onFileChange)
.on('unlink', onFileChange)
.on('error', (error) => {
logger.warn?.(`Obelisk watcher failed: ${error.message}`);
});
});
let buildTimer = null;
let stabilityTimer = null;
let heartbeatTimer = null;
let watchRetryTimer = null;
let watcher = null;
let stopped = false;
let running = false;
let pending = false;
let lastReason = null;
let idlePromise = Promise.resolve();
const runBuildNow = (reason = 'manual') => {
if (stopped) return idlePromise;
if (running) {
pending = true;
return idlePromise;
}
running = true;
pending = false;
idlePromise = (async () => {
await buildIndex({ reason });
writeHeartbeat();
})()
.catch((error) => {
logger.warn?.(`Obelisk index build failed: ${error.message}`);
})
.finally(() => {
running = false;
if (pending && !stopped) {
pending = false;
runBuildNow('pending');
}
});
return idlePromise;
};
const scheduleBuild = (reason = 'change') => {
if (stopped) return;
lastReason = reason;
if (running) pending = true;
if (buildTimer) timers.clearTimeout(buildTimer);
if (stabilityTimer) timers.clearTimeout(stabilityTimer);
buildTimer = timers.setTimeout(() => {
buildTimer = null;
if (stabilityMs <= 0) {
runBuildNow(lastReason || reason);
return;
}
stabilityTimer = timers.setTimeout(() => {
stabilityTimer = null;
runBuildNow(lastReason || reason);
}, stabilityMs);
}, debounceMs);
};
const startWatching = () => {
if (stopped || watcher) return;
watcher = watch(() => scheduleBuild('watch'));
if (!watcher) {
watchRetryTimer = timers.setTimeout(() => {
watchRetryTimer = null;
startWatching();
}, watchRetryMs);
}
};
const start = ({ buildOnStart = true } = {}) => {
stopped = false;
if (buildOnStart) scheduleBuild('startup');
startWatching();
if (typeof timers.setInterval === 'function') {
heartbeatTimer = timers.setInterval(() => {
try {
writeHeartbeat();
} catch (error) {
logger.warn?.(`Obelisk heartbeat failed: ${error.message}`);
}
}, heartbeatMs);
}
};
const stop = () => {
stopped = true;
pending = false;
if (buildTimer) timers.clearTimeout(buildTimer);
buildTimer = null;
if (stabilityTimer) timers.clearTimeout(stabilityTimer);
stabilityTimer = null;
if (watchRetryTimer) timers.clearTimeout(watchRetryTimer);
watchRetryTimer = null;
if (heartbeatTimer && typeof timers.clearInterval === 'function') timers.clearInterval(heartbeatTimer);
heartbeatTimer = null;
if (watcher?.close) watcher.close();
watcher = null;
};
return {
start,
stop,
scheduleBuild,
runBuildNow,
idle: () => idlePromise,
};
}
module.exports = { createIndexerService };
+59
View File
@@ -0,0 +1,59 @@
const path = require('path');
const { Worker } = require('worker_threads');
function createWorkerBuildIndex({
workerPath = path.join(__dirname, 'indexer-worker.js'),
WorkerImpl = Worker,
} = {}) {
let worker = null;
let nextId = 1;
const pending = new Map();
const rejectPending = (error) => {
for (const { reject } of pending.values()) reject(error);
pending.clear();
};
const ensureWorker = () => {
if (worker) return worker;
worker = new WorkerImpl(workerPath);
worker.on('message', (message) => {
const current = pending.get(message.id);
if (!current) return;
pending.delete(message.id);
if (message.error) {
const error = new Error(message.error.message);
error.stack = message.error.stack;
current.reject(error);
} else {
current.resolve(message.result);
}
});
worker.on('error', (error) => {
rejectPending(error);
worker = null;
});
worker.on('exit', (code) => {
if (pending.size) rejectPending(new Error(`Indexer worker exited with code ${code}`));
worker = null;
});
return worker;
};
const buildIndex = (args = {}) => new Promise((resolve, reject) => {
const id = nextId++;
pending.set(id, { resolve, reject });
ensureWorker().postMessage({ id, args });
});
const stop = () => {
const current = worker;
worker = null;
if (current?.terminate) current.terminate();
rejectPending(new Error('Indexer worker stopped'));
};
return { buildIndex, stop };
}
module.exports = { createWorkerBuildIndex };
+17
View File
@@ -0,0 +1,17 @@
const { parentPort } = require('worker_threads');
const { buildIndex } = require('./indexer');
parentPort.on('message', ({ id, args }) => {
try {
const result = buildIndex(args || {});
parentPort.postMessage({ id, result });
} catch (error) {
parentPort.postMessage({
id,
error: {
message: error.message,
stack: error.stack,
},
});
}
});
+439
View File
@@ -0,0 +1,439 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const Database = require('better-sqlite3');
const TEXT_LIMIT = 10000;
const DEFAULT_CLAUDE_DIR = path.join(os.homedir(), '.claude');
const DEFAULT_DB_PATH = path.join(DEFAULT_CLAUDE_DIR, 'obelisk.sqlite');
const DEFAULT_PROJECTS_DIR = path.join(DEFAULT_CLAUDE_DIR, 'projects');
const DEFAULT_HISTORY_PATH = path.join(DEFAULT_CLAUDE_DIR, 'history.jsonl');
function resolveSchemaPath() {
const candidates = [
path.join(__dirname, 'schema.sql'),
path.join(__dirname, '..', 'scripts', 'schema.sql'),
process.resourcesPath ? path.join(process.resourcesPath, 'scripts', 'schema.sql') : null,
].filter(Boolean);
const found = candidates.find(p => fs.existsSync(p));
if (!found) throw new Error('Obelisk schema.sql not found');
return found;
}
function openIndexDb({ dbPath = DEFAULT_DB_PATH, schemaPath = resolveSchemaPath(), DatabaseImpl = Database } = {}) {
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
const db = new DatabaseImpl(dbPath);
db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL');
db.exec(fs.readFileSync(schemaPath, 'utf8'));
migrateDb(db);
return db;
}
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 migrateDb(db) {
ensureColumn(db, 'messages', 'content_type', 'TEXT');
ensureColumn(db, 'messages', 'is_meta', 'INTEGER DEFAULT 0');
ensureColumn(db, 'memories', 'anchors', 'TEXT');
ensureColumn(db, 'memories', 'deleted_at', 'TEXT');
ensureColumn(db, 'memories', 'deleted_reason', 'TEXT');
}
function trunc(s) {
return typeof s === 'string' && s.length > TEXT_LIMIT ? s.slice(0, TEXT_LIMIT) : s;
}
function truncJson(obj, limit = TEXT_LIMIT) {
if (obj === null || obj === undefined) return null;
const walk = (v) => {
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 = {};
for (const [k, val] of Object.entries(v)) out[k] = walk(val);
return out;
}
return v;
};
return JSON.stringify(walk(obj));
}
function extractText(content) {
if (typeof content === 'string') return trunc(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.length ? trunc(parts.join('\n')) : null;
}
function extractContentType(content) {
if (typeof content === 'string') return 'text';
if (!Array.isArray(content) || !content.length) return 'unknown';
const types = new Set();
let sawUnknown = false;
for (const b of content) {
if (!b || typeof b !== 'object') { sawUnknown = true; continue; }
if (b.type === 'text') types.add('text');
else if (b.type === 'thinking') types.add('thinking');
else if (b.type === 'tool_use') types.add('tool_use');
else if (b.type === 'tool_result') types.add('tool_result');
else sawUnknown = true;
}
return !sawUnknown && types.size === 1 ? [...types][0] : 'unknown';
}
const COMMAND_ENVELOPE_RE = /^\s*(<command-name>[^<]+<\/command-name>|<task-notification>|<local-command-caveat>|<local-command-stdout>)/;
function extractMessageIsMeta(record, text = extractText(record?.message?.content)) {
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) {
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 readLines(filePath, callback) {
const data = fs.readFileSync(filePath, 'utf8');
const lines = data.split('\n');
for (const line of lines) {
if (line && callback(line) === false) return;
}
}
function legacyProjectPathFromSlug(project) {
if (!project) return null;
return '/' + project.replace(/-/g, '/').replace(/^\//, '');
}
function normalizeObservedCwd(cwd) {
if (typeof cwd !== 'string' || !cwd.trim() || !path.isAbsolute(cwd)) return null;
return path.normalize(cwd);
}
function inferProjectPath(project, observedCwds = []) {
const byPath = new Map();
for (const cwd of observedCwds) {
const normalized = normalizeObservedCwd(cwd);
if (!normalized) continue;
const current = byPath.get(normalized) || { path: normalized, count: 0, first: byPath.size };
current.count++;
byPath.set(normalized, current);
}
const best = [...byPath.values()].sort((a, b) => b.count - a.count || a.first - b.first)[0];
return best?.path || legacyProjectPathFromSlug(project);
}
function discoverJsonlFiles({ projectsDir = DEFAULT_PROJECTS_DIR } = {}) {
const files = [];
if (!fs.existsSync(projectsDir)) return files;
let projects;
try { projects = fs.readdirSync(projectsDir); } catch { return files; }
for (const proj of projects) {
const projPath = path.join(projectsDir, proj);
if (!isDir(projPath)) continue;
let entries;
try { entries = fs.readdirSync(projPath); } catch { continue; }
for (const f of entries) {
if (f.endsWith('.jsonl'))
files.push({ path: path.join(projPath, f), sessionId: f.slice(0, -6), project: proj, isSubagent: false });
}
for (const sd of entries) {
const saDir = path.join(projPath, sd, 'subagents');
if (!isDir(saDir)) continue;
let saEntries;
try { saEntries = fs.readdirSync(saDir); } catch { continue; }
for (const sf of saEntries) {
if (sf.endsWith('.jsonl'))
files.push({ path: path.join(saDir, sf), sessionId: sd, project: proj, isSubagent: true, agentId: sf.slice(0, -6) });
}
const wfRoot = path.join(saDir, 'workflows');
if (!isDir(wfRoot)) continue;
let wfDirs;
try { wfDirs = fs.readdirSync(wfRoot); } catch { continue; }
for (const wfDir of wfDirs) {
const wfPath = path.join(wfRoot, wfDir);
if (!isDir(wfPath)) continue;
let wfEntries;
try { wfEntries = fs.readdirSync(wfPath); } catch { continue; }
for (const wf of wfEntries) {
if (wf.endsWith('.jsonl'))
files.push({ path: path.join(wfPath, wf), sessionId: sd, project: proj, isSubagent: true, agentId: wf.slice(0, -6), workflowRunId: wfDir });
}
}
}
}
return files;
}
function needsReindex(db, fp) {
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, mtime: mt };
return mt > row.mtime ? { needed: true, skip: row.lines_processed, mtime: mt } : { needed: false, skip: 0, mtime: mt };
}
function indexJsonl(db, fi) {
const { needed, skip, mtime } = needsReindex(db, fi.path);
if (!needed) return;
const ins = {
ses: db.prepare('INSERT OR REPLACE INTO sessions (id,title,project,project_path,started_at,ended_at,git_branch,version,message_count,jsonl_path) VALUES (?,?,?,?,?,?,?,?,?,?)'),
msg: db.prepare('INSERT OR REPLACE INTO messages (uuid,session_id,type,parent_uuid,timestamp,role,text,content_type,is_meta,model,is_sidechain,agent_id,input_tokens,output_tokens,cwd,skill) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'),
tc: db.prepare('INSERT OR REPLACE INTO tool_calls (id,message_uuid,session_id,name,input_json,file_path) VALUES (?,?,?,?,?,?)'),
tr: db.prepare('INSERT OR REPLACE INTO tool_results (tool_use_id,message_uuid,session_id,content,file_path,is_error) VALUES (?,?,?,?,?,?)'),
sum: db.prepare('INSERT OR REPLACE INTO summaries (id,session_id,timestamp,source,content) VALUES (?,?,?,?,?)'),
idx: db.prepare('INSERT OR REPLACE INTO index_state (jsonl_path,mtime,lines_processed) VALUES (?,?,?)'),
};
const existing = !fi.isSubagent ? db.prepare('SELECT * FROM sessions WHERE id = ?').get(fi.sessionId) : null;
const sm = {
started_at: existing?.started_at || null,
ended_at: existing?.ended_at || null,
git_branch: existing?.git_branch || null,
version: existing?.version || null,
title: existing?.title || null,
n: existing?.message_count || 0,
cwds: [],
};
let lineNum = 0;
readLines(fi.path, (line) => {
lineNum++;
if (lineNum <= skip) return;
let obj;
try { obj = JSON.parse(line); } catch { return; }
const sid = fi.sessionId;
const ts = obj.timestamp || null;
if (obj.type === 'ai-title' && obj.aiTitle) { sm.title = obj.aiTitle; return; }
if (obj.type === 'system' && obj.subtype === 'away_summary' && obj.content) {
ins.sum.run(obj.uuid || `${sid}-away-${ts}`, sid, ts, 'away_summary', obj.content);
return;
}
if (obj.type === 'system' && obj.subtype === 'turn_duration' && obj.parentUuid && obj.durationMs) {
db.prepare('UPDATE messages SET turn_duration_ms=? WHERE uuid=?').run(obj.durationMs, obj.parentUuid);
return;
}
if (obj.type !== 'user' && obj.type !== 'assistant') return;
if (ts && (!sm.started_at || ts < sm.started_at)) sm.started_at = ts;
if (ts && (!sm.ended_at || ts > sm.ended_at)) sm.ended_at = ts;
if (obj.gitBranch) sm.git_branch = obj.gitBranch;
if (obj.version) sm.version = obj.version;
sm.n++;
if (!fi.isSubagent && obj.cwd) sm.cwds.push(obj.cwd);
const msg = obj.message || {};
const text = extractText(msg.content);
const contentType = extractContentType(msg.content);
const isMeta = extractMessageIsMeta(obj, text);
const usage = msg.usage || {};
const aid = fi.isSubagent ? fi.agentId : (obj.agentId || null);
if (obj.uuid) {
ins.msg.run(obj.uuid, sid, obj.type, obj.parentUuid || null, ts,
msg.role || obj.type, text, contentType, isMeta, msg.model || null,
obj.isSidechain ? 1 : 0, aid, usage.input_tokens || null, usage.output_tokens || null,
obj.cwd || null, obj.attributionSkill || null);
}
if (obj.type === 'assistant' && Array.isArray(msg.content)) {
for (const b of msg.content) {
if (b.type === 'tool_use' && b.id)
ins.tc.run(b.id, obj.uuid, sid, b.name, truncJson(b.input || {}), filePath(b.name, b.input));
}
}
if (obj.type === 'user' && Array.isArray(msg.content)) {
for (const b of msg.content) {
if (b.type !== 'tool_result' || !b.tool_use_id) continue;
const rt = typeof b.content === 'string' ? b.content
: Array.isArray(b.content) ? b.content.map(c => c.text || '').join('\n') : '';
ins.tr.run(b.tool_use_id, obj.uuid, sid, trunc(rt), obj.toolUseResult?.filePath || null, b.is_error ? 1 : 0);
}
}
});
if (!fi.isSubagent) {
const pp = inferProjectPath(fi.project, sm.cwds);
ins.ses.run(fi.sessionId, sm.title, fi.project, pp, sm.started_at, sm.ended_at, sm.git_branch, sm.version, sm.n, fi.path);
}
ins.idx.run(fi.path, mtime, lineNum);
}
function refreshSessionProjectPaths(db) {
const sessions = db.prepare('SELECT id, project FROM sessions').all();
const cwdStmt = db.prepare(`
SELECT cwd FROM messages
WHERE session_id = ? AND cwd IS NOT NULL AND cwd != ''
ORDER BY timestamp IS NULL, timestamp
`);
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 projectPath = inferProjectPath(session.project, cwds);
if (projectPath) update.run(projectPath, session.id);
}
}
function indexSubagentMeta(db, fi) {
if (!fi.isSubagent) return;
const mp = fi.path.replace('.jsonl', '.meta.json');
if (!fs.existsSync(mp)) return;
try {
const meta = JSON.parse(fs.readFileSync(mp, 'utf8'));
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);
const ts = db.prepare('SELECT MIN(timestamp) as t0, MAX(timestamp) as t1 FROM messages WHERE agent_id=?').get(fi.agentId);
const dur = ts?.t0 && ts?.t1 ? new Date(ts.t1).getTime() - new Date(ts.t0).getTime() : null;
if (fi.workflowRunId) {
db.prepare('INSERT OR REPLACE INTO workflow_agents (agent_id,run_id,session_id,agent_type,description) VALUES(?,?,?,?,?)').run(fi.agentId, fi.workflowRunId, fi.sessionId, meta.agentType||null, meta.description||null);
} else {
db.prepare('INSERT OR REPLACE INTO subagents VALUES(?,?,?,?,?,?,?)').run(fi.agentId, fi.sessionId, meta.toolUseId||null, meta.agentType||null, meta.description||null, dur, tok?.t||0);
}
} catch (error) {
console.warn(`Warning: failed to read subagent meta ${mp}: ${error.message}`);
}
}
function indexWorkflows(db, { projectsDir = DEFAULT_PROJECTS_DIR } = {}) {
if (!fs.existsSync(projectsDir)) return;
let projects;
try { projects = fs.readdirSync(projectsDir); } catch { return; }
for (const proj of projects) {
const pp = path.join(projectsDir, proj);
if (!isDir(pp)) continue;
let entries;
try { entries = fs.readdirSync(pp); } catch { continue; }
for (const sd of entries) {
const wd = path.join(pp, sd, 'workflows');
if (!isDir(wd)) continue;
let wfFiles;
try { wfFiles = fs.readdirSync(wd); } catch { continue; }
for (const f of wfFiles) {
if (!f.endsWith('.json')) continue;
try {
const wf = JSON.parse(fs.readFileSync(path.join(wd, f), 'utf8'));
if (!wf.runId) continue;
const ac = db.prepare('SELECT COUNT(*) as c FROM workflow_agents WHERE run_id=?').get(wf.runId);
db.prepare('INSERT OR REPLACE INTO workflows (run_id,session_id,task_id,script,result_json,timestamp,agent_count,duration_ms,total_tokens,status,workflow_name) VALUES(?,?,?,?,?,?,?,?,?,?,?)').run(
wf.runId, sd, wf.taskId||null, wf.script||null,
wf.result ? JSON.stringify(wf.result) : null, wf.timestamp||null, ac?.c||0,
wf.durationMs||null, wf.totalTokens||null, wf.status||null, wf.workflowName||null);
const progress = wf.workflowProgress || [];
for (const item of progress) {
if (item.type !== 'workflow_agent' || !item.agentId) continue;
db.prepare('UPDATE workflow_agents SET phase=?, label=?, model=?, state=?, duration_ms=?, tokens=?, tool_calls=? WHERE agent_id=?').run(
item.phaseTitle||null, item.label||null, item.model||null, item.state||null,
item.durationMs||null, item.tokens||null, item.toolCalls||null, 'agent-' + item.agentId);
}
} catch (error) {
console.warn(`Warning: failed to index workflow ${f}: ${error.message}`);
}
}
}
}
}
function indexHistory(db, { historyPath = DEFAULT_HISTORY_PATH } = {}) {
if (!fs.existsSync(historyPath)) return;
readLines(historyPath, (line) => {
try {
const o = JSON.parse(line);
if (o.sessionId && o.title) db.prepare('UPDATE sessions SET title=? WHERE id=? AND title IS NULL').run(o.title, o.sessionId);
} catch (error) {
console.warn(`Warning: malformed history line: ${error.message}`);
}
});
}
function rebuildFts(db) {
db.exec("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')");
db.exec("INSERT INTO memories_fts(memories_fts) VALUES('rebuild')");
}
function writeIndexMarker(db, key, value = Date.now()) {
db.prepare('INSERT OR REPLACE INTO index_state (jsonl_path, mtime, lines_processed) VALUES (?, ?, 0)').run(key, value);
}
function writeHeartbeat({ dbPath = DEFAULT_DB_PATH, DatabaseImpl = Database } = {}) {
if (!fs.existsSync(dbPath)) return;
const db = new DatabaseImpl(dbPath);
try {
writeIndexMarker(db, '__app_heartbeat__');
} finally {
db.close();
}
}
function buildIndex({
claudeDir = DEFAULT_CLAUDE_DIR,
projectsDir = path.join(claudeDir, 'projects'),
historyPath = path.join(claudeDir, 'history.jsonl'),
dbPath = path.join(claudeDir, 'obelisk.sqlite'),
schemaPath = resolveSchemaPath(),
DatabaseImpl = Database,
force = false,
} = {}) {
const db = openIndexDb({ dbPath, schemaPath, DatabaseImpl });
const files = discoverJsonlFiles({ projectsDir });
const latestSourceMtime = files.reduce((latest, file) => {
try {
return Math.max(latest, fs.statSync(file.path).mtimeMs);
} catch {
return latest;
}
}, 0);
try {
if (force) db.prepare("DELETE FROM index_state WHERE jsonl_path NOT LIKE '__%'").run();
for (const file of files) {
db.exec('BEGIN');
try {
indexJsonl(db, file);
indexSubagentMeta(db, file);
db.exec('COMMIT');
} catch (error) {
db.exec('ROLLBACK');
console.warn(`Warning: failed to index ${file.path}: ${error.message}`);
}
}
db.exec('BEGIN');
try {
indexWorkflows(db, { projectsDir });
refreshSessionProjectPaths(db);
indexHistory(db, { historyPath });
rebuildFts(db);
writeIndexMarker(db, '__last_build__');
writeIndexMarker(db, '__app_heartbeat__');
writeIndexMarker(db, '__app_last_successful_build__');
writeIndexMarker(db, '__indexer_owner_app__');
if (latestSourceMtime) writeIndexMarker(db, '__last_source_mtime__', latestSourceMtime);
db.exec('COMMIT');
} catch (error) {
db.exec('ROLLBACK');
throw error;
}
return { files: files.length, latestSourceMtime };
} finally {
db.close();
}
}
module.exports = {
buildIndex,
writeHeartbeat,
openIndexDb,
discoverJsonlFiles,
inferProjectPath,
};
+34 -1
View File
@@ -3,18 +3,44 @@ const path = require('path');
const os = require('os'); const os = require('os');
const fs = require('fs'); const fs = require('fs');
const Database = require('better-sqlite3'); 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'); const DB_PATH = path.join(os.homedir(), '.claude', 'obelisk.sqlite');
let db; let db;
let indexerService;
let indexerWorker;
function openDb() { function openDb() {
if (!fs.existsSync(DB_PATH)) return null; if (!fs.existsSync(DB_PATH)) return null;
if (db) db.close();
db = new Database(DB_PATH, { readonly: false }); db = new Database(DB_PATH, { readonly: false });
db.pragma('journal_mode = WAL'); db.pragma('journal_mode = WAL');
return db; 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() { function createWindow() {
const win = new BrowserWindow({ const win = new BrowserWindow({
width: 1200, width: 1200,
@@ -41,14 +67,21 @@ function createWindow() {
} }
app.whenReady().then(() => { app.whenReady().then(() => {
indexerWorker = createWorkerBuildIndex();
openDb(); openDb();
createWindow(); createWindow();
startIndexerService().runBuildNow('startup');
app.on('activate', () => { app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow(); if (BrowserWindow.getAllWindows().length === 0) createWindow();
}); });
}); });
app.on('before-quit', () => {
if (indexerService) indexerService.stop();
if (indexerWorker) indexerWorker.stop();
});
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
if (db) db.close(); if (db) db.close();
if (process.platform !== 'darwin') app.quit(); if (process.platform !== 'darwin') app.quit();
@@ -137,7 +170,7 @@ ipcMain.handle('db:getSessionSummaries', (_, sessionId) => {
ipcMain.handle('db:getMemories', () => { ipcMain.handle('db:getMemories', () => {
if (!db) return []; if (!db) return [];
return db.prepare(` return db.prepare(`
SELECT id, session_id, project, message_start, message_end, path, summary, created_at, deleted_at, deleted_reason 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 FROM memories ORDER BY created_at DESC
`).all(); `).all();
}); });
+30 -1
View File
@@ -8,7 +8,8 @@
"name": "obelisk", "name": "obelisk",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"better-sqlite3": "^11.0.0" "better-sqlite3": "^11.0.0",
"chokidar": "^4.0.3"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.0.0", "@vitejs/plugin-vue": "^5.0.0",
@@ -2578,6 +2579,21 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "url": "https://github.com/chalk/chalk?sponsor=1"
} }
}, },
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/chownr": { "node_modules/chownr": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
@@ -5681,6 +5697,19 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/require-directory": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+15 -2
View File
@@ -20,15 +20,28 @@
"files": [ "files": [
"main.js", "main.js",
"preload.js", "preload.js",
"indexer.js",
"indexer-service.js",
"indexer-worker.js",
"indexer-worker-client.js",
"dist-renderer/**/*", "dist-renderer/**/*",
"node_modules/better-sqlite3/**/*" "node_modules/better-sqlite3/**/*",
"node_modules/chokidar/**/*",
"node_modules/readdirp/**/*"
],
"extraResources": [
{
"from": "../scripts/schema.sql",
"to": "scripts/schema.sql"
}
], ],
"asarUnpack": [ "asarUnpack": [
"node_modules/better-sqlite3/**/*" "node_modules/better-sqlite3/**/*"
] ]
}, },
"dependencies": { "dependencies": {
"better-sqlite3": "^11.0.0" "better-sqlite3": "^11.0.0",
"chokidar": "^4.0.3"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.0.0", "@vitejs/plugin-vue": "^5.0.0",
+5
View File
@@ -19,4 +19,9 @@ contextBridge.exposeInMainWorld('obelisk', {
getProjects: () => ipcRenderer.invoke('db:getProjects'), getProjects: () => ipcRenderer.invoke('db:getProjects'),
getStats: () => ipcRenderer.invoke('db:getStats'), getStats: () => ipcRenderer.invoke('db:getStats'),
getUsageStats: () => ipcRenderer.invoke('db:getUsageStats'), getUsageStats: () => ipcRenderer.invoke('db:getUsageStats'),
onIndexUpdated: (callback) => {
const listener = () => callback();
ipcRenderer.on('obelisk:index-updated', listener);
return () => ipcRenderer.removeListener('obelisk:index-updated', listener);
},
}); });
+162 -206
View File
@@ -25,16 +25,23 @@ const archivedCount = computed(() => state.memories.filter(m => m.archived).leng
const totalMemoryCount = computed(() => state.memories.length); const totalMemoryCount = computed(() => state.memories.length);
const sessionCount = computed(() => state.sessions.length); const sessionCount = computed(() => state.sessions.length);
const currentRouteType = computed(() => {
const name = route.name;
if (name === 'SessionList' || name === 'SessionDetail' || name === 'SubagentDetail') return 'sessions';
if (name === 'Activity') return 'activity';
return 'memory';
});
const sidebarProjects = computed(() => { const sidebarProjects = computed(() => {
const items = state.route === 'sessions' ? state.sessions : state.memories; const items = currentRouteType.value === 'sessions' ? state.sessions : state.memories;
const filtered = items.filter(item => { const filtered = items.filter(item => {
if (state.route === 'sessions') return true; if (currentRouteType.value === 'sessions') return true;
return state.view === 'archived' ? item.archived : !item.archived; return state.view === 'archived' ? item.archived : !item.archived;
}); });
let projects = [...new Set(filtered.map(item => item.project).filter(Boolean))]; let projects = [...new Set(filtered.map(item => item.project).filter(Boolean))];
if (state.projectSearch) { if (state.projectSearch) {
const q = state.projectSearch.toLowerCase(); const q = state.projectSearch.toLowerCase();
projects = projects.filter(p => p.toLowerCase().includes(q)); projects = projects.filter(p => formatProjectLabel(p).toLowerCase().includes(q));
} }
projects.sort((a, b) => formatProjectLabel(a).localeCompare(formatProjectLabel(b))); projects.sort((a, b) => formatProjectLabel(a).localeCompare(formatProjectLabel(b)));
@@ -51,6 +58,11 @@ const sidebarProjects = computed(() => {
})); }));
}); });
const totalProjectCount = computed(() => {
const items = currentRouteType.value === 'sessions' ? state.sessions : state.memories;
return new Set(items.map(i => i.project).filter(Boolean)).size;
});
// --- Toolbar visibility --- // --- Toolbar visibility ---
const showToolbar = computed(() => { const showToolbar = computed(() => {
@@ -67,8 +79,8 @@ const showSearchMsgsToggle = computed(() => {
const windowTitle = computed(() => { const windowTitle = computed(() => {
const appName = 'Obelisk'; const appName = 'Obelisk';
let scopeText = ''; let scopeText = '';
if (route.name === 'Usage') { if (route.name === 'Activity') {
scopeText = 'Usage'; scopeText = 'Activity';
} else if (route.name?.startsWith('Session')) { } else if (route.name?.startsWith('Session')) {
if (route.name === 'SessionDetail' || route.name === 'SubagentDetail') { if (route.name === 'SessionDetail' || route.name === 'SubagentDetail') {
const s = state.sessions.find(x => x.id === route.params.id); const s = state.sessions.find(x => x.id === route.params.id);
@@ -100,8 +112,8 @@ function handleSidebarRoute(routeName) {
setRoute(routeName); setRoute(routeName);
if (routeName === 'sessions') { if (routeName === 'sessions') {
router.push('/sessions'); router.push('/sessions');
} else if (routeName === 'usage') { } else if (routeName === 'activity') {
router.push('/usage'); router.push('/activity');
} else { } else {
router.push('/memory'); router.push('/memory');
} }
@@ -112,6 +124,10 @@ function handleSidebarView(view) {
router.push('/memory'); router.push('/memory');
} }
function handleClearProject() {
setProject('all');
}
function handleSidebarProject(slug) { function handleSidebarProject(slug) {
setProject(slug); setProject(slug);
// Stay on current list route // Stay on current list route
@@ -147,94 +163,125 @@ const keepAliveIncludes = ['SessionDetail'];
</script> </script>
<template> <template>
<div class="app-shell"> <div class="app">
<!-- Titlebar (macOS traffic-light region) --> <div class="titlebar">
<div class="titlebar" :class="{ mac: IS_MAC }"> <div class="titlebar-text" id="titlebar-text">
<div class="titlebar-drag"></div>
<div id="titlebar-text" class="titlebar-text">
<span class="app-name">{{ windowTitle.appName }}</span> <span class="app-name">{{ windowTitle.appName }}</span>
<span class="sep"></span> <span class="sep"></span>
<span class="scope">{{ windowTitle.scopeText }}</span> <span class="scope">{{ windowTitle.scopeText }}</span>
</div> </div>
</div> </div>
<!-- Columns: sidebar + main -->
<div class="columns"> <div class="columns">
<!-- Sidebar -->
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-brand"> <div class="sidebar-brand">
<svg viewBox="0 0 20 20" fill="none"> <svg viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<circle cx="10" cy="10" r="8" stroke="currentColor" stroke-width="1.2" opacity="0.6"/> <defs>
<path d="M10 4 L10 16" stroke="url(#obelisk-grad)" stroke-width="2.5" stroke-linecap="round"/> <radialGradient id="icon-aurora" cx="50%" cy="62%" r="55%">
<defs><linearGradient id="obelisk-grad" x1="10" y1="4" x2="10" y2="16" gradientUnits="userSpaceOnUse"><stop stop-color="#a78bfa"/><stop offset="1" stop-color="#6366f1"/></linearGradient></defs> <stop offset="0%" stop-color="#ec4899" stop-opacity="0.8"/>
<stop offset="45%" stop-color="#a855f7" stop-opacity="0.7"/>
<stop offset="100%" stop-color="#6366f1" stop-opacity="0"/>
</radialGradient>
<linearGradient id="icon-stone-lit" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#cbd5e1"/>
<stop offset="100%" stop-color="#475569"/>
</linearGradient>
</defs>
<ellipse cx="20" cy="22" rx="15" ry="11" fill="url(#icon-aurora)"/>
<ellipse cx="20" cy="21" rx="9" ry="7" fill="url(#icon-aurora)" opacity="0.7"/>
<circle cx="8" cy="13" r="0.7" fill="#fff" opacity="0.9"/>
<circle cx="32" cy="11" r="0.9" fill="#fff" opacity="0.95"/>
<circle cx="34" cy="22" r="0.5" fill="#fff" opacity="0.7"/>
<polygon points="20,7 16.5,12 23.5,12" fill="url(#icon-stone-lit)"/>
<polygon points="20,12 16.5,12 17.5,33 20,33" fill="url(#icon-stone-lit)"/>
<polygon points="20,12 23.5,12 22.5,33 20,33" fill="#1e293b"/>
<rect x="15.5" y="33" width="9" height="1.6" rx="0.3" fill="#0f172a"/>
</svg> </svg>
<span class="name">Obelisk</span> <span class="name">Obelisk</span>
</div> </div>
<!-- Navigation section -->
<div class="sidebar-section"> <div class="sidebar-section">
<div class="sidebar-section-title"><span>Library</span></div>
<button <button
class="sidebar-item" class="sidebar-item"
:class="{ active: state.route === 'sessions' && state.projectFilter === 'all' }" :class="{ active: state.route === 'sessions' && state.projectFilter === 'all' }"
@click="handleSidebarRoute('sessions')" @click="handleSidebarRoute('sessions')"
> >
<span class="icon"> <svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M5 1v4M11 1v4"/></svg> <path d="M3 4h10v8a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4z" stroke-linejoin="round"/>
</span> <path d="M5.5 7h5M5.5 9.5h3" stroke-linecap="round"/>
</svg>
<span class="label">Sessions</span> <span class="label">Sessions</span>
<span class="badge">{{ sessionCount }}</span> <span class="badge">{{ sessionCount }}</span>
</button> </button>
<button
class="sidebar-item"
:class="{ active: state.route === 'memory' && state.view === 'active' && state.projectFilter === 'all' }"
@click="handleSidebarView('active')"
>
<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="2.5" y="2.5" width="11" height="11" rx="2"/>
<path d="M5 8h6M5 5.5h6M5 10.5h4" stroke-linecap="round"/>
</svg>
<span class="label">Memory</span>
<span class="badge">{{ totalMemoryCount }}</span>
</button>
<button <button
class="sidebar-item sub" class="sidebar-item sub"
:class="{ active: state.route === 'memory' && state.view === 'active' && state.projectFilter === 'all' }" :class="{ active: state.route === 'memory' && state.view === 'active' && state.projectFilter === 'all' }"
@click="handleSidebarView('active')" @click="handleSidebarView('active')"
> >
<span class="icon"> <svg class="icon" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><circle cx="8" cy="8" r="5.5"/><path d="M8 5v3l2 1.5"/></svg> <circle cx="6" cy="6" r="2" fill="currentColor"/>
</span> </svg>
<span class="label">Active</span> <span class="label">Active</span>
<span class="badge">{{ activeCount }}</span> <span class="badge">{{ activeCount }}</span>
</button> </button>
<button <button
class="sidebar-item sub" class="sidebar-item sub"
:class="{ active: state.route === 'memory' && state.view === 'archived' && state.projectFilter === 'all' }" :class="{ active: state.route === 'memory' && state.view === 'archived' && state.projectFilter === 'all' }"
@click="handleSidebarView('archived')" @click="handleSidebarView('archived')"
> >
<span class="icon"> <svg class="icon" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><path d="M2.5 5h11v7.5a1.5 1.5 0 0 1-1.5 1.5H4a1.5 1.5 0 0 1-1.5-1.5V5z"/><path d="M1.5 3.5h13v2h-13z"/><path d="M6 8h4"/></svg> <circle cx="6" cy="6" r="2"/>
</span> </svg>
<span class="label">Archived</span> <span class="label">Archived</span>
<span class="badge">{{ archivedCount }}</span> <span class="badge">{{ archivedCount }}</span>
</button> </button>
</div>
<div class="sidebar-section">
<div class="sidebar-section-title"><span>Stats</span></div>
<button <button
class="sidebar-item" class="sidebar-item"
:class="{ active: state.route === 'usage' }" :class="{ active: route.name === 'Activity' }"
@click="handleSidebarRoute('usage')" @click="handleSidebarRoute('activity')"
> >
<span class="icon"> <svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><path d="M2 13h12M4 9v4M7 6v7M10 8v5M13 4v9"/></svg> <rect x="2" y="10" width="2.5" height="4"/>
</span> <rect x="6" y="6" width="2.5" height="8"/>
<span class="label">Usage</span> <rect x="10" y="3" width="2.5" height="11"/>
</svg>
<span class="label">Activity</span>
</button> </button>
</div> </div>
<!-- Projects section -->
<div class="sidebar-section projects"> <div class="sidebar-section projects">
<div class="sidebar-section-title"> <div class="sidebar-section-title"><span>Projects</span></div>
<span>Projects</span> <div class="sidebar-search" v-if="totalProjectCount >= 6">
</div> <svg class="sidebar-search-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6">
<div class="sidebar-search"> <circle cx="7" cy="7" r="5"/>
<path d="M11 11l3 3" stroke-linecap="round"/>
</svg>
<input <input
type="text" type="text"
placeholder="Filter..." placeholder="Filter projects…"
autocomplete="off"
:value="state.projectSearch" :value="state.projectSearch"
@input="handleProjectSearch" @input="handleProjectSearch"
/> />
</div> </div>
<div id="sidebar-projects" class="sidebar-projects-list"> <div class="sidebar-list" id="sidebar-projects">
<button <button
v-for="p in sidebarProjects" v-for="p in sidebarProjects"
:key="p.slug" :key="p.slug"
@@ -242,199 +289,108 @@ const keepAliveIncludes = ['SessionDetail'];
:class="{ active: state.projectFilter === p.slug }" :class="{ active: state.projectFilter === p.slug }"
@click="handleSidebarProject(p.slug)" @click="handleSidebarProject(p.slug)"
> >
<span class="icon" v-html="FOLDER_SVG"></span> <svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round">
<path d="M2 5.5V12a1.5 1.5 0 0 0 1.5 1.5h9A1.5 1.5 0 0 0 14 12V6.5A1.5 1.5 0 0 0 12.5 5H8.3L7 3.5H3.5A1.5 1.5 0 0 0 2 5z"/>
</svg>
<span class="label">{{ p.label }}</span> <span class="label">{{ p.label }}</span>
<span class="badge">{{ p.count }}</span> <span class="badge">{{ p.count }}</span>
</button> </button>
<div v-if="!sidebarProjects.length" class="sidebar-empty">
No projects
</div>
</div> </div>
</div> </div>
</aside> </aside>
<!-- Main content area --> <main class="main">
<div class="main"> <div class="toolbar">
<!-- Toolbar with search + sort (only on list views) --> <div class="breadcrumb" id="breadcrumb">
<div v-if="showToolbar" class="toolbar"> <template v-if="showToolbar">
<div class="breadcrumb"> <template v-if="state.projectFilter !== 'all'">
<span class="crumb terminal"> <button class="crumb" @click="handleClearProject">
{{ state.route === 'sessions' ? 'Sessions' : 'Memory' }} {{ state.route === 'sessions' ? 'Sessions' : 'Memory' }}
</span> </button>
<template v-if="state.projectFilter !== 'all'"> <span class="crumb-sep">/</span>
<span class="crumb-sep">/</span> <span class="crumb terminal">{{ formatProjectLabel(state.projectFilter) }}</span>
<span class="crumb terminal">{{ formatProjectLabel(state.projectFilter) }}</span> </template>
<template v-else>
<span class="crumb terminal">
{{ state.route === 'sessions' ? 'Sessions' : state.route === 'memory' ? 'Memory' : 'Activity' }}
</span>
</template>
</template>
<template v-else>
<router-link class="crumb" to="/sessions" v-if="route.name === 'SessionDetail' || route.name === 'SubagentDetail'">
Sessions
</router-link>
<template v-if="route.name === 'SubagentDetail'">
<span class="crumb-sep">/</span>
<router-link class="crumb" :to="`/sessions/${route.params.id}`">
{{ (state.sessions.find(s => s.id === route.params.id)?.title || '').slice(0, 30) || route.params.id }}
</router-link>
</template>
<template v-if="route.name === 'SessionDetail'">
<span class="crumb-sep">/</span>
<span class="crumb terminal">
{{ state.sessions.find(s => s.id === route.params.id)?.title || route.params.id }}
</span>
</template>
<template v-if="route.name === 'SubagentDetail'">
<span class="crumb-sep">/</span>
<span class="crumb terminal">{{ route.params.agentId }}</span>
</template>
<router-link class="crumb" to="/memory" v-if="route.name === 'MemoryDetail'">
Memory
</router-link>
<template v-if="route.name === 'MemoryDetail'">
<span class="crumb-sep">/</span>
<span class="crumb terminal filename">
{{ (state.memories.find(m => m.id === route.params.id)?.path || '').split('/').pop() }}
</span>
</template>
<span v-if="route.name === 'Activity'" class="crumb terminal">Activity</span>
</template> </template>
</div> </div>
<div class="spacer"></div> <div class="toolbar-spacer"></div>
<div id="search-wrap" class="search-wrap"> <div class="toolbar-search" id="search-wrap" v-if="showToolbar">
<svg class="toolbar-search-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6">
<circle cx="7" cy="7" r="5"/>
<path d="M11 11l3 3" stroke-linecap="round"/>
</svg>
<input <input
id="search" id="search"
type="text" type="text"
class="search-input" placeholder="Search"
placeholder="Search..." autocomplete="off"
:value="state.query"
@input="handleSearch" @input="handleSearch"
/> />
<button <span class="toolbar-search-kbd">/</span>
v-if="showSearchMsgsToggle"
class="filter-toggle"
:class="{ active: state.includeMessageBodies }"
@click="handleToggleSearchMsgs"
title="Include message bodies in search"
>
Msgs
</button>
</div> </div>
<button <button
id="sort-toggle" v-if="showToolbar"
class="sort-group" class="sort-group"
:class="{ desc: state.sortDesc, asc: !state.sortDesc }" :class="{ desc: state.sortDesc, asc: !state.sortDesc }"
@click="handleToggleSort" @click="handleToggleSort"
id="sort-toggle"
title="Toggle sort (S)"
> >
<span id="sort-label">{{ state.sortDesc ? 'newest' : 'oldest' }}</span> <span class="label" id="sort-label">{{ state.sortDesc ? 'newest' : 'oldest' }}</span>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path class="arrow-up" d="M5 6l3-3 3 3"/>
<path class="arrow-down" d="M5 10l3 3 3-3"/>
</svg>
</button> </button>
</div> </div>
<!-- Toolbar for detail views (breadcrumb only) -->
<div v-if="!showToolbar" class="toolbar">
<div class="breadcrumb">
<router-link class="crumb" to="/sessions" v-if="route.name === 'SessionDetail' || route.name === 'SubagentDetail'">
Sessions
</router-link>
<template v-if="route.name === 'SubagentDetail'">
<span class="crumb-sep">/</span>
<router-link class="crumb" :to="`/sessions/${route.params.id}`">
{{ (state.sessions.find(s => s.id === route.params.id)?.title || '').slice(0, 30) || route.params.id }}
</router-link>
</template>
<template v-if="route.name === 'SessionDetail'">
<span class="crumb-sep">/</span>
<span class="crumb terminal">
{{ state.sessions.find(s => s.id === route.params.id)?.title || route.params.id }}
</span>
</template>
<template v-if="route.name === 'SubagentDetail'">
<span class="crumb-sep">/</span>
<span class="crumb terminal">{{ route.params.agentId }}</span>
</template>
<router-link class="crumb" to="/memory" v-if="route.name === 'MemoryDetail'">
Memory
</router-link>
<template v-if="route.name === 'MemoryDetail'">
<span class="crumb-sep">/</span>
<span class="crumb terminal filename">
{{ (state.memories.find(m => m.id === route.params.id)?.path || '').split('/').pop() }}
</span>
</template>
<span v-if="route.name === 'Usage'" class="crumb terminal">Usage</span>
</div>
</div>
<!-- Router view with keep-alive for SessionDetail -->
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component }">
<keep-alive :include="keepAliveIncludes"> <keep-alive :include="['SessionDetail']">
<component :is="Component" /> <component :is="Component" />
</keep-alive> </keep-alive>
</router-view> </router-view>
</div> </main>
</div> </div>
<!-- Status bar -->
<div class="statusbar"> <div class="statusbar">
<div id="status-left" class="status-left"></div> <div class="status-left" id="status-left"></div>
<div id="status-right" class="status-right"></div> <div class="status-right" id="status-right"></div>
</div> </div>
</div> </div>
</template> </template>
<style>
@import '../styles/base.css';
@import '../styles/sidebar.css';
@import '../styles/toolbar.css';
@import '../styles/list.css';
@import '../styles/detail.css';
@import '../styles/statusbar.css';
</style>
<style scoped>
.app-shell {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.titlebar {
height: 38px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
position: relative;
-webkit-app-region: drag;
background: var(--bg-2);
border-bottom: 1px solid var(--hairline);
}
.titlebar.mac {
padding-left: 78px;
}
.titlebar-drag {
position: absolute;
inset: 0;
}
.titlebar-text {
font-size: 12px;
color: var(--muted);
display: flex;
align-items: center;
gap: 6px;
pointer-events: none;
}
.titlebar-text .app-name {
font-weight: 600;
color: var(--fg-2);
}
.titlebar-text .sep {
opacity: 0.4;
}
.columns {
display: flex;
flex: 1;
min-height: 0;
}
.spacer {
flex: 1;
}
.statusbar {
height: 26px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
background: var(--bg-2);
border-top: 1px solid var(--hairline);
font-size: 11px;
color: var(--muted);
}
.sidebar-empty {
padding: 8px 10px;
font-size: 11px;
color: var(--muted-2);
}
.sidebar-projects-list {
flex: 1;
overflow-y: auto;
min-height: 0;
}
</style>
+6 -6
View File
@@ -59,8 +59,8 @@ function isMemoryViewActive(view) {
return state.route === 'memory' && state.view === view && state.projectFilter === 'all'; return state.route === 'memory' && state.view === view && state.projectFilter === 'all';
} }
function isUsageActive() { function isActivityActive() {
return state.route === 'usage'; return state.route === 'activity';
} }
function isProjectActive(slug) { function isProjectActive(slug) {
@@ -73,8 +73,8 @@ function handleSidebarRoute(routeName) {
setRoute(routeName); setRoute(routeName);
if (routeName === 'sessions') { if (routeName === 'sessions') {
router.push('/sessions'); router.push('/sessions');
} else if (routeName === 'usage') { } else if (routeName === 'activity') {
router.push('/usage'); router.push('/activity');
} else { } else {
router.push('/memory'); router.push('/memory');
} }
@@ -175,7 +175,7 @@ function handleProjectSearch(e) {
<button <button
class="sidebar-item" class="sidebar-item"
:class="{ active: isUsageActive() }" :class="{ active: isActivityActive() }"
@click="handleSidebarRoute('usage')" @click="handleSidebarRoute('usage')"
> >
<span class="icon"> <span class="icon">
@@ -183,7 +183,7 @@ function handleProjectSearch(e) {
<path d="M2 13h12M4 9v4M7 6v7M10 8v5M13 4v9"/> <path d="M2 13h12M4 9v4M7 6v7M10 8v5M13 4v9"/>
</svg> </svg>
</span> </span>
<span class="label">Usage</span> <span class="label">Activity</span>
</button> </button>
</div> </div>
+2 -2
View File
@@ -44,8 +44,8 @@ const breadcrumbs = computed(() => {
const m = state.memories.find(x => x.id === route.params.id); const m = state.memories.find(x => x.id === route.params.id);
const filename = (m?.path || '').split('/').pop(); const filename = (m?.path || '').split('/').pop();
crumbs.push({ label: filename, terminal: true, filename: true }); crumbs.push({ label: filename, terminal: true, filename: true });
} else if (name === 'Usage') { } else if (name === 'Activity') {
crumbs.push({ label: 'Usage', terminal: true }); crumbs.push({ label: 'Activity', terminal: true });
} }
return crumbs; return crumbs;
+10 -7
View File
@@ -22,16 +22,19 @@ export async function loadInitialData() {
ts: m.created_at ? new Date(m.created_at).getTime() : 0, ts: m.created_at ? new Date(m.created_at).getTime() : 0,
archived: !!m.deleted_at, archived: !!m.deleted_at,
archivedAt: m.deleted_at ? new Date(m.deleted_at).getTime() : null, archivedAt: m.deleted_at ? new Date(m.deleted_at).getTime() : null,
health: 'ok', anchors: m.anchors ? (typeof m.anchors === 'string' ? JSON.parse(m.anchors) : m.anchors) : [],
anchors: [],
markdown: null // loaded on demand via loadMemoryMarkdown markdown: null // loaded on demand via loadMemoryMarkdown
})); }));
// Sessions: keep DB shape, add empty messages array for on-demand loading // Sessions: merge with existing data to preserve already-loaded messages
state.sessions = (rawSessions || []).map(s => ({ const existingSessions = new Map(state.sessions.map(s => [s.id, s]));
...s, state.sessions = (rawSessions || []).map(s => {
messages: [] const existing = existingSessions.get(s.id);
})); return {
...s,
messages: existing?.messages?.length ? existing.messages : []
};
});
state.projects = projects || []; state.projects = projects || [];
state.stats = stats || {}; state.stats = stats || {};
+12 -1
View File
@@ -17,9 +17,20 @@ const app = createApp(App);
app.use(router); app.use(router);
// Load data before the first render completes // Load data on startup
router.isReady().then(() => { router.isReady().then(() => {
loadInitialData(); loadInitialData();
}); });
// Refresh data when window regains focus
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
loadInitialData();
}
});
window.obelisk?.onIndexUpdated?.(() => {
loadInitialData();
});
app.mount('#app'); app.mount('#app');
+4 -4
View File
@@ -9,7 +9,7 @@ const SessionDetail = () => import('./views/SessionDetail.vue');
const SubagentDetail = () => import('./views/SubagentDetail.vue'); const SubagentDetail = () => import('./views/SubagentDetail.vue');
const MemoryList = () => import('./views/MemoryList.vue'); const MemoryList = () => import('./views/MemoryList.vue');
const MemoryDetail = () => import('./views/MemoryDetail.vue'); const MemoryDetail = () => import('./views/MemoryDetail.vue');
const Usage = () => import('./views/Usage.vue'); const Activity = () => import('./views/Activity.vue');
const routes = [ const routes = [
{ {
@@ -42,9 +42,9 @@ const routes = [
props: true props: true
}, },
{ {
path: '/usage', path: '/activity',
name: 'Usage', name: 'Activity',
component: Usage component: Activity
}, },
{ {
path: '/', path: '/',
+6 -4
View File
@@ -154,10 +154,12 @@ export function positionTooltip(el, x, y) {
export function formatProjectLabel(slug) { export function formatProjectLabel(slug) {
if (!slug) return '(no project)'; if (!slug) return '(no project)';
const session = state.sessions.find(s => s.project === slug && s.project_path); // Find the shortest project_path for this slug (most likely the project root)
if (session?.project_path) { const sessions = state.sessions.filter(s => s.project === slug && s.project_path);
const parts = session.project_path.split('/'); if (sessions.length) {
return parts.slice(-2).join('/'); const shortest = sessions.reduce((a, b) => a.project_path.length <= b.project_path.length ? a : b);
const parts = shortest.project_path.split('/');
return parts[parts.length - 1];
} }
return slug.replace(/^-/, ''); return slug.replace(/^-/, '');
} }
@@ -3,7 +3,7 @@ import { ref, reactive, computed, onMounted } from 'vue';
import { state, navigateToSession } from '../store.js'; import { state, navigateToSession } from '../store.js';
import { fmtTokens, fmtDuration, fmtTooltipDate, positionTooltip, escapeHTML, formatProjectLabel } from '../utils.js'; import { fmtTokens, fmtDuration, fmtTooltipDate, positionTooltip, escapeHTML, formatProjectLabel } from '../utils.js';
defineOptions({ name: 'Usage' }); defineOptions({ name: 'Activity' });
// --- State --- // --- State ---
const activeTab = ref('daily'); const activeTab = ref('daily');
+110 -2
View File
@@ -1,8 +1,116 @@
<script setup> <script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import { state, FOLDER_SVG } from '../store.js';
import { loadMemoryMarkdown, archiveMemory, restoreMemory, isTextTruncated } from '../data.js';
import { escapeHTML, fmtRelative, renderMarkdown, formatProjectLabel } from '../utils.js';
defineOptions({ name: 'MemoryDetail' }); defineOptions({ name: 'MemoryDetail' });
defineProps({ id: String }); const props = defineProps({ id: String });
const router = useRouter();
const memory = computed(() => state.memories.find(m => m.id === props.id));
const markdown = ref(null);
const showSource = ref(false);
const loading = ref(false);
onMounted(async () => { await loadContent(); });
watch(() => props.id, async () => { markdown.value = null; showSource.value = false; await loadContent(); });
async function loadContent() {
const m = memory.value;
if (!m) return;
if (m.markdown != null) { markdown.value = m.markdown; return; }
if (m.path) {
loading.value = true;
const content = await loadMemoryMarkdown(m.path);
m.markdown = content;
markdown.value = content;
loading.value = false;
}
}
async function handleArchive() {
const m = memory.value;
if (!m) return;
if (m.archived) await restoreMemory(m.id);
else await archiveMemory(m.id);
router.push('/memory');
}
function goToSession() {
const m = memory.value;
if (m?.session_id) router.push(`/sessions/${m.session_id}`);
}
</script> </script>
<template> <template>
<div class="view-placeholder">MemoryDetail view for {{ id }} (TODO)</div> <div class="detail" v-if="memory">
<div class="detail-header">
<div class="detail-eyebrow">
<span class="project-icon" v-html="FOLDER_SVG"></span>
<span class="project-name">{{ formatProjectLabel(memory.project) }}</span>
<span v-if="memory.archived" class="archived-tag">archived</span>
</div>
<div class="detail-path">{{ memory.path }}</div>
<div class="detail-summary">{{ memory.summary }}</div>
<div class="detail-meta">
<button v-if="memory.session_id" class="session-link" @click="goToSession">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" style="width:11px;height:11px;">
<path d="M3 4h10v8a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4z"/>
<path d="M5.5 7h5M5.5 9.5h3" stroke-linecap="round"/>
</svg>
<span>Source session</span>
</button>
<span class="dot" v-if="memory.session_id"></span>
<span>created {{ fmtRelative(memory.ts) }}</span>
<template v-if="memory.message_start">
<span class="dot"></span>
<span style="font-family:var(--font-mono);font-size:11px;">{{ memory.message_start.slice(0, 8) }} {{ (memory.message_end || '').slice(0, 8) }}</span>
</template>
</div>
</div>
<div class="markdown-section">
<div class="markdown-toolbar">
<span class="markdown-toolbar-label">Body</span>
<button
class="source-toggle"
:class="{ active: showSource }"
:disabled="markdown == null"
@click="showSource = !showSource"
>{{ showSource ? 'Show rendered' : 'Show source' }}</button>
</div>
<div v-if="loading" style="color:var(--muted);padding:20px;text-align:center;">Loading</div>
<div v-else-if="markdown == null" style="color:var(--muted-2);font-style:italic;padding:20px;text-align:center;border:1px dashed var(--hairline);border-radius:6px;">File not found or empty.</div>
<pre v-else-if="showSource" class="markdown-source">{{ markdown }}</pre>
<div v-else v-html="renderMarkdown(markdown, { variant: 'body' })"></div>
</div>
<div v-if="memory.anchors && memory.anchors.length" class="detail-section-divider" id="anchors-section">
<span>Anchors</span><span class="count">{{ memory.anchors.length }}</span>
</div>
<div v-if="memory.anchors && memory.anchors.length" class="anchor-list">
<button
v-for="a in memory.anchors"
:key="a.path + ':' + a.line"
class="anchor-link"
:disabled="a.exists === false"
:title="a.exists === false ? 'File no longer exists' : 'Open in editor'"
>
<span class="anchor-icon">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" stroke-linecap="round"><path d="M3.5 2h6l3 3v9a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M9.5 2v3h3"/></svg>
</span>
<span class="anchor-path">{{ a.path }}</span>
<span class="anchor-line" v-if="a.line">:{{ a.line }}</span>
</button>
</div>
<div class="detail-actions">
<button class="btn" @click="router.push('/memory')">Back</button>
<button class="btn" :class="memory.archived ? 'primary' : 'danger'" @click="handleArchive">
{{ memory.archived ? 'Restore' : 'Archive' }}
</button>
</div>
</div>
</template> </template>
+12 -4
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, computed, onMounted, nextTick, onActivated } from 'vue'; import { ref, computed, onMounted, nextTick, onActivated, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { state, FOLDER_SVG } from '../store.js'; import { state, FOLDER_SVG } from '../store.js';
import { loadSessionDetail, isTextTruncated, loadFullText } from '../data.js'; import { loadSessionDetail, isTextTruncated, loadFullText } from '../data.js';
@@ -27,18 +27,24 @@ const showBackToTop = ref(false);
const wrapRef = ref(null); const wrapRef = ref(null);
const detailRef = ref(null); const detailRef = ref(null);
// --- Load session on mount --- // --- Load session on mount or when id changes ---
onMounted(async () => { onMounted(async () => {
await loadMessages(); await loadMessages();
}); });
// When keep-alive re-activates, re-check if we need data
onActivated(async () => { onActivated(async () => {
if (messages.value.length === 0 && props.id) { if (messages.value.length === 0 && props.id) {
await loadMessages(); await loadMessages();
} }
}); });
watch(() => props.id, async (newId, oldId) => {
if (newId && newId !== oldId) {
messages.value = [];
await loadMessages();
}
});
async function loadMessages() { async function loadMessages() {
if (!props.id) return; if (!props.id) return;
loading.value = true; loading.value = true;
@@ -181,7 +187,9 @@ function getToolCallParsedInput(tc) {
</div> </div>
<div class="session-title">{{ session.title || '(untitled)' }}</div> <div class="session-title">{{ session.title || '(untitled)' }}</div>
<div class="session-meta-inline"> <div class="session-meta-inline">
<span>{{ fmtRelative(new Date(session.started_at || 0).getTime()) }}</span> <span>created {{ fmtRelative(new Date(session.started_at || 0).getTime()) }}</span>
<span class="dot"></span>
<span>last active {{ fmtRelative(new Date(session.ended_at || session.started_at || 0).getTime()) }}</span>
<span class="dot"></span> <span class="dot"></span>
<span>{{ session.message_count || 0 }} messages</span> <span>{{ session.message_count || 0 }} messages</span>
<template v-if="session.git_branch"> <template v-if="session.git_branch">
+36 -4
View File
@@ -2,7 +2,7 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { state } from '../store.js'; import { state } from '../store.js';
import { highlightPlain, escapeHTML, formatProjectLabel, fmtListTime } from '../utils.js'; import { highlightPlain, escapeHTML, formatProjectLabel, fmtListTime, fmtRelative } from '../utils.js';
defineOptions({ name: 'SessionList' }); defineOptions({ name: 'SessionList' });
@@ -22,8 +22,8 @@ const visibleSessions = computed(() => {
}) })
.filter(Boolean) .filter(Boolean)
.sort((a, b) => { .sort((a, b) => {
const ta = new Date(a.started_at || 0).getTime(); const ta = new Date(a.ended_at || a.started_at || 0).getTime();
const tb = new Date(b.started_at || 0).getTime(); const tb = new Date(b.ended_at || b.started_at || 0).getTime();
return state.sortDesc ? tb - ta : ta - tb; return state.sortDesc ? tb - ta : ta - tb;
}); });
}); });
@@ -39,13 +39,44 @@ function projectLabel(session) {
} }
function timeLabel(session) { function timeLabel(session) {
const ts = new Date(session.started_at || 0).getTime(); const ts = new Date(session.ended_at || session.started_at || 0).getTime();
return fmtListTime(ts); return fmtListTime(ts);
} }
function lastActiveLabel(session) {
const ts = new Date(session.ended_at || session.started_at || 0).getTime();
return fmtListTime(ts);
}
function createdLabel(session) {
const ts = new Date(session.started_at || 0).getTime();
return fmtRelative(ts);
}
function openSession(session) { function openSession(session) {
router.push({ name: 'SessionDetail', params: { id: session.id } }); router.push({ name: 'SessionDetail', params: { id: session.id } });
} }
function obeliskStyle(session) {
const created = new Date(session.started_at || 0).getTime();
const days = Math.max(0, (Date.now() - created) / 86400000);
const height = Math.min(1, Math.log(1 + days) / Math.log(1 + 365));
let color;
if (days < 7) color = '#a855f7';
else if (days < 30) color = '#6366f1';
else if (days < 90) color = '#64748b';
else color = '#475569';
const glow = days < 7 ? `0 0 4px ${color}` : 'none';
const maxHeight = 36; // px, roughly the row height minus padding
return {
height: `${Math.max(4, Math.round(height * maxHeight))}px`,
background: color,
boxShadow: glow,
};
}
</script> </script>
<template> <template>
@@ -63,6 +94,7 @@ function openSession(session) {
:data-session-id="s.id" :data-session-id="s.id"
@click="openSession(s)" @click="openSession(s)"
> >
<div class="srow-obelisk" :style="obeliskStyle(s)"></div>
<div class="srow-body"> <div class="srow-body">
<div class="srow-title" v-html="titleHTML(s)"></div> <div class="srow-title" v-html="titleHTML(s)"></div>
<div class="srow-meta"> <div class="srow-meta">
+136 -2
View File
@@ -1,8 +1,142 @@
<script setup> <script setup>
import { ref, onMounted, watch, computed } from 'vue';
import { useRouter } from 'vue-router';
import { state } from '../store.js';
import { loadSubagentDetail, isTextTruncated, loadFullText } from '../data.js';
import { escapeHTML, fmtClockTime, renderMarkdown } from '../utils.js';
defineOptions({ name: 'SubagentDetail' }); defineOptions({ name: 'SubagentDetail' });
defineProps({ id: String, agentId: String }); const props = defineProps({ id: String, agentId: String });
const router = useRouter();
const messages = ref([]);
const loading = ref(false);
const parentSession = computed(() => state.sessions.find(s => s.id === props.id));
onMounted(async () => { await load(); });
watch(() => props.agentId, async (n, o) => { if (n && n !== o) { messages.value = []; await load(); } });
async function load() {
if (!props.agentId) return;
loading.value = true;
try {
messages.value = await loadSubagentDetail(props.agentId);
} finally { loading.value = false; }
}
function goBack() {
router.push(`/sessions/${props.id}`);
}
async function handleLoadFull(uuid, el) {
const full = await loadFullText(uuid);
if (full && el) {
const body = el.closest('.msg')?.querySelector('.markdown-msg, .markdown-compact');
if (body) body.outerHTML = renderMarkdown(full, { variant: 'msg' });
el.remove();
}
}
</script> </script>
<template> <template>
<div class="view-placeholder">SubagentDetail view for agent {{ agentId }} in session {{ id }} (TODO)</div> <div class="session-detail-wrap" ref="wrapRef">
<div class="detail-wide">
<div class="session-header">
<div class="session-eyebrow">
<span style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;">Subagent</span>
</div>
<div class="session-title">{{ agentId }}</div>
<div class="session-meta-inline">
<span>{{ messages.length }} messages</span>
</div>
</div>
<div v-if="loading" class="empty">Loading</div>
<div v-else class="timeline">
<div
v-for="(msg, idx) in messages"
:key="msg.uuid"
class="msg"
:class="[msg.type === 'user' ? 'user' : 'assistant']"
:data-uuid="msg.uuid"
>
<!-- Thinking -->
<template v-if="msg.content_type === 'thinking'">
<div class="msg-thinking">
<button class="thinking-toggle" @click="$event.currentTarget.closest('.msg-thinking').classList.toggle('open')">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="thinking-label">Thinking</span>
</button>
<div class="thinking-body" v-html="renderMarkdown(msg.text, { variant: 'msg' })"></div>
</div>
</template>
<!-- Meta -->
<template v-else-if="msg.is_meta">
<div class="msg-meta-collapsed">
<button class="meta-toggle" @click="$event.currentTarget.closest('.msg-meta-collapsed').classList.toggle('open')">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="meta-label">System</span>
<span class="meta-preview">{{ (msg.text || '').replace(/<[^>]+>/g, '').slice(0, 80) }}</span>
</button>
<div class="meta-body" v-html="renderMarkdown(msg.text, { variant: 'compact' })"></div>
</div>
</template>
<!-- Normal message -->
<template v-else>
<div class="msg-head">
<span class="role">{{ msg.type === 'user' ? 'Prompt' : 'Assistant' }}</span>
<span class="when">{{ msg.timestamp ? fmtClockTime(msg.timestamp) : '' }}</span>
</div>
<div v-if="msg._thinking" class="msg-thinking">
<button class="thinking-toggle" @click="$event.currentTarget.closest('.msg-thinking').classList.toggle('open')">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="thinking-label">Thinking</span>
</button>
<div class="thinking-body" v-html="renderMarkdown(msg._thinking, { variant: 'msg' })"></div>
</div>
<div v-if="msg.text" v-html="renderMarkdown(msg.text, { variant: 'msg' })"></div>
<div v-else-if="!msg.tool_calls?.length" class="msg-text empty-text">(no text content)</div>
<button
v-if="isTextTruncated(msg.text)"
class="truncated-btn"
@click="handleLoadFull(msg.uuid, $event.currentTarget)"
>Message truncated click to load full text</button>
<!-- Tool calls -->
<div v-if="msg.tool_calls?.length" class="msg-tools">
<div v-for="tc in msg.tool_calls" :key="tc.id" class="msg-tool" :class="{ 'is-error': tc.result?.is_error }">
<button class="toolcall-toggle" @click="$event.currentTarget.closest('.msg-tool').classList.toggle('open')">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="tool-name">{{ tc.name }}</span>
<span class="tool-arg">{{ getToolArgPreview(tc) }}</span>
<span v-if="tc.result?.is_error" class="tool-error">error</span>
</button>
<div class="toolcall-body">
<div class="tc-section">Input</div>
<pre>{{ tc.input_json }}</pre>
<template v-if="tc.result">
<div class="tc-section">{{ tc.result.is_error ? 'Error' : 'Output' }}</div>
<pre>{{ tc.result.content || '(empty)' }}</pre>
</template>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
</template> </template>
<script>
function getToolArgPreview(tc) {
try {
const j = JSON.parse(tc.input_json || '{}');
return j.file_path || j.command || j.path || j.description || JSON.stringify(j).slice(0, 100);
} catch { return (tc.input_json || '').slice(0, 100); }
}
</script>
+6
View File
@@ -116,6 +116,10 @@
content: ''; position: absolute; left: 0; top: 0; bottom: 0; content: ''; position: absolute; left: 0; top: 0; bottom: 0;
width: 2px; background: var(--muted-2); width: 2px; background: var(--muted-2);
} }
.srow-obelisk {
position: absolute; left: 0; bottom: 0;
width: 3px; border-radius: 1.5px 1.5px 0 0;
}
.srow-body { min-width: 0; display: flex; flex-direction: column; gap: 4px; } .srow-body { min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.srow-title { .srow-title {
font-size: var(--text-md); font-weight: 500; color: var(--fg); font-size: var(--text-md); font-weight: 500; color: var(--fg);
@@ -146,7 +150,9 @@
color: var(--fg-2); text-align: right; color: var(--fg-2); text-align: right;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
flex-shrink: 0; padding-top: 2px; white-space: nowrap; flex-shrink: 0; padding-top: 2px; white-space: nowrap;
display: flex; flex-direction: column; gap: 2px;
} }
.srow-right .srow-created { font-size: 10px; color: var(--muted); }
.empty { .empty {
flex: 1; display: flex; align-items: center; justify-content: center; flex: 1; display: flex; align-items: center; justify-content: center;
+1 -1
View File
@@ -34,7 +34,7 @@
.sidebar-search input::placeholder { color: var(--muted-2); } .sidebar-search input::placeholder { color: var(--muted-2); }
.sidebar-search input:focus { outline: 0; border-color: var(--accent); background: var(--surface-strong); } .sidebar-search input:focus { outline: 0; border-color: var(--accent); background: var(--surface-strong); }
.sidebar-search-icon { .sidebar-search-icon {
position: absolute; left: 14px; top: 50%; transform: translateY(-50%); position: absolute; left: 14px; top: 12px; transform: translateY(-50%);
width: 11px; height: 11px; color: var(--muted); pointer-events: none; width: 11px; height: 11px; color: var(--muted); pointer-events: none;
} }
.sidebar-list { overflow-y: auto; padding: 2px 0 8px; flex: 1; min-height: 0; } .sidebar-list { overflow-y: auto; padding: 2px 0 8px; flex: 1; min-height: 0; }
+1 -1
View File
@@ -15,7 +15,7 @@
cursor: pointer; transition: all 0.1s; cursor: pointer; transition: all 0.1s;
display: inline-flex; align-items: center; gap: 6px; display: inline-flex; align-items: center; gap: 6px;
line-height: 1; border: 0; background: transparent; line-height: 1; border: 0; background: transparent;
white-space: nowrap; white-space: nowrap; text-decoration: none;
} }
.crumb:hover { background: var(--surface-strong); color: var(--fg-2); } .crumb:hover { background: var(--surface-strong); color: var(--fg-2); }
.crumb.terminal { color: var(--fg); font-weight: 600; cursor: default; } .crumb.terminal { color: var(--fg); font-weight: 600; cursor: default; }
+11
View File
@@ -3,6 +3,9 @@
Advanced reference for the obelisk database. Advanced reference for the obelisk database.
Read this when `search()`, `context()`, or `sql()` are not enough. Read this when `search()`, `context()`, or `sql()` are not enough.
Executable schema source: `scripts/schema.sql`. This document explains that
contract for agents and humans; it is not the runtime source of truth.
--- ---
## 1. Database Schema ## 1. Database Schema
@@ -215,6 +218,14 @@ CREATE TABLE index_state (
); );
``` ```
Sentinel rows use synthetic `jsonl_path` keys:
`__last_build__` stores the last completed build time, `__app_heartbeat__`
stores the optional app indexer's liveness heartbeat,
`__app_last_successful_build__` stores the app's last successful index build,
`__indexer_owner_app__` marks app ownership, and `__last_source_mtime__` records
the newest indexed source mtime. Skill-side lazy builds skip work only while the
app heartbeat and app successful-build marker are both fresh.
### memories ### memories
Human-approved markdown memory records registered in Obelisk. The markdown Human-approved markdown memory records registered in Obelisk. The markdown
+1 -60
View File
@@ -8,66 +8,7 @@ const { DatabaseSync } = require('node:sqlite');
const CLAUDE_DIR = path.join(os.homedir(), '.claude'); const CLAUDE_DIR = path.join(os.homedir(), '.claude');
const DB_PATH = path.join(CLAUDE_DIR, 'obelisk.sqlite'); const DB_PATH = path.join(CLAUDE_DIR, 'obelisk.sqlite');
const TEXT_LIMIT = 10000; const TEXT_LIMIT = 10000;
const SCHEMA = fs.readFileSync(new URL('./schema.sql', import.meta.url), 'utf8');
const SCHEMA = `
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, title TEXT, project TEXT, project_path TEXT,
started_at TEXT, ended_at TEXT, git_branch TEXT, version TEXT,
message_count INTEGER DEFAULT 0, jsonl_path TEXT);
CREATE TABLE IF NOT EXISTS messages (
uuid TEXT PRIMARY KEY, session_id TEXT, type TEXT, parent_uuid TEXT,
timestamp TEXT, role TEXT, text TEXT, content_type TEXT,
is_meta INTEGER DEFAULT 0, model TEXT,
is_sidechain INTEGER DEFAULT 0, agent_id TEXT,
input_tokens INTEGER, output_tokens INTEGER,
cwd TEXT, skill TEXT, turn_duration_ms INTEGER);
CREATE TABLE IF NOT EXISTS tool_calls (
id TEXT PRIMARY KEY, message_uuid TEXT, session_id TEXT,
name TEXT, input_json TEXT, file_path TEXT);
CREATE TABLE IF NOT EXISTS tool_results (
tool_use_id TEXT PRIMARY KEY, message_uuid TEXT, session_id TEXT,
content TEXT, file_path TEXT, is_error INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS subagents (
agent_id TEXT PRIMARY KEY, session_id TEXT, parent_tool_use_id TEXT,
agent_type TEXT, description TEXT, duration_ms INTEGER, total_tokens INTEGER);
CREATE TABLE IF NOT EXISTS workflows (
run_id TEXT PRIMARY KEY, session_id TEXT, task_id TEXT,
script TEXT, result_json TEXT, timestamp TEXT, agent_count INTEGER DEFAULT 0,
duration_ms INTEGER, total_tokens INTEGER, status TEXT, workflow_name TEXT);
CREATE TABLE IF NOT EXISTS workflow_agents (
agent_id TEXT PRIMARY KEY, run_id TEXT, session_id TEXT,
agent_type TEXT, description TEXT,
phase TEXT, label TEXT, model TEXT, state TEXT,
duration_ms INTEGER, tokens INTEGER, tool_calls INTEGER);
CREATE TABLE IF NOT EXISTS index_state (
jsonl_path TEXT PRIMARY KEY, mtime REAL, lines_processed INTEGER);
CREATE TABLE IF NOT EXISTS summaries (
id TEXT PRIMARY KEY, session_id TEXT, timestamp TEXT,
source TEXT, content TEXT);
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
uuid UNINDEXED, session_id UNINDEXED, text, content=messages, content_rowid=rowid);
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
CREATE INDEX IF NOT EXISTS idx_messages_agent ON messages(agent_id);
CREATE INDEX IF NOT EXISTS idx_messages_ts ON messages(session_id, timestamp);
CREATE INDEX IF NOT EXISTS idx_tc_session_name ON tool_calls(session_id, name);
CREATE INDEX IF NOT EXISTS idx_tc_file ON tool_calls(file_path);
CREATE INDEX IF NOT EXISTS idx_sa_session ON subagents(session_id);
CREATE INDEX IF NOT EXISTS idx_wf_session ON workflows(session_id);
CREATE INDEX IF NOT EXISTS idx_wa_run ON workflow_agents(run_id);
CREATE INDEX IF NOT EXISTS idx_summaries_session ON summaries(session_id);
CREATE TABLE IF NOT EXISTS memories (
id TEXT PRIMARY KEY, session_id TEXT, project TEXT,
message_start TEXT, message_end TEXT,
path TEXT, anchors TEXT, summary TEXT, created_at TEXT,
deleted_at TEXT, deleted_reason TEXT);
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
id UNINDEXED, path, summary,
content=memories, content_rowid=rowid,
tokenize='unicode61 remove_diacritics 1');
CREATE INDEX IF NOT EXISTS idx_memories_project ON memories(project);
CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id);
CREATE INDEX IF NOT EXISTS idx_memories_created ON memories(created_at);
`;
function openDb() { function openDb() {
const db = new DatabaseSync(DB_PATH); const db = new DatabaseSync(DB_PATH);
+20 -3
View File
@@ -246,12 +246,29 @@ function indexHistory(db) {
} }
const BUILD_DEBOUNCE_MS = 30000; const BUILD_DEBOUNCE_MS = 30000;
const APP_HEARTBEAT_FRESH_MS = 60000;
function shouldSkipBuild(db, { now = Date.now() } = {}) {
const appHeartbeat = db.prepare("SELECT mtime FROM index_state WHERE jsonl_path='__app_heartbeat__'").get();
const appSuccessfulBuild = db.prepare("SELECT mtime FROM index_state WHERE jsonl_path='__app_last_successful_build__'").get();
if (
appHeartbeat && now - appHeartbeat.mtime < APP_HEARTBEAT_FRESH_MS &&
appSuccessfulBuild && now - appSuccessfulBuild.mtime < APP_HEARTBEAT_FRESH_MS
) {
return { skip: true, reason: 'app_successful_build' };
}
const last = db.prepare("SELECT mtime FROM index_state WHERE jsonl_path='__last_build__'").get();
if (last && now - last.mtime < BUILD_DEBOUNCE_MS) {
return { skip: true, reason: 'recent_build' };
}
return { skip: false };
}
function buildIndex({ force = false } = {}) { function buildIndex({ force = false } = {}) {
const db = openDb(); const db = openDb();
if (!force) { if (!force) {
const last = db.prepare("SELECT mtime FROM index_state WHERE jsonl_path='__last_build__'").get(); const skip = shouldSkipBuild(db);
if (last && Date.now() - last.mtime < BUILD_DEBOUNCE_MS) { db.close(); return; } if (skip.skip) { db.close(); return; }
} }
if (force) { if (force) {
@@ -286,4 +303,4 @@ function buildIndex({ force = false } = {}) {
db.close(); db.close();
} }
export { buildIndex, inferProjectPath, refreshSessionProjectPaths }; export { buildIndex, inferProjectPath, refreshSessionProjectPaths, shouldSkipBuild };
+57
View File
@@ -0,0 +1,57 @@
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, title TEXT, project TEXT, project_path TEXT,
started_at TEXT, ended_at TEXT, git_branch TEXT, version TEXT,
message_count INTEGER DEFAULT 0, jsonl_path TEXT);
CREATE TABLE IF NOT EXISTS messages (
uuid TEXT PRIMARY KEY, session_id TEXT, type TEXT, parent_uuid TEXT,
timestamp TEXT, role TEXT, text TEXT, content_type TEXT,
is_meta INTEGER DEFAULT 0, model TEXT,
is_sidechain INTEGER DEFAULT 0, agent_id TEXT,
input_tokens INTEGER, output_tokens INTEGER,
cwd TEXT, skill TEXT, turn_duration_ms INTEGER);
CREATE TABLE IF NOT EXISTS tool_calls (
id TEXT PRIMARY KEY, message_uuid TEXT, session_id TEXT,
name TEXT, input_json TEXT, file_path TEXT);
CREATE TABLE IF NOT EXISTS tool_results (
tool_use_id TEXT PRIMARY KEY, message_uuid TEXT, session_id TEXT,
content TEXT, file_path TEXT, is_error INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS subagents (
agent_id TEXT PRIMARY KEY, session_id TEXT, parent_tool_use_id TEXT,
agent_type TEXT, description TEXT, duration_ms INTEGER, total_tokens INTEGER);
CREATE TABLE IF NOT EXISTS workflows (
run_id TEXT PRIMARY KEY, session_id TEXT, task_id TEXT,
script TEXT, result_json TEXT, timestamp TEXT, agent_count INTEGER DEFAULT 0,
duration_ms INTEGER, total_tokens INTEGER, status TEXT, workflow_name TEXT);
CREATE TABLE IF NOT EXISTS workflow_agents (
agent_id TEXT PRIMARY KEY, run_id TEXT, session_id TEXT,
agent_type TEXT, description TEXT,
phase TEXT, label TEXT, model TEXT, state TEXT,
duration_ms INTEGER, tokens INTEGER, tool_calls INTEGER);
CREATE TABLE IF NOT EXISTS index_state (
jsonl_path TEXT PRIMARY KEY, mtime REAL, lines_processed INTEGER);
CREATE TABLE IF NOT EXISTS summaries (
id TEXT PRIMARY KEY, session_id TEXT, timestamp TEXT,
source TEXT, content TEXT);
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
uuid UNINDEXED, session_id UNINDEXED, text, content=messages, content_rowid=rowid);
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
CREATE INDEX IF NOT EXISTS idx_messages_agent ON messages(agent_id);
CREATE INDEX IF NOT EXISTS idx_messages_ts ON messages(session_id, timestamp);
CREATE INDEX IF NOT EXISTS idx_tc_session_name ON tool_calls(session_id, name);
CREATE INDEX IF NOT EXISTS idx_tc_file ON tool_calls(file_path);
CREATE INDEX IF NOT EXISTS idx_sa_session ON subagents(session_id);
CREATE INDEX IF NOT EXISTS idx_wf_session ON workflows(session_id);
CREATE INDEX IF NOT EXISTS idx_wa_run ON workflow_agents(run_id);
CREATE INDEX IF NOT EXISTS idx_summaries_session ON summaries(session_id);
CREATE TABLE IF NOT EXISTS memories (
id TEXT PRIMARY KEY, session_id TEXT, project TEXT,
message_start TEXT, message_end TEXT,
path TEXT, anchors TEXT, summary TEXT, created_at TEXT,
deleted_at TEXT, deleted_reason TEXT);
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
id UNINDEXED, path, summary,
content=memories, content_rowid=rowid,
tokenize='unicode61 remove_diacritics 1');
CREATE INDEX IF NOT EXISTS idx_memories_project ON memories(project);
CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id);
CREATE INDEX IF NOT EXISTS idx_memories_created ON memories(created_at);