diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index 81f1064a..1e87adfd 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -51,6 +51,7 @@ From `derive-project-path.js`: `deriveProjectPath(folderPath)`, `resolveWorktree - **`resolveWorktreePath` collapses `/.worktrees/` → ``** when the parent dir exists. Consequence: many `~/.claude/projects/-home-...workspace-myproject--worktrees-X` folders derive to the same projectPath. Callers must dedupe (see `get-work-files` IPC for the pattern). - **Two-table sidebar payload**: projects are aggregated, but each session row has its own `subagentType` field. A `null`/empty `subagentType` means it's a parent session; anything else (e.g. `'general-purpose'`, `'researcher'`) marks a subagent. - **`fs.watch` debouncing**: the watcher batches per-folder events in a `pendingChanges = Map | true>` for ~200 ms before flushing to `refreshFolder`. A `true` value means "full walk needed" (rare path). +- **A session's title comes from its first *real* user turn, and a transcript without one is not indexed.** `classifyUserText()` in `read-session-file.js` sorts each user record into `prompt` / `command` / `skip`. `skip` is local-command bookkeeping (``, ``, ``, `` — the CLI writes a command's own output back as a `user` record too); `command` is a bare slash-command record (`/clear…`). This matters because **`/clear` and `/model` do not stay in the current transcript — the CLI opens a NEW jsonl and writes only that bookkeeping into it**. Taking a `command` record as the summary therefore (a) titled every session started by `/clear` "`/clear clear ` or ``, plus the `cache_meta` gate of their folders, which makes the next reconcile re-read exactly those files. - **Stats `firstSessionDate`** is computed from `MIN(modified)`, not `MIN(created)`. Old sessions touched by recent reads keep their original `created` but their `modified` reflects the latest indexing — by design (the heatmap measures activity, not creation). - **`main.js`'s `ctx.db` is a hand-built allow-list, not a spread of `db.js`.** `main.js` (~line 323) passes `sessionCache.init({ ..., db: { deleteCachedFolder, getCachedByFolder, upsertCachedSessions, ... } })` as an explicit object literal — it does **not** do `db: require('./db')`. If you add a new function to `db.js` and call it from `session-cache.js` via `ctx.db.`, but forget to add it to both this literal *and* the `require('./db')` destructure at the top of `main.js`, `ctx.db.` is `undefined`. The resulting `TypeError` is thrown inside `populateCacheViaWorker`'s `worker.on('message')` handler, which has no `try/catch` — it lands on stderr (not `electron-log`) and silently aborts the cold-start indexing write loop. Symptom: the log shows `Indexing N projects…` but never `Indexed N sessions across …`, and the affected table stays empty. Guarded by `test/main-ctx-db-wiring.test.js` (static source-grep asserting the allow-list ⊇ every `ctx.db.*` dereference in `session-cache.js`) — run it whenever you touch this boundary, but also update the allow-list by hand since the test only catches *missing* entries, not the intent. @@ -62,6 +63,8 @@ From `derive-project-path.js`: `deriveProjectPath(folderPath)`, `resolveWorktree - `derive-project-path.test.js` — covers the worktree-collapse + cwd extraction paths - `db-daily-activity.test.js` — covers heatmap aggregation - `read-session-file.test.js` — covers header parsing +- `read-session-file-slash-command.test.js` — covers the `/clear` bookkeeping transcript and slash-command titles +- `db-purge-command-summaries.test.js` — covers migration v9's surgical purge - `main-ctx-db-wiring.test.js` — covers the `ctx.db` allow-list ⊇ session-cache.js usage invariant above - IPC consumers of cached payloads: `get-projects`, `get-active-sessions`, `search`, `get-stats-from-db`, `get-work-files`, `list-subagents`, `read-session-jsonl` - Renderer: `public/sidebar.js` (consumes `buildProjectsFromCache` output), `public/stats-view.js` (consumes `getDailyActivity`) diff --git a/db.js b/db.js index 8f288e3b..6f43097d 100644 --- a/db.js +++ b/db.js @@ -227,6 +227,43 @@ const migrations = [ } } catch {} }, + // v9: purge rows summarised from local-command bookkeeping. /clear and /model + // open a new transcript whose first records are only that bookkeeping, which + // read-session-file.js used to take as the summary — titling every session + // started by /clear "/clear…" and listing bookkeeping-only + // transcripts as phantom sessions. The parser no longer does; drop the rows + // it already wrote, plus the cache_meta gate for their folders so the next + // reconcile re-reads exactly those files (every other file in the folder + // still hits the fileMtime fast path and is left untouched). + (db) => { + try { + const bad = db.prepare(`SELECT sessionId, folder FROM session_cache + WHERE summary LIKE '%' OR summary LIKE '%'` + ).all(); + if (bad.length === 0) return; + const delRow = db.prepare('DELETE FROM session_cache WHERE sessionId = ?'); + const delFolderMeta = db.prepare('DELETE FROM cache_meta WHERE folder = ?'); + for (const { sessionId, folder } of bad) { + delRow.run(sessionId); + if (folder) delFolderMeta.run(folder); + } + // Search entries live in tables created further down this file, so they + // may not exist yet on a DB this migration is the first to touch — + // prepared separately so a missing table throws here instead of skipping + // the purge above. External-content FTS5 ordering: fts delete first (it + // reads search_content), then content, then the map. + try { + const delFts = db.prepare("DELETE FROM search_fts WHERE rowid IN (SELECT rowid FROM search_map WHERE type = 'session' AND id = ?)"); + const delContent = db.prepare("DELETE FROM search_content WHERE rowid IN (SELECT rowid FROM search_map WHERE type = 'session' AND id = ?)"); + const delMap = db.prepare("DELETE FROM search_map WHERE type = 'session' AND id = ?"); + for (const { sessionId } of bad) { + delFts.run(sessionId); + delContent.run(sessionId); + delMap.run(sessionId); + } + } catch {} + } catch {} + }, ]; const currentDbVersion = (() => { diff --git a/read-session-file.js b/read-session-file.js index 6fd2978a..5972b5a4 100644 --- a/read-session-file.js +++ b/read-session-file.js @@ -109,6 +109,34 @@ function extractDailyMetrics(lines, fallbackDate) { return Array.from(map.values()); } +// --- First-prompt selection --- +// Some records typed `user` carry no prompt: `!`-prefixed shell input, the +// caveat Claude Code wraps local-command output in, that output itself, and +// bare slash-command invocations. /clear and /model open a BRAND NEW transcript +// whose only records are that bookkeeping, so treating one as the session +// summary both mistitles every session started by /clear and lists +// content-free transcripts as phantom sidebar entries. +const LOCAL_COMMAND_RE = /|||/; +const SLASH_COMMAND_RE = /^\s*([^<]*)<\/command-name>/; +const COMMAND_ARGS_RE = /([^<]*)<\/command-args>/; + +/** Classify a user message's text as a summary candidate: + * 'prompt' — a real user turn, used as-is. + * 'command' — a slash-command invocation, usable only as a fallback. + * 'skip' — local-command bookkeeping, never a summary. + */ +function classifyUserText(text) { + if (!text || LOCAL_COMMAND_RE.test(text)) return { kind: 'skip', text: '' }; + const cmd = text.match(SLASH_COMMAND_RE); + if (cmd) { + const name = cmd[1].trim(); + const args = (text.match(COMMAND_ARGS_RE)?.[1] || '').trim(); + return { kind: 'command', text: (args ? name + ' ' + args : name).slice(0, 120) }; + } + const taskMatch = text.match(/||/.test(text)) { - // Use scheduled task name if present - const taskMatch = text.match(/||/.test(txt)) { - const taskMatch = txt.match(/ { assert.equal(state.marker, null, 'an empty cache means no scan ever completed -- the marker must be earned by the first successful scan, ' + 'otherwise an interrupted first scan would be blessed as complete on relaunch'); - assert.equal(state.version, '8'); + assert.equal(state.version, '9'); } finally { fs.rmSync(dir, { recursive: true, force: true }); } @@ -100,7 +100,7 @@ test('pre-marker install (populated cache, db_version 7) gets the marker backfil assert.equal(state.marker, 'true', 'a populated pre-marker cache can only come from a completed batch-write scan -- ' + 'migration v8 must bless it or every existing install would re-run the full cold-start scan'); - assert.equal(state.version, '8'); + assert.equal(state.version, '9'); assert.equal(state.cacheCount, 1, 'the existing cache rows are preserved, not rescanned'); } finally { fs.rmSync(dir, { recursive: true, force: true }); @@ -110,7 +110,7 @@ test('pre-marker install (populated cache, db_version 7) gets the marker backfil test('the backfill runs once: a partial cache after an interrupted post-marker scan is NOT re-blessed on relaunch', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-marker-partial-')); try { - // First load: fresh DB, migrations run to completion (db_version 8), no + // First load: fresh DB, migrations run to completion (db_version 9), no // marker (empty cache). Now simulate an interrupted first scan: some rows // land in session_cache, the marker is never written. const init = loadDbModule(dir); @@ -122,7 +122,7 @@ test('the backfill runs once: a partial cache after an interrupted post-marker s `, dir); assert.equal(seed.status, 0, seed.stderr); - // Relaunch: db_version is already 8, so migration v8 must not run again. + // Relaunch: db_version is already 9, so migration v8 must not run again. // If it did, the partial cache would be blessed as complete and the next // get-projects would take the warm branch straight into the synchronous // reconcile sweep -- the freeze the marker exists to prevent. diff --git a/test/db-purge-command-summaries.test.js b/test/db-purge-command-summaries.test.js new file mode 100644 index 00000000..dd2b492d --- /dev/null +++ b/test/db-purge-command-summaries.test.js @@ -0,0 +1,117 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { spawnSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const APP_DIR = path.join(__dirname, '..'); +// better-sqlite3 is compiled for Electron's ABI — run every DB snippet under +// Electron-as-Node, same as the other db.js tests. +const electronBin = require('electron'); + +function runInElectronNode(code, dataDir) { + return spawnSync(electronBin, ['-e', code], { + cwd: APP_DIR, + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', SWITCHBOARD_DATA_DIR: dataDir }, + encoding: 'utf8', + }); +} + +function loadDbModule(dataDir) { + return runInElectronNode(`require(${JSON.stringify(path.join(APP_DIR, 'db.js'))})`, dataDir); +} + +// Migration v9 purges the rows a pre-fix parser summarised from the +// slash-command records /clear writes into the transcript it opens. Those rows +// cannot heal on their own: the phantom ones sit on a file that never changes +// again (so the watcher never revisits it), and the real ones keep the bad +// title because the header-only refresh path only overwrites a summary it can +// re-derive. +test('migration v9 purges slash-command summaries and re-opens their folder for re-indexing', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-cmd-purge-')); + try { + const init = loadDbModule(dir); + assert.equal(init.status, 0, init.stderr); + + const seed = runInElectronNode(` + const Database = require('better-sqlite3'); + const db = new Database(require('path').join(process.env.SWITCHBOARD_DATA_DIR, 'switchboard.db')); + db.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('db_version', '8')").run(); + const ins = db.prepare('INSERT INTO session_cache (sessionId, folder, projectPath, summary, modified) VALUES (?, ?, ?, ?, ?)'); + ins.run('phantom', 'f1', '/tmp/p1', '/clear\\nclear', '2026-01-01T00:00:00Z'); + ins.run('real', 'f1', '/tmp/p1', 'fix the watcher', '2026-01-02T00:00:00Z'); + ins.run('other-folder', 'f2', '/tmp/p2', 'unrelated work', '2026-01-03T00:00:00Z'); + db.prepare('INSERT INTO cache_meta (folder, projectPath, indexMtimeMs) VALUES (?, ?, ?)').run('f1', '/tmp/p1', 123); + db.prepare('INSERT INTO cache_meta (folder, projectPath, indexMtimeMs) VALUES (?, ?, ?)').run('f2', '/tmp/p2', 456); + // Mirror upsertSearchEntries: map row first, then the content column + // store, then the fts5 shadow row, all sharing the same rowid. + db.prepare('INSERT INTO search_map (rowid, id, type, folder) VALUES (1, ?, ?, ?)').run('phantom', 'session', 'f1'); + db.prepare('INSERT INTO search_content (rowid, title, body) VALUES (1, ?, ?)').run('/clear', 'body'); + db.prepare('INSERT INTO search_fts (rowid, title, body) VALUES (1, ?, ?)').run('/clear', 'body'); + `, dir); + assert.equal(seed.status, 0, seed.stderr); + + const r = loadDbModule(dir); + assert.equal(r.status, 0, r.stderr); + + const inspect = runInElectronNode(` + const Database = require('better-sqlite3'); + const db = new Database(require('path').join(process.env.SWITCHBOARD_DATA_DIR, 'switchboard.db'), { readonly: true }); + console.log(JSON.stringify({ + ids: db.prepare('SELECT sessionId FROM session_cache ORDER BY sessionId').all().map(r => r.sessionId), + folders: db.prepare('SELECT folder FROM cache_meta ORDER BY folder').all().map(r => r.folder), + searchIds: db.prepare("SELECT id FROM search_map WHERE type = 'session'").all().map(r => r.id), + searchContent: db.prepare('SELECT COUNT(*) AS n FROM search_content').get().n, + version: db.prepare("SELECT value FROM settings WHERE key = 'db_version'").get()?.value, + })); + `, dir); + assert.equal(inspect.status, 0, inspect.stderr); + const state = JSON.parse(inspect.stdout.trim().split('\n').pop()); + + assert.deepEqual(state.ids, ['other-folder', 'real'], 'only the command-summary row is dropped'); + assert.deepEqual(state.folders, ['f2'], + 'the affected folder loses its index gate so reconcile re-reads the purged file; untouched folders keep theirs'); + assert.deepEqual(state.searchIds, [], 'the purged row leaves no stale search entry behind'); + assert.equal(state.searchContent, 0); + assert.equal(state.version, '9'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('migration v9 is a no-op on a database with no command summaries', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-cmd-purge-noop-')); + try { + const init = loadDbModule(dir); + assert.equal(init.status, 0, init.stderr); + + const seed = runInElectronNode(` + const Database = require('better-sqlite3'); + const db = new Database(require('path').join(process.env.SWITCHBOARD_DATA_DIR, 'switchboard.db')); + db.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('db_version', '8')").run(); + db.prepare('INSERT INTO session_cache (sessionId, folder, summary, modified) VALUES (?, ?, ?, ?)') + .run('s1', 'f1', 'real work', '2026-01-01T00:00:00Z'); + db.prepare('INSERT INTO cache_meta (folder, projectPath, indexMtimeMs) VALUES (?, ?, ?)').run('f1', '/tmp/p1', 123); + `, dir); + assert.equal(seed.status, 0, seed.stderr); + + const r = loadDbModule(dir); + assert.equal(r.status, 0, r.stderr); + + const inspect = runInElectronNode(` + const Database = require('better-sqlite3'); + const db = new Database(require('path').join(process.env.SWITCHBOARD_DATA_DIR, 'switchboard.db'), { readonly: true }); + console.log(JSON.stringify({ + cacheCount: db.prepare('SELECT COUNT(*) AS n FROM session_cache').get().n, + metaCount: db.prepare('SELECT COUNT(*) AS n FROM cache_meta').get().n, + })); + `, dir); + assert.equal(inspect.status, 0, inspect.stderr); + const state = JSON.parse(inspect.stdout.trim().split('\n').pop()); + assert.equal(state.cacheCount, 1, 'no re-index forced on installs that never hit the bug'); + assert.equal(state.metaCount, 1); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/test/db-schema-reconcile.test.js b/test/db-schema-reconcile.test.js index 20392c51..a32d9e30 100644 --- a/test/db-schema-reconcile.test.js +++ b/test/db-schema-reconcile.test.js @@ -83,11 +83,11 @@ test('foreign higher-version database is reconciled, not crashed', () => { assert.ok(state.cols.includes('fileMtime'), 'fileMtime column added'); assert.equal(state.cacheCount, 0, 'stale cache cleared for re-index'); assert.equal(state.metaCount, 0, 'folder index gate cleared for re-index'); - // Fork divergence from upstream: our migrations array has 8 entries (vs 4 - // upstream), so a version-5 DB legitimately runs v6-v8 (all idempotent - // try/catch) and gets bumped to 8. The invariant that matters is "never + // Fork divergence from upstream: our migrations array has 9 entries (vs 4 + // upstream), so a version-5 DB legitimately runs v6-v9 (all idempotent + // try/catch) and gets bumped to 9. The invariant that matters is "never // downgraded"; a DB claiming a version above ours stays untouched. - assert.equal(state.version, '8', 'foreign db_version upgraded to ours, never downgraded'); + assert.equal(state.version, '9', 'foreign db_version upgraded to ours, never downgraded'); for (const col of ['parentSessionId', 'agentId', 'runtime', 'sessionFile']) { assert.ok(state.cols.includes(col), `foreign column ${col} preserved`); } diff --git a/test/read-session-file-slash-command.test.js b/test/read-session-file-slash-command.test.js new file mode 100644 index 00000000..0fee984e --- /dev/null +++ b/test/read-session-file-slash-command.test.js @@ -0,0 +1,175 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { readSessionFile, readSessionDisplayHeader } = require('../read-session-file'); + +function mkTmp() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-slash-')); +} + +function cleanup(dir) { + fs.rmSync(dir, { recursive: true, force: true }); +} + +function write(dir, sessionId, entries) { + const filePath = path.join(dir, `${sessionId}.jsonl`); + fs.writeFileSync(filePath, entries.map(e => JSON.stringify(e)).join('\n') + '\n', 'utf8'); + return filePath; +} + +// The exact records Claude Code writes into the NEW transcript /clear opens. +const CLEAR_BOOKKEEPING = [ + { type: 'mode', mode: 'normal' }, + { type: 'file-history-snapshot', messageId: 'm1' }, + { + type: 'user', + isMeta: true, + timestamp: '2026-08-26T07:44:02.583Z', + message: { + role: 'user', + content: 'Caveat: The messages below were generated by the user while running local commands.', + }, + }, + { + type: 'user', + timestamp: '2026-08-26T07:44:02.578Z', + message: { + role: 'user', + content: '/clear\n clear\n ', + }, + }, + { + type: 'system', + subtype: 'local_command', + timestamp: '2026-08-26T07:44:02.582Z', + content: '', + }, +]; + +test('a transcript holding only /clear bookkeeping is not indexed at all', () => { + const tmp = mkTmp(); + try { + const filePath = write(tmp, 'cleared', CLEAR_BOOKKEEPING); + assert.equal(readSessionFile(filePath, 'folder-x', '/some/project'), null, + '/clear opens a new transcript with no conversation in it — indexing it puts a phantom session in the sidebar'); + assert.equal(readSessionDisplayHeader(filePath), null); + } finally { + cleanup(tmp); + } +}); + +test('a session started by /clear is titled by its first real prompt', () => { + const tmp = mkTmp(); + try { + const filePath = write(tmp, 'after-clear', [ + ...CLEAR_BOOKKEEPING, + { type: 'user', timestamp: '2026-08-26T07:45:00.000Z', message: { role: 'user', content: 'fix the watcher' } }, + { type: 'assistant', timestamp: '2026-08-26T07:45:01.000Z', message: { role: 'assistant', content: 'on it' } }, + ]); + + const row = readSessionFile(filePath, 'folder-x', '/some/project'); + assert.equal(row.summary, 'fix the watcher'); + assert.equal(row.firstPrompt, 'fix the watcher'); + assert.equal(readSessionDisplayHeader(filePath).summary, 'fix the watcher'); + } finally { + cleanup(tmp); + } +}); + +test('a session whose only user turn is a slash command is titled by that command', () => { + const tmp = mkTmp(); + try { + const filePath = write(tmp, 'command-only', [ + { + type: 'user', + timestamp: '2026-08-26T07:44:02.578Z', + message: { + role: 'user', + content: '/code-review\ncode-review\nhigh', + }, + }, + { type: 'assistant', timestamp: '2026-08-26T07:44:03.000Z', message: { role: 'assistant', content: 'reviewing' } }, + ]); + + // The raw record would render as "/code-review code-review { + const tmp = mkTmp(); + try { + const filePath = write(tmp, 'model-only', [ + { + type: 'user', + timestamp: '2026-08-26T07:44:02.578Z', + message: { + role: 'user', + content: '/model\nmodel\n', + }, + }, + ]); + assert.equal(readSessionFile(filePath, 'folder-x', '/some/project'), null); + assert.equal(readSessionDisplayHeader(filePath), null); + } finally { + cleanup(tmp); + } +}); + +test("a command's own output is not a prompt either", () => { + const tmp = mkTmp(); + try { + // /model logs its confirmation back as a `user` record; before the fix it + // became the title of every session where /model preceded the first prompt. + const filePath = write(tmp, 'model-then-work', [ + ...CLEAR_BOOKKEEPING, + { + type: 'user', + timestamp: '2026-08-26T07:45:00.000Z', + message: { + role: 'user', + content: '/model\nmodel\nsonnet', + }, + }, + { + type: 'user', + timestamp: '2026-08-26T07:45:01.000Z', + message: { role: 'user', content: 'Set model to Sonnet 5' }, + }, + { type: 'user', timestamp: '2026-08-26T07:46:00.000Z', message: { role: 'user', content: 'add the motion sensor' } }, + { type: 'assistant', timestamp: '2026-08-26T07:46:01.000Z', message: { role: 'assistant', content: 'on it' } }, + ]); + + assert.equal(readSessionFile(filePath, 'folder-x', '/some/project').summary, 'add the motion sensor'); + assert.equal(readSessionDisplayHeader(filePath).summary, 'add the motion sensor'); + } finally { + cleanup(tmp); + } +}); + +test('a scheduled-task prompt still wins over a leading slash command', () => { + const tmp = mkTmp(); + try { + const filePath = write(tmp, 'scheduled', [ + ...CLEAR_BOOKKEEPING, + { + type: 'user', + timestamp: '2026-08-26T07:45:00.000Z', + message: { role: 'user', content: 'run it' }, + }, + { type: 'assistant', timestamp: '2026-08-26T07:45:01.000Z', message: { role: 'assistant', content: 'ok' } }, + ]); + assert.equal(readSessionFile(filePath, 'folder-x', '/some/project').summary, + 'Scheduled: nightly introspection'); + } finally { + cleanup(tmp); + } +});