diff --git a/.gitignore b/.gitignore index 12ff5c9..d7bb805 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ plans/ tests/ node_modules/ dist-renderer/ +release/ diff --git a/README.md b/README.md index f1c0220..55403dd 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,12 @@ same SQLite data. - `references/schema.md` — full SQLite schema and API reference - `references/query-patterns.md` — copyable CodeAct recipes for common retrieval tasks - `references/retrieval-semantics.md` — query design frame for scoped and synthesis retrieval -- `references/recap-patterns.md` — optional `/obelisk recap` card content contract +- `references/recap/overview.md` — optional `/obelisk recap` card-by-card entrypoint +- `references/recap/pattern1-cover.md` and `references/recap/writing1-cover.md` +- `references/recap/pattern2-thinking.md` and `references/recap/writing2-thinking.md` +- `references/recap/pattern3-vibe.md` and `references/recap/writing3-vibe.md` +- `references/recap/pattern4-workflow.md` and `references/recap/writing4-workflow.md` +- `references/recap/pattern5-closing.md` and `references/recap/writing5-closing.md` - `references/pitfalls.md` — scope, FTS, ordering, compact/raw, and field-name traps The executable SQLite schema lives in `scripts/schema.sql`; `references/schema.md` @@ -133,8 +138,11 @@ is the human/agent explanation of that contract. 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 schema stay out of the first prompt until the agent needs them. -The optional recap reference is only for the explicit `/obelisk recap` intent; -it is not part of the ordinary retrieval path. +The optional recap references are only for the explicit `/obelisk recap` intent; +they are not part of the ordinary retrieval path. `references/recap/overview.md` +drives a card-by-card loop: read one card's retrieval pattern, gather that +card's evidence, read its writing reference, update the JSON, then continue. +This keeps schema, taste, and query planning from competing in one large prompt. ## What gets indexed @@ -162,7 +170,20 @@ Full-text search via FTS5 covers message text across every session layer and ran ├── schema.md # Full table schema + advanced API reference ├── query-patterns.md # Copyable retrieval recipes ├── retrieval-semantics.md # Query design frame for retrieval semantics - ├── recap-patterns.md # Optional /obelisk recap card content contract + ├── recap-patterns.md # Compatibility pointer to references/recap/overview.md + ├── recap-writing.md # Compatibility pointer to per-card recap writing docs + ├── recap/ + │ ├── overview.md + │ ├── pattern1-cover.md + │ ├── writing1-cover.md + │ ├── pattern2-thinking.md + │ ├── writing2-thinking.md + │ ├── pattern3-vibe.md + │ ├── writing3-vibe.md + │ ├── pattern4-workflow.md + │ ├── writing4-workflow.md + │ ├── pattern5-closing.md + │ └── writing5-closing.md └── pitfalls.md # Scope, FTS, ordering, and compactness traps ``` diff --git a/SKILL.md b/SKILL.md index ed07d29..e2e4a7c 100644 --- a/SKILL.md +++ b/SKILL.md @@ -79,11 +79,11 @@ output intent, not retrieval architecture. | Intent | Description | Reference | |---|---|---| -| `recap [target]` | Generate weekly/monthly recap card content for app handoff or share-style output. | `references/recap-patterns.md` | +| `recap [target]` | Generate weekly/monthly recap card content for app handoff or share-style output. | `references/recap/overview.md` | Routing rules: -1. If the first word is `recap`, read `references/recap-patterns.md` before the +1. If the first word is `recap`, read `references/recap/overview.md` before the first query. Everything after `recap` is the recap target. Common app-generated prompts include `/obelisk recap this week`, `/obelisk recap last week`, `/obelisk recap this month`, and @@ -91,8 +91,12 @@ Routing rules: relative to the current date and timezone. 2. `recap` does not create a separate retrieval layer. It still uses `overview()`, `memories()`, helpers, and `sql()` only when needed. -3. If the first word is not `recap`, do not load - `references/recap-patterns.md`. Continue with Query Routing below. Do not +3. Follow the overview's card-by-card sequence. Each card has its own retrieval + pattern and writing file; retrieve that card's evidence, read that card's + writing file, update the JSON, then move to the next card. Do not preload all + recap references before the current card is written. +4. If the first word is not `recap`, do not load + `references/recap/overview.md`. Continue with Query Routing below. Do not infer recap from broad requests for weekly/monthly summaries, charts, rankings, shareable cards, or playlist-style metaphors. diff --git a/app/build/icon.icns b/app/build/icon.icns new file mode 100644 index 0000000..621857f Binary files /dev/null and b/app/build/icon.icns differ diff --git a/app/build/icon.png b/app/build/icon.png new file mode 100644 index 0000000..4d1ec49 Binary files /dev/null and b/app/build/icon.png differ diff --git a/app/icon.png b/app/icon.png new file mode 100644 index 0000000..4d1ec49 Binary files /dev/null and b/app/icon.png differ diff --git a/app/indexer.js b/app/indexer.js index f29304e..b55dbed 100644 --- a/app/indexer.js +++ b/app/indexer.js @@ -89,7 +89,7 @@ function extractContentType(content) { return !sawUnknown && types.size === 1 ? [...types][0] : 'unknown'; } -const COMMAND_ENVELOPE_RE = /^\s*([^<]+<\/command-name>|||)/; +const COMMAND_ENVELOPE_RE = /^\s*([^<]+<\/command-name>|<(?:task-notification|system-reminder)\b|= 3) return parts[1] || null; + return null; +} + +function dedupeFileInfos(files) { + const byPath = new Map(); + for (const file of files) byPath.set(file.path, file); + return [...byPath.values()]; +} + +function discoverJsonlFilesForChanges({ projectsDir = DEFAULT_PROJECTS_DIR, changedPaths = [] } = {}) { + const files = []; + for (const changedPath of changedPaths) { + const info = jsonlFileInfoFromPath(projectsDir, changedPath); + if (info) files.push(info); + } + return dedupeFileInfos(files); +} + +function discoverJsonlFilesFull({ projectsDir = DEFAULT_PROJECTS_DIR } = {}) { const files = []; if (!fs.existsSync(projectsDir)) return files; let projects; @@ -195,7 +265,26 @@ function indexJsonl(db, fi) { 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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'), + msg: db.prepare(` + INSERT 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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(uuid) DO UPDATE SET + session_id=excluded.session_id, + type=excluded.type, + parent_uuid=excluded.parent_uuid, + timestamp=excluded.timestamp, + role=excluded.role, + text=excluded.text, + content_type=excluded.content_type, + is_meta=excluded.is_meta, + model=excluded.model, + is_sidechain=excluded.is_sidechain, + agent_id=excluded.agent_id, + input_tokens=excluded.input_tokens, + output_tokens=excluded.output_tokens, + cwd=excluded.cwd, + skill=excluded.skill + `), 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 (?,?,?,?,?)'), @@ -208,7 +297,7 @@ function indexJsonl(db, fi) { git_branch: existing?.git_branch || null, version: existing?.version || null, title: existing?.title || null, - n: existing?.message_count || 0, + n: skip > 0 ? (existing?.message_count || 0) : 0, cwds: [], }; @@ -270,6 +359,7 @@ function indexJsonl(db, fi) { 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); + return { sessionId: fi.sessionId, path: fi.path }; } function refreshSessionProjectPaths(db) { @@ -362,6 +452,15 @@ function rebuildFts(db) { db.exec("INSERT INTO memories_fts(memories_fts) VALUES('rebuild')"); } +function ensureFtsReady(db, { force = false } = {}) { + const marker = '__fts_triggers_ready__'; + const ready = db.prepare('SELECT jsonl_path FROM index_state WHERE jsonl_path = ?').get(marker); + if (ready && !force) return false; + rebuildFts(db); + writeIndexMarker(db, marker); + return true; +} + 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); } @@ -384,9 +483,10 @@ function buildIndex({ schemaPath = resolveSchemaPath(), DatabaseImpl = Database, force = false, + changedPaths = undefined, } = {}) { const db = openIndexDb({ dbPath, schemaPath, DatabaseImpl }); - const files = discoverJsonlFiles({ projectsDir }); + const files = discoverJsonlFiles({ projectsDir, changedPaths: force ? undefined : changedPaths }); const latestSourceMtime = files.reduce((latest, file) => { try { return Math.max(latest, fs.statSync(file.path).mtimeMs); @@ -396,11 +496,29 @@ function buildIndex({ }, 0); try { - if (force) db.prepare("DELETE FROM index_state WHERE jsonl_path NOT LIKE '__%'").run(); + if (force) { + db.prepare("DELETE FROM index_state WHERE substr(jsonl_path, 1, 2) != '__'").run(); + db.prepare("DELETE FROM messages").run(); + db.prepare("DELETE FROM tool_calls").run(); + db.prepare("DELETE FROM tool_results").run(); + db.prepare("DELETE FROM sessions").run(); + db.prepare("DELETE FROM summaries").run(); + db.prepare("DELETE FROM subagents").run(); + db.prepare("DELETE FROM workflows").run(); + db.prepare("DELETE FROM workflow_agents").run(); + } + const affectedSessionIds = new Set(); + if (Array.isArray(changedPaths)) { + for (const changedPath of changedPaths) { + const sessionId = sessionIdFromChangedPath(projectsDir, changedPath); + if (sessionId) affectedSessionIds.add(sessionId); + } + } for (const file of files) { db.exec('BEGIN'); try { - indexJsonl(db, file); + const indexed = indexJsonl(db, file); + if (indexed?.sessionId) affectedSessionIds.add(indexed.sessionId); indexSubagentMeta(db, file); db.exec('COMMIT'); } catch (error) { @@ -409,11 +527,12 @@ function buildIndex({ } } db.exec('BEGIN'); + let ftsRebuilt = false; try { indexWorkflows(db, { projectsDir }); refreshSessionProjectPaths(db); indexHistory(db, { historyPath }); - rebuildFts(db); + ftsRebuilt = ensureFtsReady(db, { force }); writeIndexMarker(db, '__last_build__'); writeIndexMarker(db, '__app_heartbeat__'); writeIndexMarker(db, '__app_last_successful_build__'); @@ -424,7 +543,7 @@ function buildIndex({ db.exec('ROLLBACK'); throw error; } - return { files: files.length, latestSourceMtime }; + return { files: files.length, latestSourceMtime, affectedSessionIds: [...affectedSessionIds], ftsRebuilt }; } finally { db.close(); } diff --git a/app/main.js b/app/main.js index 86f7ad5..7993f4e 100644 --- a/app/main.js +++ b/app/main.js @@ -6,42 +6,133 @@ const Database = require('better-sqlite3'); const { writeHeartbeat } = require('./indexer'); const { createIndexerService } = require('./indexer-service'); const { createWorkerBuildIndex } = require('./indexer-worker-client'); +const { buildRecapExportQuery } = require('./recap-capture-query'); -const DB_PATH = path.join(os.homedir(), '.claude', 'obelisk.sqlite'); +function detectClaudeDir() { + // macOS / Linux: ~/.claude + if (process.platform !== 'win32') { + return path.join(os.homedir(), '.claude'); + } + // Windows: Claude Code runs in WSL, data lives at \\wsl.localhost\\home\\.claude + const distros = ['Ubuntu', 'Ubuntu-24.04', 'Ubuntu-22.04', 'Debian', 'openSUSE-Leap', 'kali-linux']; + for (const distro of distros) { + const homePath = path.join('\\\\wsl.localhost', distro, 'home'); + if (!fs.existsSync(homePath)) continue; + try { + const users = fs.readdirSync(homePath); + for (const user of users) { + const claudeDir = path.join(homePath, user, '.claude'); + if (fs.existsSync(claudeDir)) return claudeDir; + } + } catch {} + } + // Fallback: native Windows path (for future native Claude Code on Windows) + return path.join(os.homedir(), '.claude'); +} + +const DEFAULT_CLAUDE_DIR = detectClaudeDir(); let db; let indexerService; let indexerWorker; -function openDb() { - if (!fs.existsSync(DB_PATH)) return null; +function getConfiguredClaudeDir() { + const persisted = loadPersistedSettings(); + return persisted.claudeDir || DEFAULT_CLAUDE_DIR; +} + +function getPathsForClaudeDir(claudeDir = getConfiguredClaudeDir()) { + return { + claudeDir, + dbPath: path.join(claudeDir, 'obelisk.sqlite'), + projectsDir: path.join(claudeDir, 'projects'), + }; +} + +function closeDb() { if (db) db.close(); - db = new Database(DB_PATH, { readonly: false }); + db = null; +} + +function openDb(dbPath = getPathsForClaudeDir().dbPath) { + closeDb(); + if (!fs.existsSync(dbPath)) return null; + db = new Database(dbPath, { readonly: false }); db.pragma('journal_mode = WAL'); return db; } -function notifyIndexUpdated() { +function notifyIndexUpdated(result = {}) { + const affectedSessionIds = Array.isArray(result.affectedSessionIds) + ? [...new Set(result.affectedSessionIds.filter(Boolean))] + : []; + const payload = { affectedSessionIds }; for (const win of BrowserWindow.getAllWindows()) { - win.webContents.send('obelisk:index-updated'); + win.webContents.send('obelisk:index-updated', payload); + for (const sessionId of affectedSessionIds) { + win.webContents.send('obelisk:session-updated', { sessionId }); + } } } -function startIndexerService() { +function startIndexerService({ buildOnStart = false } = {}) { + const paths = getPathsForClaudeDir(); indexerService = createIndexerService({ - buildIndex: async ({ reason }) => { - const result = await indexerWorker.buildIndex({ reason }); - openDb(); - notifyIndexUpdated(); + projectsDir: paths.projectsDir, + buildIndex: async ({ reason, changedPaths }) => { + const result = await indexerWorker.buildIndex({ + reason, + changedPaths, + claudeDir: paths.claudeDir, + projectsDir: paths.projectsDir, + dbPath: paths.dbPath, + }); + openDb(paths.dbPath); + notifyIndexUpdated(result); return result; }, - writeHeartbeat, + writeHeartbeat: () => writeHeartbeat({ dbPath: paths.dbPath }), }); - indexerService.start({ buildOnStart: false }); + indexerService.start({ buildOnStart }); return indexerService; } +function startBackgroundResources({ runStartupBuild = false } = {}) { + if (!indexerWorker) indexerWorker = createWorkerBuildIndex(); + openDb(); + if (!indexerService) { + const service = startIndexerService({ buildOnStart: false }); + if (runStartupBuild) service.runBuildNow('startup'); + } + if (!obeliskWatcher) startObeliskWatcher(); +} + +async function stopIndexerServiceAndWait() { + const service = indexerService; + if (!service) return; + service.stop(); + if (typeof service.idle === 'function') await service.idle(); + if (indexerService === service) indexerService = null; +} + +async function stopBackgroundResources({ stopWorker = false } = {}) { + await stopIndexerServiceAndWait(); + if (stopWorker && indexerWorker) { + indexerWorker.stop(); + indexerWorker = null; + } + if (obeliskWatcher) { + const watcher = obeliskWatcher; + obeliskWatcher = null; + if (typeof watcher.close === 'function') await Promise.resolve(watcher.close()); + } + closeDb(); +} + function createWindow() { + const isDev = process.argv.includes('--dev'); + const shouldOpenDevTools = process.argv.includes('--devtools'); + const win = new BrowserWindow({ width: 1200, height: 800, @@ -54,6 +145,7 @@ function createWindow() { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, + devTools: isDev || shouldOpenDevTools, }, }); @@ -64,10 +156,11 @@ function createWindow() { } }); - const isDev = process.argv.includes('--dev'); if (isDev) { win.loadURL('http://localhost:5173'); - win.webContents.openDevTools(); + if (shouldOpenDevTools) { + win.webContents.openDevTools(); + } } else { win.loadFile(path.join(__dirname, 'dist-renderer', 'index.html')); } @@ -78,6 +171,7 @@ const RECAP_DIR = path.join(OBELISK_DIR, 'recap'); let obeliskWatcher = null; function startObeliskWatcher() { + if (obeliskWatcher) return obeliskWatcher; const chokidar = require('chokidar'); if (!fs.existsSync(OBELISK_DIR)) { fs.mkdirSync(OBELISK_DIR, { recursive: true }); @@ -94,6 +188,7 @@ function startObeliskWatcher() { obeliskWatcher.on('add', onObeliskChange); obeliskWatcher.on('change', onObeliskChange); obeliskWatcher.on('unlink', onObeliskChange); + return obeliskWatcher; } function onObeliskChange(filePath) { @@ -105,25 +200,23 @@ function onObeliskChange(filePath) { } app.whenReady().then(() => { - indexerWorker = createWorkerBuildIndex(); - openDb(); + startBackgroundResources({ runStartupBuild: true }); createWindow(); - startIndexerService().runBuildNow('startup'); - startObeliskWatcher(); app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) createWindow(); + if (BrowserWindow.getAllWindows().length === 0) { + startBackgroundResources({ runStartupBuild: true }); + createWindow(); + } }); }); app.on('before-quit', () => { - if (indexerService) indexerService.stop(); - if (indexerWorker) indexerWorker.stop(); - if (obeliskWatcher) obeliskWatcher.close(); + void stopBackgroundResources({ stopWorker: true }); }); app.on('window-all-closed', () => { - if (db) db.close(); + void stopBackgroundResources({ stopWorker: true }); if (process.platform !== 'darwin') app.quit(); }); @@ -366,7 +459,7 @@ async function createExportCapture(parentWin, query) { : `file://${path.join(__dirname, 'dist-renderer', 'index.html')}#/recap-export?${query}`; await exportWin.loadURL(url); - await new Promise(r => setTimeout(r, 500)); + await waitForExportReady(exportWin.webContents); const image = await exportWin.webContents.capturePage({ x: 0, y: 0, width: EXPORT_WIDTH, height: EXPORT_HEIGHT, @@ -375,10 +468,22 @@ async function createExportCapture(parentWin, query) { return image; } -ipcMain.handle('capture:export', async (event, { cardIdx, archetype }) => { +async function waitForExportReady(webContents, timeoutMs = 2500) { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + try { + const ready = await webContents.executeJavaScript('window.__OBELISK_RECAP_EXPORT_READY__ === true', true); + if (ready) return true; + } catch {} + await new Promise(r => setTimeout(r, 50)); + } + return false; +} + +ipcMain.handle('capture:export', async (event, { cardIdx, archetype, filename } = {}) => { const win = BrowserWindow.fromWebContents(event.sender); if (!win) return null; - const query = `card=${cardIdx}&arch=${archetype}`; + const query = buildRecapExportQuery({ cardIdx, archetype, filename }); const image = await createExportCapture(win, query); const { filePath } = await dialog.showSaveDialog(win, { defaultPath: `obelisk-recap-${cardIdx + 1}.png`, @@ -389,10 +494,10 @@ ipcMain.handle('capture:export', async (event, { cardIdx, archetype }) => { return filePath; }); -ipcMain.handle('capture:copy', async (event, { cardIdx, archetype }) => { +ipcMain.handle('capture:copy', async (event, { cardIdx, archetype, filename } = {}) => { const win = BrowserWindow.fromWebContents(event.sender); if (!win) return false; - const query = `card=${cardIdx}&arch=${archetype}`; + const query = buildRecapExportQuery({ cardIdx, archetype, filename }); const image = await createExportCapture(win, query); clipboard.writeImage(image); return true; @@ -415,3 +520,117 @@ ipcMain.handle('recap:read', (_, filename) => { return JSON.parse(fs.readFileSync(filePath, 'utf-8')); } catch { return null; } }); + +// --- Settings --- + +const SETTINGS_PATH = path.join(OBELISK_DIR, 'settings.json'); + +function loadPersistedSettings() { + try { + if (fs.existsSync(SETTINGS_PATH)) return JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf-8')); + } catch {} + return {}; +} + +function savePersistedSettings(settings) { + if (!fs.existsSync(OBELISK_DIR)) fs.mkdirSync(OBELISK_DIR, { recursive: true }); + fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2)); +} + +ipcMain.handle('settings:get', () => { + const persisted = loadPersistedSettings(); + const { claudeDir, dbPath: dbFile } = getPathsForClaudeDir(persisted.claudeDir || DEFAULT_CLAUDE_DIR); + const recapDir = persisted.recapDir || RECAP_DIR; + const exists = fs.existsSync(claudeDir); + let sessionCount = 0; + let memoryCount = 0; + let lastIndexed = ''; + + if (db) { + try { + sessionCount = db.prepare('SELECT COUNT(*) as c FROM sessions').get()?.c || 0; + memoryCount = db.prepare('SELECT COUNT(*) as c FROM memories WHERE deleted_at IS NULL').get()?.c || 0; + const latest = db.prepare('SELECT MAX(started_at) as t FROM sessions').get(); + lastIndexed = latest?.t || ''; + } catch {} + } + + return { + claudeDir, + dbPath: dbFile, + recapDir, + autoRefresh: persisted.autoRefresh !== false, + sessionCount, + memoryCount, + lastIndexed, + status: exists ? 'ok' : 'error', + statusText: exists ? 'Connected' : 'Folder not found', + }; +}); + +ipcMain.handle('settings:set', async (_, key, value) => { + const persisted = loadPersistedSettings(); + if (value === null) { + delete persisted[key]; + } else { + persisted[key] = value; + } + savePersistedSettings(persisted); + + if (key === 'autoRefresh') { + if (value === false && indexerService) { + await stopIndexerServiceAndWait(); + } else if (value !== false && indexerService) { + await stopIndexerServiceAndWait(); + startIndexerService({ buildOnStart: false }); + } + } + + if (key === 'claudeDir') { + await stopIndexerServiceAndWait(); + openDb(); + if (persisted.autoRefresh !== false) { + startIndexerService({ buildOnStart: true }); + } + notifyIndexUpdated(); + } + return true; +}); + +ipcMain.handle('settings:browseFolder', async (event) => { + const win = BrowserWindow.fromWebContents(event.sender); + const { filePaths } = await dialog.showOpenDialog(win, { + properties: ['openDirectory'], + title: 'Select Claude Code data folder', + }); + if (filePaths && filePaths[0]) return filePaths[0]; + return null; +}); + +ipcMain.handle('settings:revealPath', (_, p) => { + const { shell } = require('electron'); + if (fs.existsSync(p)) shell.showItemInFolder(p); +}); + +ipcMain.handle('settings:rebuildIndex', async () => { + if (!indexerWorker) return null; + const persisted = loadPersistedSettings(); + const paths = getPathsForClaudeDir(persisted.claudeDir || DEFAULT_CLAUDE_DIR); + const shouldRestartWatcher = persisted.autoRefresh !== false; + await stopIndexerServiceAndWait(); + closeDb(); + try { + const result = await indexerWorker.buildIndex({ + reason: 'manual-rebuild', + force: true, + claudeDir: paths.claudeDir, + projectsDir: paths.projectsDir, + dbPath: paths.dbPath, + }); + openDb(paths.dbPath); + notifyIndexUpdated(result); + return result; + } finally { + if (shouldRestartWatcher) startIndexerService({ buildOnStart: false }); + } +}); diff --git a/app/package.json b/app/package.json index 6b41ef8..6f1548d 100644 --- a/app/package.json +++ b/app/package.json @@ -8,26 +8,51 @@ "dev": "electron . --dev", "dev:renderer": "vite renderer", "build:renderer": "vite build renderer", - "build": "electron-builder" + "build": "npm run build:renderer && electron-builder", + "pack": "npm run build:renderer && electron-builder --dir", + "dist": "npm run build:renderer && electron-builder --mac --win --linux", + "dist:mac": "npm run build:renderer && electron-builder --mac", + "dist:win": "npm run build:renderer && electron-builder --win", + "dist:linux": "npm run build:renderer && electron-builder --linux" }, "build": { "appId": "com.obelisk.app", "productName": "Obelisk", + "artifactName": "${productName}-${version}-${os}-${arch}.${ext}", + "directories": { + "output": "release" + }, "mac": { - "target": "dmg", + "icon": "build/icon.icns", + "target": [ + "dmg", + "zip" + ], "category": "public.app-category.developer-tools" }, + "win": { + "target": [ + "nsis", + "portable" + ] + }, + "linux": { + "icon": "build/icon.png", + "target": [ + "AppImage", + "deb" + ], + "category": "Development" + }, "files": [ "main.js", "preload.js", + "recap-capture-query.js", "indexer.js", "indexer-service.js", "indexer-worker.js", "indexer-worker-client.js", - "dist-renderer/**/*", - "node_modules/better-sqlite3/**/*", - "node_modules/chokidar/**/*", - "node_modules/readdirp/**/*" + "dist-renderer/**/*" ], "extraResources": [ { diff --git a/app/preload.js b/app/preload.js index 86e709b..cc9e3a3 100644 --- a/app/preload.js +++ b/app/preload.js @@ -20,10 +20,15 @@ contextBridge.exposeInMainWorld('obelisk', { getStats: () => ipcRenderer.invoke('db:getStats'), getUsageStats: () => ipcRenderer.invoke('db:getUsageStats'), onIndexUpdated: (callback) => { - const listener = () => callback(); + const listener = (_, payload) => callback(payload); ipcRenderer.on('obelisk:index-updated', listener); return () => ipcRenderer.removeListener('obelisk:index-updated', listener); }, + onSessionUpdated: (callback) => { + const listener = (_, payload) => callback(payload); + ipcRenderer.on('obelisk:session-updated', listener); + return () => ipcRenderer.removeListener('obelisk:session-updated', listener); + }, captureExport: (opts) => ipcRenderer.invoke('capture:export', opts), copyImage: (opts) => ipcRenderer.invoke('capture:copy', opts), recapList: () => ipcRenderer.invoke('recap:list'), @@ -33,4 +38,9 @@ contextBridge.exposeInMainWorld('obelisk', { ipcRenderer.on('obelisk:recap-updated', listener); return () => ipcRenderer.removeListener('obelisk:recap-updated', listener); }, + getSettings: () => ipcRenderer.invoke('settings:get'), + browseFolder: () => ipcRenderer.invoke('settings:browseFolder'), + setSetting: (key, value) => ipcRenderer.invoke('settings:set', key, value), + revealPath: (p) => ipcRenderer.invoke('settings:revealPath', p), + rebuildIndex: () => ipcRenderer.invoke('settings:rebuildIndex'), }); diff --git a/app/recap-capture-query.js b/app/recap-capture-query.js new file mode 100644 index 0000000..ea39c3b --- /dev/null +++ b/app/recap-capture-query.js @@ -0,0 +1,18 @@ +const path = require('path'); + +function cleanRecapFilename(filename) { + if (!filename) return ''; + return path.basename(String(filename)); +} + +function buildRecapExportQuery({ cardIdx = 0, archetype = '', filename = '' } = {}) { + const params = new URLSearchParams(); + const cardNumber = Number(cardIdx); + params.set('card', Number.isFinite(cardNumber) ? String(cardNumber) : '0'); + if (archetype) params.set('arch', String(archetype)); + const safeFilename = cleanRecapFilename(filename); + if (safeFilename) params.set('file', safeFilename); + return params.toString(); +} + +module.exports = { buildRecapExportQuery, cleanRecapFilename }; diff --git a/app/renderer/src/App.vue b/app/renderer/src/App.vue index 40e16cd..161dd16 100644 --- a/app/renderer/src/App.vue +++ b/app/renderer/src/App.vue @@ -14,6 +14,7 @@ import { toggleIncludeMessageBodies } from './store.js'; import { formatProjectLabel } from './utils.js'; +import { buildSidebarProjects } from './sidebar-projects.mjs'; const router = useRouter(); const route = useRoute(); @@ -30,38 +31,24 @@ const currentRouteType = computed(() => { if (name === 'SessionList' || name === 'SessionDetail' || name === 'SubagentDetail') return 'sessions'; if (name === 'Activity') return 'activity'; if (name === 'Recap' || name === 'RecapDetail') return 'recap'; + if (name === 'Settings') return 'settings'; return 'memory'; }); -const sidebarProjects = computed(() => { - const items = currentRouteType.value === 'sessions' ? state.sessions : state.memories; - const filtered = items.filter(item => { - if (currentRouteType.value === 'sessions') return true; - return state.view === 'archived' ? item.archived : !item.archived; - }); - let projects = [...new Set(filtered.map(item => item.project).filter(Boolean))]; - if (state.projectSearch) { - const q = state.projectSearch.toLowerCase(); - projects = projects.filter(p => formatProjectLabel(p).toLowerCase().includes(q)); - } - projects.sort((a, b) => formatProjectLabel(a).localeCompare(formatProjectLabel(b))); - - // Count per project - const counts = {}; - for (const item of filtered) { - if (item.project) counts[item.project] = (counts[item.project] || 0) + 1; - } - - return projects.map(p => ({ - slug: p, - label: formatProjectLabel(p), - count: counts[p] || 0 - })); +const sidebarProjectsForCurrentScope = (search = state.projectSearch) => buildSidebarProjects({ + routeType: currentRouteType.value, + sessions: state.sessions, + memories: state.memories, + projects: state.projects, + view: state.view, + search, + formatProjectLabel, }); +const sidebarProjects = computed(() => sidebarProjectsForCurrentScope()); + const totalProjectCount = computed(() => { - const items = currentRouteType.value === 'sessions' ? state.sessions : state.memories; - return new Set(items.map(i => i.project).filter(Boolean)).size; + return sidebarProjectsForCurrentScope('').length; }); // --- Toolbar visibility --- @@ -86,6 +73,8 @@ const windowTitle = computed(() => { scopeText = 'Recap'; } else if (route.name === 'RecapDetail') { scopeText = `Recap · ${route.params.id}`; + } else if (route.name === 'Settings') { + scopeText = 'Settings'; } else if (route.name?.startsWith('Session')) { if (route.name === 'SessionDetail' || route.name === 'SubagentDetail') { const s = state.sessions.find(x => x.id === route.params.id); @@ -220,7 +209,7 @@ provide('recapGenerateOpen', recapGenerateOpen); + +
@@ -374,6 +381,7 @@ provide('recapGenerateOpen', recapGenerateOpen); Activity Recap + Settings Recap diff --git a/app/renderer/src/components/recap/ClosingCard.vue b/app/renderer/src/components/recap/ClosingCard.vue index 9e91ece..2eb4c34 100644 --- a/app/renderer/src/components/recap/ClosingCard.vue +++ b/app/renderer/src/components/recap/ClosingCard.vue @@ -1,6 +1,7 @@ diff --git a/app/renderer/src/views/RecapList.vue b/app/renderer/src/views/RecapList.vue index 40b23e7..bdc5a98 100644 --- a/app/renderer/src/views/RecapList.vue +++ b/app/renderer/src/views/RecapList.vue @@ -2,7 +2,7 @@ import { ref, computed, onMounted, onUnmounted, inject } from 'vue'; import { useRouter, useRoute } from 'vue-router'; import { PALETTES, ARCH_KEYS } from '../components/recap/archetypes.js'; -import { MINI_SEALS } from '../components/recap/seals.js'; +import { CORNER_SEALS } from '../components/recap/seals.js'; defineOptions({ name: 'RecapList' }); @@ -27,7 +27,7 @@ function glowColor(arch) { return PALETTES[arch]?.glow || PALETTES.architect.glow; } function sealSvg(arch) { - return MINI_SEALS[arch] || MINI_SEALS.architect; + return CORNER_SEALS[arch] || CORNER_SEALS.architect; } function formatDateRange(r) { if (!r.period) return ''; @@ -116,7 +116,7 @@ onUnmounted(() => { unsub?.(); }); {{ formatDateRange(r) }}
{{ r.persona?.title }}
-
{{ r.persona?.subtitle }}
+
{{ r.persona?.claim || r.persona?.subtitle }}
{{ r.metrics?.sessions || 0 }} sessions · @@ -266,7 +266,7 @@ onUnmounted(() => { unsub?.(); }); .timeline { position: relative; } .timeline::before { content: ''; position: absolute; - left: 15px; top: 15px; bottom: 15px; + left: 32px; top: 32px; bottom: 32px; width: 1px; margin-left: -0.5px; background: linear-gradient(to bottom, rgba(167,139,250,0.55) 0%, rgba(167,139,250,0.35) 8%, @@ -276,19 +276,19 @@ onUnmounted(() => { unsub?.(); }); .recap-row { position: relative; display: grid; - grid-template-columns: 30px 1fr; - column-gap: 28px; align-items: center; + grid-template-columns: 64px 1fr; + column-gap: 18px; align-items: center; padding: 12px 0; cursor: pointer; transition: transform 0.12s; } .recap-row:hover { transform: translateX(2px); } .recap-node { - width: 30px; height: 30px; + width: 64px; height: 64px; position: relative; z-index: 2; } .recap-node::before { - content: ''; position: absolute; inset: -3px; + content: ''; position: absolute; inset: -2px; border-radius: 50%; background: var(--bg); z-index: -1; } .recap-node :deep(svg) { diff --git a/app/renderer/src/views/SessionDetail.vue b/app/renderer/src/views/SessionDetail.vue index 6cd3846..005fad2 100644 --- a/app/renderer/src/views/SessionDetail.vue +++ b/app/renderer/src/views/SessionDetail.vue @@ -1,8 +1,9 @@ + + + + diff --git a/app/renderer/styles/detail.css b/app/renderer/styles/detail.css index 91f7892..147b16d 100644 --- a/app/renderer/styles/detail.css +++ b/app/renderer/styles/detail.css @@ -1165,7 +1165,7 @@ /* Back to top floating button */ /* Message pagination nav */ .msg-nav { - position: fixed; bottom: 40px; + position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); display: flex; align-items: center; gap: 4px; padding: 5px 8px; diff --git a/app/renderer/styles/sidebar.css b/app/renderer/styles/sidebar.css index 408fbde..fd1a78c 100644 --- a/app/renderer/styles/sidebar.css +++ b/app/renderer/styles/sidebar.css @@ -15,6 +15,8 @@ .sidebar-section { padding: 8px 6px; flex-shrink: 0; } .sidebar-section.projects { flex: 1; min-height: 0; display: flex; flex-direction: column; padding-bottom: 0; } .sidebar-section + .sidebar-section { border-top: 1px solid var(--hairline); } +.sidebar-spacer { flex: 1; min-height: 0; } +.sidebar-bottom { margin-top: auto; } .sidebar-section-title { padding: 4px 10px 6px; font-size: 10.5px; color: var(--muted); diff --git a/panel.html b/panel.html deleted file mode 100644 index 4eb02b9..0000000 --- a/panel.html +++ /dev/null @@ -1,737 +0,0 @@ - - - - - - -Obelisk — Memory - - - -
-
-
-
-

Memory

-
-
Let Claude Code search its own memory.
-
-
0active
-
0archived
-
0projects
-
-
- -
-
Active 0
-
Archived 0
-
- -
-
- -
- - Undo -
- - - - diff --git a/references/recap-patterns.md b/references/recap-patterns.md index ee518bc..9781d87 100644 --- a/references/recap-patterns.md +++ b/references/recap-patterns.md @@ -1,508 +1,9 @@ -# Obelisk Recap Patterns +# Obelisk Recap Retrieval Patterns -Use this when Obelisk Intent Routing sends the `recap` intent. In practice, -that means the first word after `/obelisk` is `recap`; everything after it is -the recap target. +Compatibility pointer. -Do not self-trigger this reference from broad weekly/monthly summaries, charts, -rankings, shareable-card language, or playlist-style metaphors. Those are target -details only after the explicit `recap` intent has already selected this file. - -This is an optional app handoff pattern. Obelisk stays agent-first: the agent -queries sessions and memories, judges what matters, then fills card content. -The app owns rendering, layout, export, and animation. - -## Common Period Targets - -Treat the target after `recap` as normal user language. Common app-generated -targets: - -- `/obelisk recap this week` -- the current calendar week in the user's timezone. -- `/obelisk recap last week` -- the previous calendar week. -- `/obelisk recap this month` -- the current calendar month. -- `/obelisk recap last month` -- the previous calendar month. - -Use the current date and timezone from the runtime/session context when -available. If the target contains extra style language, keep the period meaning -and treat the rest as presentation guidance. - -## Contract - -- Produce card content, not HTML, CSS, SVG, or layout instructions. -- Keep the first result share-safe. Avoid secrets, tokens, private URLs, long - absolute paths, raw tool outputs, and embarrassing private text unless the - user asked for private analysis. -- Concrete numbers, exact times, quotes, and verdicts need evidence. If you - cannot support a detail, omit it or soften it. -- Memory is prior notes, not ground truth. If a card uses a memory conclusion, - say in prose that it came from a prior memory when answering in chat, and add - `memory_id` evidence when producing JSON. -- Do not propose a durable memory just because you generated a recap. Propose - memory only if the retrieval reveals a reusable cross-session conclusion not - already covered by `memories()`. - -## Retrieval Shape - -Start with orientation and bounded evidence. Use English memory queries even -when the recap text will be in another language. - -```js -const period = { - label: 'Week 24', - after: '2026-06-08T00:00:00+08:00', - before: '2026-06-15T00:00:00+08:00', - timezone: 'Asia/Shanghai', -}; - -const map = overview({ limit: 8 }); -const project = map.current.project?.project; -const scoped = project ? { project } : {}; - -return { - current: map.current, - current_project: map.current_project, - prior_memories: memories({ - ...scoped, - query: 'weekly recap project decisions workflows debugging shipping', - limit: 8, - }).map(m => ({ - id: m.id, - path: m.path, - anchors: m.anchors, - session_id: m.session_id, - created_at: m.created_at, - summary: m.summary?.slice(0, 280), - })), - sessions: sessions({ - ...scoped, - after: period.after, - before: period.before, - limit: 40, - }).map(s => ({ - id: s.id, - title: s.title, - project: s.project, - branch: s.git_branch, - started_at: s.started_at, - ended_at: s.ended_at, - message_count: s.message_count, - })), - summaries: summaries({ - ...scoped, - after: period.after, - before: period.before, - limit: 30, - }).map(s => ({ - id: s.id, - session_id: s.session_id, - session_title: s.session_title, - project: s.project, - timestamp: s.timestamp, - snippet: s.content?.slice(0, 260), - })), - workflows: workflows({ - ...scoped, - after: period.after, - before: period.before, - limit: 20, - }).map(w => ({ - run_id: w.run_id, - session_id: w.session_id, - name: w.workflow_name, - status: w.status, - agent_count: w.agent_count, - tokens: w.total_tokens, - timestamp: w.timestamp, - })), - failures: failures({ - ...scoped, - after: period.after, - before: period.before, - limit: 12, - }).map(f => ({ - session_id: f.session_id, - session_title: f.session_title, - tool: f.tool_name, - timestamp: f.timestamp, - snippet: f.content?.slice(0, 180), - })), -}; -``` - -Use SQL for exact aggregate numbers after the helper-first pass has established -the scope. Keep meta rows out of ordinary user-visible counts. - -```js -const project = '%quiet-zero%'; -const after = '2026-06-08T00:00:00+08:00'; -const before = '2026-06-15T00:00:00+08:00'; - -const metrics = sql(` - SELECT - COUNT(DISTINCT s.id) AS sessions, - COUNT(m.uuid) AS messages, - COALESCE(SUM(COALESCE(m.input_tokens, 0) + COALESCE(m.output_tokens, 0)), 0) AS tokens, - COUNT(DISTINCT substr(m.timestamp, 1, 10)) AS active_days - FROM sessions s - LEFT JOIN messages m ON m.session_id = s.id - WHERE s.project LIKE ? - AND COALESCE(m.is_meta, 0) = 0 - AND m.timestamp >= ? - AND m.timestamp < ? -`, project, after, before)[0]; - -const user_messages = sql(` - SELECT - m.uuid, - m.session_id, - s.title AS session_title, - m.timestamp, - substr(m.text, 1, 500) AS text - FROM messages m - JOIN sessions s ON s.id = m.session_id - WHERE s.project LIKE ? - AND m.type = 'user' - AND m.content_type = 'text' - AND COALESCE(m.is_meta, 0) = 0 - AND m.timestamp >= ? - AND m.timestamp < ? - ORDER BY m.timestamp - LIMIT 300 -`, project, after, before); - -return { metrics, user_messages }; -``` - -## Archetypes - -Choose one dominant archetype from the evidence. Do not force all cards to match -it; the archetype is the cover persona and tone baseline. - -| archetype | essential action | tone baseline | -|---|---|---| -| `architect` | establishes structure, boundaries, and systems from above | matter-of-fact, structural pride | -| `debugger` | loops through symptoms until root cause becomes visible | wry, weary, occasional dark humor | -| `shipper` | keeps pushing forward with dense cadence | energetic, slightly breathless | -| `curator` | collects, organizes, refines, and distills | reflective, low-key clarity | -| `director` | coordinates many threads from the center outward | observant, slight remove | -| `cartographer` | reorganizes known structure, redraws maps, moves boundaries | patient, surveyor-like | -| `wanderer` | crosses projects without one obvious center, leaving traces | gentle, accepting, no apology | - -Selection hints: - -- Use `architect` when the week is dominated by system design, APIs, schemas, - boundaries, or core concepts. -- Use `debugger` when repeated failures, false positives, regressions, or root - cause hunts dominate. -- Use `shipper` when the evidence shows sustained implementation velocity, - builds, releases, or many completed edits. -- Use `curator` when the work is mostly cleanup, documentation, memory, - organization, or taste/refinement. -- Use `director` when workflows, subagents, review loops, or multi-agent - coordination are the main story. -- Use `cartographer` when the user moves modules, redraws information - architecture, re-scopes boundaries, or turns "this lives here" into "this - belongs there". -- Use `wanderer` when the period spans many unrelated projects and the honest - story is exploratory rather than centered. - -## JSON Shape - -The schema is deliberately card-content oriented. Keep keys stable, but let -strings carry the style. Card text can use the user's language; field names stay -English. - -For weekly recaps, `metrics.active_days` and cover `activity` should contain 7 -numbers ordered Monday through Sunday, where `0` means no visible activity and -`1` means active. For monthly recaps, use one value per calendar day in the -period, or omit the field if the evidence is too thin. - -```ts -type Recap = { - schema_version: "obelisk.recap.v1"; - kind: "weekly" | "monthly"; - generated_at: string; - - period: { - label: string; - start: string; - end: string; - timezone: string; - }; - - source: { - project?: string; - session_ids: string[]; - memory_ids?: string[]; - }; - - metrics: { - sessions?: number; - messages?: number; - tokens?: number; - active_days?: number[]; - streak_days?: number; - workflows?: number; - workflow_agents?: number; - corrections?: number; - }; - - persona: { - archetype: - | "architect" - | "debugger" - | "shipper" - | "curator" - | "director" - | "cartographer" - | "wanderer"; - title: string; - subtitle: string; - tone: string; - }; - - cards: [ - CoverCard, - ThinkingPathCard, - VibeCard, - WorkflowOrToolsCard, - ClosingCard - ]; - - evidence?: Evidence[]; -}; -``` - -### Card 1: Cover - -```ts -type CoverCard = { - type: "cover"; - badge: string; - title: string; - subtitle: string; - activity: number[]; - footer: string; - evidence_refs?: string[]; -}; -``` - -Use the cover to name the period and the persona. The title can be an archetype -label such as "The Architect"; the subtitle should summarize the period's real -dominant work in one line. - -### Card 2: Thinking Path - -```ts -type ThinkingPathCard = { - type: "thinking_path"; - title: string; - items: Array<{ - day: string; - prompt: string; - outcome: string; - evidence_refs?: string[]; - }>; -}; -``` - -Pick 3-6 turning points. A `prompt` is the question, friction, or task that -started the path. An `outcome` is the decision, fix, or learned framing. - -### Card 3: Vibe - -```ts -type VibeCard = { - type: "vibe"; - title: string; - observations: Array<{ - label: string; - text: string; - count?: number; - time?: string; - evidence_refs?: string[]; - }>; - meter?: { - label: string; - value: number; - caption: string; - }; - quote?: { - text: string; - caption?: string; - evidence_refs?: string[]; - }; -}; -``` - -This card can be playful, but it must stay grounded. Repeated phrases should be -counted from visible user messages, not meta messages or tool results. - -### Card 4: Workflows Or Tools - -```ts -type WorkflowOrToolsCard = { - type: "workflow" | "tool_habits" | "debugging" | "shipping"; - title: string; - summary?: string; - stats?: string; - items: Array<{ - name: string; - outcome: string; - evidence_refs?: string[]; - }>; - verdict: string; -}; -``` - -Choose the card type that best fits the period. Use `workflow` when workflow -runs or subagents are the story; `debugging` when failures and fixes dominate; -`shipping` when completed implementation dominates; `tool_habits` when the -period is mostly about how the user worked. - -### Card 5: Closing - -```ts -type ClosingCard = { - type: "closing"; - headline: string; - stats: string[]; - most_said_phrase?: string; - signoff: string; - evidence_refs?: string[]; -}; -``` - -Close with one high-signal stat and a short signoff. Avoid turning the closing -card into a second summary. - -### Evidence - -```ts -type Evidence = { - id: string; - session_id?: string; - message_uuid?: string; - memory_id?: string; - summary?: string; -}; -``` - -Evidence is for local traceability and app inspection. It does not have to be -shown on exported cards. Prefer short summaries over raw snippets. - -## Output Rules - -- If the user asks for app handoff, return one JSON object and no surrounding - prose. -- If the user asks conversationally, answer with the card content naturally and - include JSON only if useful. -- Keep `cards.length === 5` in the order above. -- Do not include unsupported cards just to fill space. It is better to make a - quieter card than to invent drama. -- Do not include raw SQL, query scripts, or private evidence in the shareable - card text. - -## Minimal Example - -```json -{ - "schema_version": "obelisk.recap.v1", - "kind": "weekly", - "generated_at": "2026-06-13T03:40:00+08:00", - "period": { - "label": "Week 24", - "start": "2026-06-08", - "end": "2026-06-14", - "timezone": "Asia/Shanghai" - }, - "source": { - "project": "quiet-zero", - "session_ids": ["sid-a", "sid-b"], - "memory_ids": ["mem-a"] - }, - "metrics": { - "sessions": 12, - "messages": 847, - "tokens": 2400000, - "active_days": [1, 1, 1, 0, 1, 1, 1], - "workflows": 3, - "workflow_agents": 42 - }, - "persona": { - "archetype": "architect", - "title": "The Architect", - "subtitle": "Designed a memory system from raw sessions to durable notes.", - "tone": "matter-of-fact, structural pride" - }, - "cards": [ - { - "type": "cover", - "badge": "Week 24", - "title": "The Architect", - "subtitle": "Designed a full memory layer without turning sessions into a wiki.", - "activity": [1, 1, 1, 0, 1, 1, 1], - "footer": "12 sessions - 2.4M tokens", - "evidence_refs": ["e1", "e2"] - }, - { - "type": "thinking_path", - "title": "Your thinking path", - "items": [ - { - "day": "Mon", - "prompt": "Why compile sessions into a wiki?", - "outcome": "Kept raw SQLite as the evidence layer.", - "evidence_refs": ["e1"] - } - ] - }, - { - "type": "vibe", - "title": "Your vibe this week", - "observations": [ - { - "label": "Catchphrase", - "text": "This is too ugly.", - "count": 4, - "evidence_refs": ["e3"] - } - ], - "meter": { - "label": "Patience", - "value": 0.8, - "caption": "saint" - } - }, - { - "type": "workflow", - "title": "Are you a Workflow Enjoyer?", - "stats": "3 workflows - 42 agents", - "items": [ - { - "name": "hono-plugin-review", - "outcome": "perfect", - "evidence_refs": ["e4"] - } - ], - "verdict": "Mostly tolerated" - }, - { - "type": "closing", - "headline": "19 day streak", - "stats": ["847 messages exchanged"], - "most_said_phrase": "Okay, start doing it.", - "signoff": "See you next week.", - "evidence_refs": ["e5"] - } - ], - "evidence": [ - { - "id": "e1", - "session_id": "sid-a", - "message_uuid": "msg-a", - "summary": "The user chose raw SQLite as the evidence layer." - } - ] -} -``` +The `/obelisk recap` flow now starts at `references/recap/overview.md`. +Do not use this as an all-in-one retrieval document. The current flow is +card-by-card: read the overview, then for each card read its `patternN-*.md`, +retrieve that card's evidence, read its `writingN-*.md`, and update the JSON +before moving on. diff --git a/references/recap-writing.md b/references/recap-writing.md new file mode 100644 index 0000000..f7b8af1 --- /dev/null +++ b/references/recap-writing.md @@ -0,0 +1,8 @@ +# Obelisk Recap Writing + +Compatibility pointer. + +The `/obelisk recap` writing contract now lives in the per-card writing files +under `references/recap/`, coordinated by `references/recap/overview.md`. +Read the overview first. Then use each per-card writing file immediately after +that card's retrieval pattern, rather than loading one large writing prompt. diff --git a/references/recap/overview.md b/references/recap/overview.md new file mode 100644 index 0000000..4542c19 --- /dev/null +++ b/references/recap/overview.md @@ -0,0 +1,74 @@ +# Obelisk Recap Overview + +Use this only when the first word after `/obelisk` is `recap`. Everything after +`recap` is the target period or style hint. + +## Highest Priority: Phase Loop + +This workflow is sequential. Do not preload all recap files. Do not gather all +evidence first and write all cards at the end. + +Follow this loop exactly: + +1. Resolve the target period from the user's phrase. +2. Run only a tiny orientation pass such as `overview({ limit: 6 })`. +3. For Card 1, read `pattern1-cover.md`. +4. Retrieve only Card 1 evidence. +5. Read `writing1-cover.md`. +6. Update/write the JSON for Card 1 now. +7. Only after the JSON is updated, move to Card 2 and repeat. + +Card order: + +| card | retrieve | write | +|---|---|---| +| 1 cover | `pattern1-cover.md` | `writing1-cover.md` | +| 2 thinking | `pattern2-thinking.md` | `writing2-thinking.md` | +| 3 vibe | `pattern3-vibe.md` | `writing3-vibe.md` | +| 4 workflow | `pattern4-workflow.md` | `writing4-workflow.md` | +| 5 closing | `pattern5-closing.md` | `writing5-closing.md` | + +The per-card files own retrieval details, JSON field duties, and card-specific +taste. Do not move those concerns back into this file. + +## Period Targets + +- `this week`, `last week`: calendar week in the user's runtime timezone. +- `this month`, `last month`: calendar month in the user's runtime timezone. + +Do not infer timezone from examples, UTC suffixes, or file timestamps when +runtime/session timezone is available. + +## Overall Deck Taste + +This is a Spotify Wrapped-like set of personal share cards: concise, designed, +slightly playful, and built to make the user's work feel seen. + +Do not criticize the user. Do not scold, diagnose, rank their personality, or +turn friction into a performance review. + +Use designed English chrome where it feels like card UI: week/month labels, +archetype labels, compact stats, verdict seals, and signoffs. Preserve the +user's own language for prompts, quotes, catchphrases, and reactions. This is +not a translation task. + +The deck should feel like a small artifact from someone who noticed the week, +not a report generated from a database. + +## Archetypes + +Choose one dominant archetype from the period's dominant attention, not from the +current recap-generation session. + +| archetype | when it fits | tone baseline | +|---|---|---| +| `architect` | structure, boundaries, schema, systems | matter-of-fact structural pride | +| `debugger` | symptoms, false positives, root-cause loops | wry and bug-comfortable | +| `shipper` | dense implementation cadence | energetic but not breathless | +| `curator` | organization, memory, docs, refinement | reflective and precise | +| `director` | workflows, subagents, orchestration | observant from a slight remove | +| `cartographer` | moving boundaries and redrawing maps | patient and surveyor-like | +| `wanderer` | many projects without one center | gentle, exploratory | + +If two fit, pick the one that describes what the user spent more thinking time +on, not what shipped. diff --git a/references/recap/pattern1-cover.md b/references/recap/pattern1-cover.md new file mode 100644 index 0000000..14b6a66 --- /dev/null +++ b/references/recap/pattern1-cover.md @@ -0,0 +1,36 @@ +# Card 1 Cover Retrieval + +Goal: choose the recap's dominant claim, persona, activity shape, and compact +footer. The cover is not a topic inventory; it is one glanceable claim about +what the period felt like. + +Use the period from `overview.md`. Start from `overview({ limit: 6 })`, then +look at in-period sessions, summaries, memories, and any obvious project scope. +If the user asked for a project, keep that scope; otherwise prefer the current +project only when the evidence makes it the clear center. + +Prefer helpers first. If you need custom SQL for activity, token/message counts, +or source-session scope, read `references/schema.md` before writing the SQL. + +Retrieve: + +- dominant claim: one thing that defined the period, supported by raw evidence; +- persona: which archetype best matches the user's attention; +- source sessions and memories used by this cover claim; +- activity: weekly day intensities or monthly day intensities when supported; +- footer: compact public metric such as sessions, messages, or tokens. + +Avoid: + +- a claim that lists three topics; +- an archetype chosen from the recap-generation session itself; +- footer caveats like excluded projects, exact SQL filters, or long session names; +- making the cover a workflow metric when the week was really about a decision. + +Read this card's writing file immediately after the cover evidence is stable: +`references/recap/writing1-cover.md`. Then update the JSON fields +`period`, `source`, `metrics`, `persona`, and the first `cards[]` entry. +Do not read `pattern2-thinking.md` until this JSON update is done. + +Stop when the cover has one evidence-backed dominant claim, one chosen persona, +one metric scope, and at least one `evidence` anchor. diff --git a/references/recap/pattern2-thinking.md b/references/recap/pattern2-thinking.md new file mode 100644 index 0000000..b0d46f6 --- /dev/null +++ b/references/recap/pattern2-thinking.md @@ -0,0 +1,33 @@ +# Card 2 Thinking Retrieval + +Goal: find turning points. This card is not a project timeline and not an implementation log. It is the record of what changed in the user's mind. + +Retrieve 3-6 turns. A turn needs both sides: + +- the user question, friction, doubt, or request that started the turn; +- the later decision, reframing, finding, or constraint that replaced the earlier + state. + +Useful searches: + +- user questions in the period: "为什么", "是不是", "怎么", "我觉得", "不应该"; +- places where the user corrected the direction and then approved a new frame; +- summaries that name decisions, followed by `context()` or `thread()` for the + user's actual words; +- memory records only as hints; raw session evidence must provide the prompt and + turn. + +Prefer helpers first. If you need custom SQL for message windows or user-turn +counts, read `references/schema.md` before writing the SQL. + +Do not use workflow names, feature names, or agent task labels as prompts when +the user had their own wording. Do not use counts, "5 rounds", "13 agents", or +implementation effort as turns unless that count is the turn itself. + +Read this card's writing file immediately after the turns are chosen: +`references/recap/writing2-thinking.md`. Then update the JSON `thinking_path` +card and add evidence for each item. +Do not read `pattern3-vibe.md` until this JSON update is done. + +Stop when each item has a source-language prompt label, a short changed-state +prompt, turn, and an `evidence` anchor. diff --git a/references/recap/pattern3-vibe.md b/references/recap/pattern3-vibe.md new file mode 100644 index 0000000..be3c8ad --- /dev/null +++ b/references/recap/pattern3-vibe.md @@ -0,0 +1,30 @@ +# Card 3 Vibe Retrieval + +Goal: find small human signals in visible user messages. Vibe is not a correction log, not bracketed runtime text, and not a psychological profile. + +Look for: + +- catchphrases and repeated tiny reactions; +- unusually blunt praise or rejection; +- late-night disbelief, jokes, or rituals; +- one quotable sentence that captures the period's character. + +Only count visible user messages. Helper APIs omit meta by default, but custom +SQL for phrase counts must filter user text with `COALESCE(m.is_meta,0)=0` and +`m.content_type='text'`. Do not count tool results, injected command envelopes, +UI labels, or bracketed runtime strings. + +Useful retrieval: + +- targeted phrase counts after you notice a likely catchphrase; +- `thread(sessionId)` around high-energy moments; +- `search()` for exact phrases, then `context()` for timing; +- a bounded SQL count only after reading `references/schema.md`. + +Read this card's writing file immediately after you have the small user signals: +`references/recap/writing3-vibe.md`. Then update the JSON `vibe` card and add +evidence for every quote, count, and timestamp. +Do not read `pattern4-workflow.md` until this JSON update is done. + +Stop when every observation is either exact user words or a tiny label backed by +exact user words. diff --git a/references/recap/pattern4-workflow.md b/references/recap/pattern4-workflow.md new file mode 100644 index 0000000..d1dbb94 --- /dev/null +++ b/references/recap/pattern4-workflow.md @@ -0,0 +1,41 @@ +# Card 4 Workflow Retrieval + +Goal: find actual workflow runs and how the user received them. Card 4 is about +orchestration as experienced by the user, not an agent performance table. + +Workflow rows have their own `workflows.timestamp`. During this card's retrieval, +call `workflows({ project: projectLike, after, before })` before concluding the +period had zero workflows. + +Prefer helpers first. If you need custom SQL for workflow joins, timestamps, or +message reactions, read `references/schema.md` before writing the SQL. + +Do not derive workflow counts only from `sessions({ after, before })`: long +sessions can start before the period and still contain workflow runs inside the +period. Do not scope workflow lookup by exact `project_path`; nested cwd values +can belong to the same Claude project slug. + +For each candidate workflow: + +- get the actual workflow_name from `workflows()` or `workflowTree()`; +- collect run id, timestamp, project, agent count, and compact result for stats + and evidence only; +- search the parent session for the user message immediately following the workflow completion; +- use that user reaction as `items[].reaction`. + +Rank rows by the strength of the user reaction, not by agent count, workflow +size, duration, or implementation importance. A small workflow with "完美" is a +better row than a large workflow with no visible response. + +Do not use architecture topics, memory-system milestones, app modules, or recap +feature work as workflow rows unless they are actual workflow_name values. +Do not make a row for a workflow with no visible user reaction; keep it only in +`stats`, `metrics`, or `evidence`. + +Read this card's writing file immediately after workflow evidence is stable: +`references/recap/writing4-workflow.md`. Then update the JSON `workflow` card, +top-level workflow metrics, and source session ids for workflow evidence. +Do not read `pattern5-closing.md` until this JSON update is done. + +Stop when every displayed row has an actual workflow name and a visible user +reaction. diff --git a/references/recap/pattern5-closing.md b/references/recap/pattern5-closing.md new file mode 100644 index 0000000..be5c4c3 --- /dev/null +++ b/references/recap/pattern5-closing.md @@ -0,0 +1,35 @@ +# Card 5 Closing Retrieval + +Goal: close with a small personal receipt. Use the same period and source scope +as the recap, or explicitly record a wider metric in `evidence`. + +Retrieve: + +- one consistent metric that can stand alone, such as streak, active days, + sessions, messages, or workflows; +- one or two compact receipts; +- most said phrase, only if a real repeated user phrase is supported; +- signoff material from the period's mood, not a second summary. + +For phrase counts, count only non-meta visible user text. For streaks and active +days, define whether the scope is all Obelisk data, the current project, or the +selected evidence sessions. Keep the scope consistent with the cover footer +unless the evidence explicitly says otherwise. + +Prefer helpers first. If you need custom SQL for phrase counts, active days, or +streaks, read `references/schema.md` before writing the SQL. + +Avoid: + +- naked numbers without units; +- project report bullets; +- internal session names; +- token audits; +- slogans, advice, or next-action commands. + +Read this card's writing file immediately after the closing receipt is chosen: +`references/recap/writing5-closing.md`. Then update the JSON `closing` card and +add evidence for counts and phrases. +This is the final card; save the completed JSON before replying. + +Stop when the closing can end the deck without explaining the whole week again. diff --git a/references/recap/writing1-cover.md b/references/recap/writing1-cover.md new file mode 100644 index 0000000..96df3fa --- /dev/null +++ b/references/recap/writing1-cover.md @@ -0,0 +1,83 @@ +# Card 1 Cover Writing + +The cover should be readable in one glance: badge, persona, one plain claim, +activity, footer. Before writing, say the claim to the user in a chat bubble. +If it sounds like a topic list or report heading, shrink it. + +## Mock taste anchor + +```json +{ + "type": "cover", + "badge": "Week 24", + "title": "The Architect", + "claim": "从零设计了一个完整的 memory 系统。", + "activity": [0.85, 0.92, 0.72, 0.45, 0.88, 0.30, 0], + "footer": "12 sessions · 2.4M tokens" +} +``` + +This works because `从零设计了一个完整的 memory 系统。` is one plain claim, in +the user's language, and can be read in one breath. `The Architect` is English +chrome; it gives the card a designed surface without translating the user's +actual work. + +## JSON Shape + +```ts +type CoverCard = { + type: "cover"; + badge: string; + title: string; + claim: string; + activity: number[]; + footer: string; + evidence_refs?: string[]; +}; +``` + +Field duties: + +- `badge`: compact period chrome, such as `Week 24`. +- `title`: persona label, usually `The Architect`, `The Debugger`, etc. +- `claim`: one plain claim; not a topic list, project inventory, colon-led + tagline, or clever English that hides the user's language. +- `activity`: period intensity values from retrieval. +- `footer`: public metric line with no internal filter notes. + +After writing, check that `persona.claim` and `cover.claim` tell the same +story, and attach `evidence_refs` to the claim or metric if it is surprisingly +specific. + +## First JSON Write + +After Card 1, create or update the recap JSON file. Do this before reading +`pattern2-thinking.md`. + +Use this top-level shape: + +```ts +type Recap = { + schema_version: "obelisk.recap.v1"; + kind: "weekly" | "monthly"; + generated_at: string; + period: { label: string; start: string; end: string; timezone: string }; + source: { project?: string; session_ids: string[]; memory_ids?: string[] }; + metrics: { + sessions?: number; + messages?: number; + tokens?: number; + active_days?: number[]; + streak_days?: number; + workflows?: number; + workflow_agents?: number; + corrections?: number; + }; + persona: { archetype: string; title: string; claim: string; tone: string }; + cards: [CoverCard, { type: "thinking_path" }, { type: "vibe" }, { type: "workflow" }, { type: "closing" }]; + evidence?: Array<{ id: string; session_id?: string; message_uuid?: string; memory_id?: string; summary?: string }>; +}; +``` + +For app handoff, write JSON under `~/.obelisk/recap/`. Weekly filenames are +`recap-{YYYY}-W{WW}.json`; monthly filenames are `recap-{YYYY}-{MM}.json`. diff --git a/references/recap/writing2-thinking.md b/references/recap/writing2-thinking.md new file mode 100644 index 0000000..39affa3 --- /dev/null +++ b/references/recap/writing2-thinking.md @@ -0,0 +1,49 @@ +# Card 2 Thinking Writing + +Thinking Path should feel like a few bends in the user's reasoning, not a +weekly changelog. Before writing, test each row by asking: "what changed here?" + +## Mock taste anchor + +```json +{ + "type": "thinking_path", + "title": "Five questions, five turns.", + "items": [ + { "day": "Mon", "prompt": "为什么要把 session 编译成 wiki?", "turn": "raw SQLite, no wiki" }, + { "day": "Tue", "prompt": "buildWhere 是什么", "turn": "unified filter opts, not DSL" }, + { "day": "Wed", "prompt": "failures() 90% 误报", "turn": "is_error in JSONL" }, + { "day": "Thu", "prompt": "memory 层需要清理机制吗", "turn": "soft-delete, human-only" }, + { "day": "Fri", "prompt": "热力图不选中默认显示本月", "turn": "GitHub-style activity timeline" } + ] +} +``` + +The prompts stay close to the user's words. Each turn is a short decision +fragment, not a full explanation. + +## JSON Shape + +```ts +type ThinkingPathCard = { + type: "thinking_path"; + title: string; + items: Array<{ + day: string; + prompt: string; + turn: string; + evidence_refs?: string[]; + }>; +}; +``` + +Field duties: + +- `title`: designed deck line, not `本周路径`, not a research-paper heading. +- `prompt`: user's compact question, friction, or task. Use source language. +- `turn`: short decision fragment, finding, or shift; usually under 10 words. + Compact English fragments are allowed when they work as designed chrome. + +After writing, remove any row whose prompt is a workflow name or whose turn +describes implementation rather than changed thinking. +Update the JSON now before reading `pattern3-vibe.md`. diff --git a/references/recap/writing3-vibe.md b/references/recap/writing3-vibe.md new file mode 100644 index 0000000..348e600 --- /dev/null +++ b/references/recap/writing3-vibe.md @@ -0,0 +1,73 @@ +# Card 3 Vibe Writing + +Vibe is affectionate observation. It should make the user recognize themselves +without feeling evaluated. Before writing, remove anything that reads like a +correction audit, behavior label, diagnosis, or complaint ledger. + +## Mock taste anchor + +```json +{ + "type": "vibe", + "title": "A short character study.", + "voice_lines": [ + { "label": "catchphrase", "text": "这太丑了", "count": 4 }, + { "label": "highest praise", "text": "可以" }, + { "label": "late night", "text": "你在干什么", "time": "02:47 AM" } + ], + "meter": { + "label": "patience", + "value": 0.78, + "caption": "saint" + }, + "quote": { + "text": "若无必要,勿增实体。", + "caption": "your most philosophical moment" + } +} +``` + +The humor comes from exact small lines. `可以` is funnier and truer than +"approval signal". + +## JSON Shape + +```ts +type VibeCard = { + type: "vibe"; + title: string; + voice_lines: Array<{ + label: string; + text: string; + count?: number; + time?: string; + evidence_refs?: string[]; + }>; + meter?: { + label: string; + value: number; + caption: string; + }; + quote?: { + text: string; + caption?: string; + evidence_refs?: string[]; + }; +}; +``` + +Field duties: + +- `title`: light character-study line, not a scorecard. +- `voice_lines[].text`: exact user words; no paraphrase, translation, + ellipsized half-quote, meta text, or correction log. +- `voice_lines[].label`: designed chrome can be English; the quoted user text + stays in source language. +- `meter`: meter is not a diagnosis. Keep the caption one or two words and + affectionate, never punitive. +- `quote.text`: one exact user phrase or sentence. + +Do not use `[Request interrupted by user]`, tool output, injected context, or +UI status text as vibe. After writing, verify every `voice_lines[].text` and +`quote.text` can be traced to a non-meta user message. +Update the JSON now before reading `pattern4-workflow.md`. diff --git a/references/recap/writing4-workflow.md b/references/recap/writing4-workflow.md new file mode 100644 index 0000000..2f894b6 --- /dev/null +++ b/references/recap/writing4-workflow.md @@ -0,0 +1,68 @@ +# Card 4 Workflow Writing + +Workflow is the orchestration card. It should show the strongest few workflow +runs and the user's reaction to them. Before writing, remove any row whose +reaction is not traceable to a visible user reaction. + +## Mock taste anchor + +```json +{ + "type": "workflow", + "title": "Three workflows. Forty-two agents.", + "deck": "你召唤了机器军团。结果各有不同。", + "stats": "3 workflows · 42 agents", + "items": [ + { "name": "hono-plugin-review", "reaction": "完美" }, + { "name": "vue-migration", "reaction": "你这页面完全和之前的不一样…" }, + { "name": "split-render-js", "reaction": "可以" } + ], + "verdict": "Mostly tolerated." +} +``` + +The row reactions are user reactions. The title carries the metric; the verdict +is a small English seal. + +## JSON Shape + +```ts +type WorkflowCard = { + type: "workflow"; + title: string; + deck?: string; + stats?: string; + items: Array<{ + name: string; + reaction: string; + evidence_refs?: string[]; + }>; + verdict: string; +}; +``` + +Field duties: + +- `title`: human story line or compact metric line. +- `deck`: optional second line; do not repeat stats mechanically. +- `stats`: compact count line. +- `items[].name`: actual workflow name, command name, or run-id prefix. +- `items[].reaction`: exact or lightly trimmed user reaction. Preserve source + language. No feature description, implementation summary, agent count, + duration, "framework switch", "modularization", "theming landed", or other + internal progress label. +- `verdict`: compact seal based on the row reactions, often 3-6 words. + +Agent counts belong only in `title` or `stats`, never in `items[].reaction`. +These row values are invalid because they are implementation labels, not user +reactions: + +- `13 agents, the big build` is invalid. +- `9 agents, framework switch` is invalid. +- `6 agents, modularization` is invalid. +- `theming landed` is invalid. + +If no user reaction exists, omit the row rather than write an implementation result. +After writing, check that every row name maps to retrieval evidence and every +reaction can be read as quoted user verdict text. +Update the JSON now before reading `pattern5-closing.md`. diff --git a/references/recap/writing5-closing.md b/references/recap/writing5-closing.md new file mode 100644 index 0000000..0c80db5 --- /dev/null +++ b/references/recap/writing5-closing.md @@ -0,0 +1,54 @@ +# Card 5 Closing Writing + +Closing is a receipt, not a second summary. Before writing, read the headline +alone. If it does not mean anything without the rest of the card, add the unit +or choose a better line. + +## Mock taste anchor + +```json +{ + "type": "closing", + "headline": "19 days", + "receipts": ["847 messages exchanged", "12 corrections · 47 approvals"], + "most_said_phrase": "好的开始做吧", + "signoff": "See you next week." +} +``` + +This works because `19 days` has a unit, the receipts feel like a small receipt, +and `See you next week.` is a quiet goodbye instead of a slogan. + +## JSON Shape + +```ts +type ClosingCard = { + type: "closing"; + headline: string; + receipts: string[]; + most_said_phrase?: string; + signoff: string; + evidence_refs?: string[]; +}; +``` + +Field duties: + +- `headline`: compact stat or phrase with its unit; not a naked number. +- `receipts`: at most two `receipts`, compact and personal. +- `most_said_phrase`: complete phrase the user actually said, or omit it. +- `signoff`: short and earned; quiet goodbye, not advice or a brand slogan. + English signoff chrome such as `See you next week.` is allowed. + +After writing, remove internal scope notes from visible fields and put them in +`evidence`. The final card should feel like the deck ending, not the report +continuing. + +Final save rules: + +- The file contains only the JSON object: no Markdown fence, no prose. +- Keep exactly five cards in this order: cover, thinking_path, vibe, workflow, + closing. +- Keep private SQL, raw tool output, secrets, long paths, and source caveats out + of visible card text; put traceability in `evidence`. +- After saving, reply briefly with the saved path and important evidence caveats. diff --git a/references/schema.md b/references/schema.md index 7bbee16..0ace4c2 100644 --- a/references/schema.md +++ b/references/schema.md @@ -89,7 +89,8 @@ CREATE VIRTUAL TABLE messages_fts USING fts5( ); ``` -Queried via `MATCH` syntax. Rebuilt on each index pass. +Queried via `MATCH` syntax. The table is kept in sync by the `messages_fts_*` +triggers above; rebuild it manually only when repairing FTS state. ### memories_fts @@ -384,6 +385,31 @@ const msgs = thread('session-uuid'); return { count: msgs.length, first: msgs[0]?.text?.slice(0, 100) }; ``` +#### `raw(uuid, opts?)` + +Windowed access to the original JSONL line for a message. Use this when indexed +text, tool inputs, or tool results were truncated and you need the raw source. +It resolves main-session, subagent, and workflow-agent JSONL paths from the +indexed message metadata. + +| Param | Type | Description | +|-------|------|-------------| +| `uuid` | `string` | Message UUID | +| `opts.offset` | `number` | Character offset into the JSONL line (default 0) | +| `opts.limit` | `number` | Max characters to return (default 10000) | + +**Returns:** `{ text, totalLength, offset, limit, hasMore }` or `null` if the +source line cannot be found. + +```js +const line = raw('message-uuid', { offset: 0, limit: 4000 }); +return { + preview: line?.text, + has_more: line?.hasMore, + total: line?.totalLength, +}; +``` + #### `subagents(opts?)` All subagent spawns, with message counts. For backward compatibility, passing a string is treated as `sessionId`. @@ -484,6 +510,34 @@ const last5 = recent(5); return last5.map(s => ({ title: s.title, project: s.project_path, ended: s.ended_at })); ``` +#### `summaries(opts?)` + +Session summary rows, newest first. For backward compatibility, passing a +string is treated as `sessionId`, and passing a number is treated as `limit`. + +| Param | Type | Description | +|-------|------|-------------| +| `opts.sessionId` | `string` | Restrict to one session | +| `opts.sessions` | `string[]` | Restrict to a set of session IDs | +| `opts.project` | `string` | SQL `LIKE` pattern over `sessions.project` | +| `opts.after` | `string` | ISO 8601 lower bound on summary timestamp | +| `opts.before` | `string` | ISO 8601 upper bound on summary timestamp | +| `opts.branch` | `string` | Filter by source session git branch (exact match) | +| `opts.limit` | `number` | Max results (default 100) | + +**Returns:** `Array` ordered by +`timestamp` descending. + +```js +const rows = summaries({ project: '%quiet-zero%', limit: 5 }); +return rows.map(s => ({ + session: s.session_title, + source: s.source, + summary: s.content?.slice(0, 240), + timestamp: s.timestamp, +})); +``` + #### `overview(opts?)` Compact orientation map for choosing the next retrieval scope. It is not an @@ -785,7 +839,7 @@ const wfs = workflows(); for (const wf of wfs.slice(0, 3)) { const tree = workflowTree(wf.run_id); wf.agent_details = tree?.agents.map(a => ({ - type: a.agent_type, desc: a.description, msgs: a.messages.length, + type: a.agent_type, desc: a.description, msgs: a.messageCount, })); } return wfs.slice(0, 3); diff --git a/scripts/db.mjs b/scripts/db.mjs index 3be8375..c6ab8d9 100644 --- a/scripts/db.mjs +++ b/scripts/db.mjs @@ -82,7 +82,7 @@ function extractContentType(content) { return !sawUnknown && types.size === 1 ? [...types][0] : 'unknown'; } -const COMMAND_ENVELOPE_RE = /^\s*([^<]+<\/command-name>|||)/; +const COMMAND_ENVELOPE_RE = /^\s*([^<]+<\/command-name>|<(?:task-notification|system-reminder)\b|