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:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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
@@ -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
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+30
-1
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+143
-187
@@ -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,62 +289,34 @@ 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">
|
|
||||||
<span class="crumb terminal">
|
|
||||||
{{ state.route === 'sessions' ? 'Sessions' : 'Memory' }}
|
|
||||||
</span>
|
|
||||||
<template v-if="state.projectFilter !== 'all'">
|
<template v-if="state.projectFilter !== 'all'">
|
||||||
|
<button class="crumb" @click="handleClearProject">
|
||||||
|
{{ state.route === 'sessions' ? 'Sessions' : 'Memory' }}
|
||||||
|
</button>
|
||||||
<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>
|
||||||
</div>
|
<template v-else>
|
||||||
<div class="spacer"></div>
|
<span class="crumb terminal">
|
||||||
<div id="search-wrap" class="search-wrap">
|
{{ state.route === 'sessions' ? 'Sessions' : state.route === 'memory' ? 'Memory' : 'Activity' }}
|
||||||
<input
|
</span>
|
||||||
id="search"
|
</template>
|
||||||
type="text"
|
</template>
|
||||||
class="search-input"
|
<template v-else>
|
||||||
placeholder="Search..."
|
|
||||||
@input="handleSearch"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
v-if="showSearchMsgsToggle"
|
|
||||||
class="filter-toggle"
|
|
||||||
:class="{ active: state.includeMessageBodies }"
|
|
||||||
@click="handleToggleSearchMsgs"
|
|
||||||
title="Include message bodies in search"
|
|
||||||
>
|
|
||||||
Msgs
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
id="sort-toggle"
|
|
||||||
class="sort-group"
|
|
||||||
:class="{ desc: state.sortDesc, asc: !state.sortDesc }"
|
|
||||||
@click="handleToggleSort"
|
|
||||||
>
|
|
||||||
<span id="sort-label">{{ state.sortDesc ? 'newest' : 'oldest' }}</span>
|
|
||||||
</button>
|
|
||||||
</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'">
|
<router-link class="crumb" to="/sessions" v-if="route.name === 'SessionDetail' || route.name === 'SubagentDetail'">
|
||||||
Sessions
|
Sessions
|
||||||
</router-link>
|
</router-link>
|
||||||
@@ -326,115 +345,52 @@ const keepAliveIncludes = ['SessionDetail'];
|
|||||||
{{ (state.memories.find(m => m.id === route.params.id)?.path || '').split('/').pop() }}
|
{{ (state.memories.find(m => m.id === route.params.id)?.path || '').split('/').pop() }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<span v-if="route.name === 'Usage'" class="crumb terminal">Usage</span>
|
<span v-if="route.name === 'Activity'" class="crumb terminal">Activity</span>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="toolbar-spacer"></div>
|
||||||
|
<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
|
||||||
|
id="search"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search…"
|
||||||
|
autocomplete="off"
|
||||||
|
:value="state.query"
|
||||||
|
@input="handleSearch"
|
||||||
|
/>
|
||||||
|
<span class="toolbar-search-kbd">/</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="showToolbar"
|
||||||
|
class="sort-group"
|
||||||
|
:class="{ desc: state.sortDesc, asc: !state.sortDesc }"
|
||||||
|
@click="handleToggleSort"
|
||||||
|
id="sort-toggle"
|
||||||
|
title="Toggle sort (S)"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
</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>
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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]));
|
||||||
|
state.sessions = (rawSessions || []).map(s => {
|
||||||
|
const existing = existingSessions.get(s.id);
|
||||||
|
return {
|
||||||
...s,
|
...s,
|
||||||
messages: []
|
messages: existing?.messages?.length ? existing.messages : []
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
state.projects = projects || [];
|
state.projects = projects || [];
|
||||||
state.stats = stats || {};
|
state.stats = stats || {};
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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: '/',
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 };
|
||||||
|
|||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user