Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .ai/contexts/session-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ From `derive-project-path.js`: `deriveProjectPath(folderPath)`, `resolveWorktree
- **`resolveWorktreePath` collapses `<repo>/.worktrees/<name>` → `<repo>`** 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<folder, Set<filename> | 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 (`<bash-input>`, `<bash-stdout>`, `<local-command-caveat>`, `<local-command-stdout>` — the CLI writes a command's own output back as a `user` record too); `command` is a bare slash-command record (`<command-name>/clear</command-name>…`). 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 </com…`" (the raw tags survive `cleanDisplayName`'s tag strip as a truncated fragment) and (b) indexed the bookkeeping-only transcript as a phantom sidebar session that the user never started. A `command` record is now a *fallback* title, used only when the transcript also holds an assistant turn (`/code-review high` → a real headless-command session); with no assistant turn both readers return `null` and nothing is indexed, matching how a brand-new session stays out of the sidebar until its first prompt. Rows written by the pre-fix parser cannot self-heal — the phantom ones sit on a file that never changes again, and the real ones keep the bad title because the header-only refresh path only overwrites a summary it can re-derive — so `db.js` migration **v9** purges rows whose summary starts with `<command-name>` or `<local-command-stdout>`, 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.<name>`, but forget to add it to both this literal *and* the `require('./db')` destructure at the top of `main.js`, `ctx.db.<name>` 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.
Expand All @@ -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`)
Expand Down
37 changes: 37 additions & 0 deletions db.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<command-name>/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 '<command-name>%' OR summary LIKE '<local-command-stdout>%'`
).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 = (() => {
Expand Down
62 changes: 51 additions & 11 deletions read-session-file.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = /<bash-input>|<bash-stdout>|<local-command-caveat>|<local-command-stdout>/;
const SLASH_COMMAND_RE = /^\s*<command-name>([^<]*)<\/command-name>/;
const COMMAND_ARGS_RE = /<command-args>([^<]*)<\/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(/<scheduled-task\s+name="([^"]+)"/);
return { kind: 'prompt', text: taskMatch ? 'Scheduled: ' + taskMatch[1] : text.slice(0, 120) };
}

/** Parse a single .jsonl file into a session object (or null if invalid).
* opts.parentSessionId — if set, treat as a subagent transcript and stamp the
* parent reference into the returned row.
Expand All @@ -121,6 +149,9 @@ function readSessionFile(filePath, folder, projectPath, opts = {}) {
const content = fs.readFileSync(filePath, 'utf8');
const lines = content.split('\n').filter(Boolean);
let summary = '';
// Fallback title for a session whose only user turn is a slash command.
let commandSummary = '';
let assistantSeen = false;
let messageCount = 0;
let textContent = '';
let slug = null;
Expand Down Expand Up @@ -158,22 +189,26 @@ function readSessionFile(filePath, folder, projectPath, opts = {}) {
(entry.type === 'message' && (entry.role === 'user' || entry.role === 'assistant'))) {
messageCount++;
}
if (entry.type === 'assistant' || (entry.type === 'message' && entry.role === 'assistant')) {
assistantSeen = true;
}
const msg = entry.message;
const text = typeof msg === 'string' ? msg :
(typeof msg?.content === 'string' ? msg.content :
(msg?.content?.[0]?.text || ''));
if (!summary && (entry.type === 'user' || (entry.type === 'message' && entry.role === 'user'))) {
// Skip local command messages (! prefix) — use the next real user message
if (text && !/<bash-input>|<bash-stdout>|<local-command-caveat>/.test(text)) {
// Use scheduled task name if present
const taskMatch = text.match(/<scheduled-task\s+name="([^"]+)"/);
summary = taskMatch ? 'Scheduled: ' + taskMatch[1] : text.slice(0, 120);
}
const cand = classifyUserText(text);
if (cand.kind === 'prompt') summary = cand.text;
else if (cand.kind === 'command' && !commandSummary) commandSummary = cand.text;
}
if (text && textContent.length < 8000) {
textContent += text.slice(0, 500) + '\n';
}
}
// A slash command stands in as the title only when the session went on to
// do something. Bookkeeping-only transcripts (a bare /clear) have nothing
// to show and must not be indexed at all.
if (!summary && assistantSeen) summary = commandSummary;
if (!summary || messageCount < 1) return null;

const fallbackDate = stat.mtime.toISOString().slice(0, 10);
Expand Down Expand Up @@ -312,6 +347,8 @@ function readSessionDisplayHeader(filePath, opts = {}) {
if (n < stat.size) lines.pop();

let summary = '';
let commandSummary = '';
let assistantSeen = false;
let slug = null, customTitle = null, aiTitle = null, agentId = null;
let sidechainSeen = false;
let lineCount = 0;
Expand All @@ -323,20 +360,23 @@ function readSessionDisplayHeader(filePath, opts = {}) {
if (entry.slug && !slug) slug = entry.slug;
if (entry.agentId && !agentId) agentId = entry.agentId;
if (entry.isSidechain) sidechainSeen = true;
if (entry.type === 'assistant' || (entry.type === 'message' && entry.role === 'assistant')) {
assistantSeen = true;
}
if (entry.type === 'custom-title' && entry.customTitle && !customTitle) customTitle = entry.customTitle;
if (entry.type === 'ai-title' && entry.aiTitle && !aiTitle) aiTitle = entry.aiTitle;
const msg = entry.message;
const txt = typeof msg === 'string' ? msg :
(typeof msg?.content === 'string' ? msg.content :
(msg?.content?.[0]?.text || ''));
if (!summary && (entry.type === 'user' || (entry.type === 'message' && entry.role === 'user'))) {
if (txt && !/<bash-input>|<bash-stdout>|<local-command-caveat>/.test(txt)) {
const taskMatch = txt.match(/<scheduled-task\s+name="([^"]+)"/);
summary = taskMatch ? 'Scheduled: ' + taskMatch[1] : txt.slice(0, 120);
}
const cand = classifyUserText(txt);
if (cand.kind === 'prompt') summary = cand.text;
else if (cand.kind === 'command' && !commandSummary) commandSummary = cand.text;
}
}

if (!summary && assistantSeen) summary = commandSummary;
if (!summary) return null;

if (isSubagent) {
Expand Down Expand Up @@ -371,4 +411,4 @@ function readSessionDisplayHeader(filePath, opts = {}) {
}
}

module.exports = { readSessionFile, readSessionDisplayHeader, subagentSessionId, resolveJsonlPath, readSubagentMeta, enumerateSessionFiles, extractDailyMetrics, isToolResultOnly };
module.exports = { readSessionFile, readSessionDisplayHeader, classifyUserText, subagentSessionId, resolveJsonlPath, readSubagentMeta, enumerateSessionFiles, extractDailyMetrics, isToolResultOnly };
8 changes: 4 additions & 4 deletions test/db-initial-scan-marker.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ test('fresh empty database does NOT get the completeness marker', () => {
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 });
}
Expand Down Expand Up @@ -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 });
Expand All @@ -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);
Expand All @@ -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.
Expand Down
Loading
Loading