diff --git a/.gitignore b/.gitignore index 4499ae8..12ff5c9 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ plans/ .skillopt-backups tests/ node_modules/ +dist-renderer/ diff --git a/README.md b/README.md index 48188f8..f1c0220 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,7 @@ 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/pitfalls.md` — scope, FTS, ordering, compact/raw, and field-name traps The executable SQLite schema lives in `scripts/schema.sql`; `references/schema.md` @@ -132,6 +133,8 @@ 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. ## What gets indexed @@ -159,6 +162,7 @@ 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 └── pitfalls.md # Scope, FTS, ordering, and compactness traps ``` diff --git a/SKILL.md b/SKILL.md index 132f117..ed07d29 100644 --- a/SKILL.md +++ b/SKILL.md @@ -72,12 +72,36 @@ Use `sql()` only as an escalation path for exact joins, aggregations, or schema questions that helpers cannot express cleanly. Do not use raw SQL as a generic fallback for broad retrieval. +## Intent Routing + +Obelisk supports a small intent prefix layer after `/obelisk`. This is for +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` | + +Routing rules: + +1. If the first word is `recap`, read `references/recap-patterns.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 + `/obelisk recap last month`; interpret these as natural period targets + 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 + infer recap from broad requests for weekly/monthly summaries, charts, + rankings, shareable cards, or playlist-style metaphors. + ## Query Routing Before writing a query, classify the task. Progressive disclosure is useful, but skipping the relevant reference usually costs extra query rounds. -- Read `references/query-patterns.md` before the first query for broad synthesis, progress summaries, design history, weekly/monthly reviews, or questions that ask what the user did, learned, decided, tried, or abandoned. Start from the first-pass or one-shot synthesis pattern, then run a faceted detail pass if needed. +- Read `references/query-patterns.md` before the first query for broad synthesis, progress summaries, design history, ordinary weekly/monthly reviews, or questions that ask what the user did, learned, decided, tried, or abandoned. Start from the first-pass or one-shot synthesis pattern, then run a faceted detail pass if needed. - Read `references/retrieval-semantics.md` before multi-step retrieval, scoped project/file/session searches, or synthesis/conclusion/history questions. It defines the query design frame. - Read `references/schema.md` before raw `sql()` unless the needed table/column relationship is already explicit here. Do this before running the SQL, not after a missing-column error. Do not start with raw SQL for broad synthesis unless helpers cannot express the needed aggregation or join. - Read `references/pitfalls.md` after an error or when helper fields, FTS syntax, aliases, or row shapes are unclear. diff --git a/app/indexer-service.js b/app/indexer-service.js index 4d9de59..aab4a58 100644 --- a/app/indexer-service.js +++ b/app/indexer-service.js @@ -64,9 +64,27 @@ function createIndexerService({ let running = false; let pending = false; let lastReason = null; + let changedPaths = new Set(); let idlePromise = Promise.resolve(); - const runBuildNow = (reason = 'manual') => { + const addChangedPath = (changedPath) => { + if (Array.isArray(changedPath)) { + for (const p of changedPath) addChangedPath(p); + return; + } + const name = changedPath ? String(changedPath) : ''; + if (name) changedPaths.add(name); + }; + + const takeChangedPaths = () => { + if (!changedPaths.size) return undefined; + const paths = [...changedPaths]; + changedPaths = new Set(); + return paths; + }; + + const runBuildNow = (reason = 'manual', paths = undefined) => { + addChangedPath(paths); if (stopped) return idlePromise; if (running) { pending = true; @@ -74,8 +92,9 @@ function createIndexerService({ } running = true; pending = false; + const buildChangedPaths = takeChangedPaths(); idlePromise = (async () => { - await buildIndex({ reason }); + await buildIndex({ reason, changedPaths: buildChangedPaths }); writeHeartbeat(); })() .catch((error) => { @@ -91,8 +110,9 @@ function createIndexerService({ return idlePromise; }; - const scheduleBuild = (reason = 'change') => { + const scheduleBuild = (reason = 'change', changedPath = undefined) => { if (stopped) return; + addChangedPath(changedPath); lastReason = reason; if (running) pending = true; if (buildTimer) timers.clearTimeout(buildTimer); @@ -112,7 +132,7 @@ function createIndexerService({ const startWatching = () => { if (stopped || watcher) return; - watcher = watch(() => scheduleBuild('watch')); + watcher = watch((changedPath) => scheduleBuild('watch', changedPath)); if (!watcher) { watchRetryTimer = timers.setTimeout(() => { watchRetryTimer = null; diff --git a/app/main.js b/app/main.js index d2f1a32..86f7ad5 100644 --- a/app/main.js +++ b/app/main.js @@ -1,4 +1,4 @@ -const { app, BrowserWindow, ipcMain } = require('electron'); +const { app, BrowserWindow, ipcMain, clipboard, dialog, nativeImage } = require('electron'); const path = require('path'); const os = require('os'); const fs = require('fs'); @@ -57,6 +57,13 @@ function createWindow() { }, }); + // Prevent Electron's built-in zoom so Cmd+=/- reaches the renderer + win.webContents.on('before-input-event', (event, input) => { + if ((input.meta || input.control) && ['+', '=', '-', '0'].includes(input.key)) { + win.webContents.setZoomLevel(0); + } + }); + const isDev = process.argv.includes('--dev'); if (isDev) { win.loadURL('http://localhost:5173'); @@ -66,11 +73,43 @@ function createWindow() { } } +const OBELISK_DIR = path.join(os.homedir(), '.obelisk'); +const RECAP_DIR = path.join(OBELISK_DIR, 'recap'); +let obeliskWatcher = null; + +function startObeliskWatcher() { + const chokidar = require('chokidar'); + if (!fs.existsSync(OBELISK_DIR)) { + fs.mkdirSync(OBELISK_DIR, { recursive: true }); + } + obeliskWatcher = chokidar.watch(OBELISK_DIR, { + ignoreInitial: true, + awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100 }, + ignored: (p, stats) => { + if (stats?.isDirectory()) return false; + if (!stats) return false; + return !p.endsWith('.md') && !p.endsWith('.json'); + }, + }); + obeliskWatcher.on('add', onObeliskChange); + obeliskWatcher.on('change', onObeliskChange); + obeliskWatcher.on('unlink', onObeliskChange); +} + +function onObeliskChange(filePath) { + if (filePath.startsWith(RECAP_DIR)) { + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send('obelisk:recap-updated', filePath); + } + } +} + app.whenReady().then(() => { indexerWorker = createWorkerBuildIndex(); openDb(); createWindow(); startIndexerService().runBuildNow('startup'); + startObeliskWatcher(); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); @@ -80,6 +119,7 @@ app.whenReady().then(() => { app.on('before-quit', () => { if (indexerService) indexerService.stop(); if (indexerWorker) indexerWorker.stop(); + if (obeliskWatcher) obeliskWatcher.close(); }); app.on('window-all-closed', () => { @@ -300,3 +340,78 @@ ipcMain.handle('db:getUsageStats', () => { return { daily, totalTokens, peakDay, longestTurn }; }); + +// --- Capture --- + +const EXPORT_WIDTH = 540; +const EXPORT_HEIGHT = 675; + +async function createExportCapture(parentWin, query) { + const exportWin = new BrowserWindow({ + width: EXPORT_WIDTH, + height: EXPORT_HEIGHT, + show: false, + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + offscreen: true, + deviceScaleFactor: 2, + }, + }); + + const isDev = process.argv.includes('--dev'); + const url = isDev + ? `http://localhost:5173/#/recap-export?${query}` + : `file://${path.join(__dirname, 'dist-renderer', 'index.html')}#/recap-export?${query}`; + + await exportWin.loadURL(url); + await new Promise(r => setTimeout(r, 500)); + + const image = await exportWin.webContents.capturePage({ + x: 0, y: 0, width: EXPORT_WIDTH, height: EXPORT_HEIGHT, + }); + exportWin.close(); + return image; +} + +ipcMain.handle('capture:export', async (event, { cardIdx, archetype }) => { + const win = BrowserWindow.fromWebContents(event.sender); + if (!win) return null; + const query = `card=${cardIdx}&arch=${archetype}`; + const image = await createExportCapture(win, query); + const { filePath } = await dialog.showSaveDialog(win, { + defaultPath: `obelisk-recap-${cardIdx + 1}.png`, + filters: [{ name: 'PNG', extensions: ['png'] }], + }); + if (!filePath) return null; + fs.writeFileSync(filePath, image.toPNG()); + return filePath; +}); + +ipcMain.handle('capture:copy', async (event, { cardIdx, archetype }) => { + const win = BrowserWindow.fromWebContents(event.sender); + if (!win) return false; + const query = `card=${cardIdx}&arch=${archetype}`; + const image = await createExportCapture(win, query); + clipboard.writeImage(image); + return true; +}); + +// --- Recap files --- + +ipcMain.handle('recap:list', () => { + if (!fs.existsSync(RECAP_DIR)) return []; + return fs.readdirSync(RECAP_DIR) + .filter(f => f.endsWith('.json')) + .sort() + .reverse(); +}); + +ipcMain.handle('recap:read', (_, filename) => { + const filePath = path.join(RECAP_DIR, path.basename(filename)); + if (!fs.existsSync(filePath)) return null; + try { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } catch { return null; } +}); diff --git a/app/preload.js b/app/preload.js index 2450c44..86e709b 100644 --- a/app/preload.js +++ b/app/preload.js @@ -24,4 +24,13 @@ contextBridge.exposeInMainWorld('obelisk', { ipcRenderer.on('obelisk:index-updated', listener); return () => ipcRenderer.removeListener('obelisk:index-updated', listener); }, + captureExport: (opts) => ipcRenderer.invoke('capture:export', opts), + copyImage: (opts) => ipcRenderer.invoke('capture:copy', opts), + recapList: () => ipcRenderer.invoke('recap:list'), + recapRead: (filename) => ipcRenderer.invoke('recap:read', filename), + onRecapUpdated: (callback) => { + const listener = (_, filePath) => callback(filePath); + ipcRenderer.on('obelisk:recap-updated', listener); + return () => ipcRenderer.removeListener('obelisk:recap-updated', listener); + }, }); diff --git a/app/renderer/src/App.vue b/app/renderer/src/App.vue index af73463..40e16cd 100644 --- a/app/renderer/src/App.vue +++ b/app/renderer/src/App.vue @@ -1,5 +1,5 @@ Activity + Recap + Recap +
+ + + +
diff --git a/app/renderer/src/views/RecapDetail.vue b/app/renderer/src/views/RecapDetail.vue new file mode 100644 index 0000000..060f8b2 --- /dev/null +++ b/app/renderer/src/views/RecapDetail.vue @@ -0,0 +1,305 @@ + + + + + diff --git a/app/renderer/src/views/RecapExport.vue b/app/renderer/src/views/RecapExport.vue new file mode 100644 index 0000000..0ce510b --- /dev/null +++ b/app/renderer/src/views/RecapExport.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/app/renderer/src/views/RecapList.vue b/app/renderer/src/views/RecapList.vue new file mode 100644 index 0000000..40b23e7 --- /dev/null +++ b/app/renderer/src/views/RecapList.vue @@ -0,0 +1,512 @@ + + + + + diff --git a/app/renderer/src/views/SessionDetail.vue b/app/renderer/src/views/SessionDetail.vue index 00a3664..6cd3846 100644 --- a/app/renderer/src/views/SessionDetail.vue +++ b/app/renderer/src/views/SessionDetail.vue @@ -1,5 +1,5 @@ @@ -295,6 +652,29 @@ function getToolCallParsedInput(tc) { + + +