Problem
GET /api/brain/summary (server/routes/brainSettings.js:76-79) calls brainStorage.getSummary() (server/services/brainStorage.js:1278-1310), which runs getAll() on seven entity stores (people, projects, ideas, admin, memories, links, buckets) plus getInboxLogCounts() (brainStorage.js:853-873, itself getAll('inbox')). getAll → loadRawMap (brainStorage.js:250-258) lists the type directory and loadOnes every record body (readFile + JSON.parse per data/brain/<type>/<id>/index.json). All of that is thrown away except .length, one status field, and isGitHubRepo.
Brain already has the cheap primitive for exactly this question: the per-record projection index (createRecordIndex / resolveRecordIndex, brainStorage.js:313-379) that #3508 (listLiveIds, :395) and #3509 (link summary index, :1047-1114) introduced so a store the process has already walked answers from memory with zero body reads. getSummary and getInboxLogCounts never got moved onto it.
Trigger
client/src/pages/Brain.jsx:43-51 — useAutoRefetch(fetchData, 30_000) fetches /brain/summary on every Brain page mount and every 30 s while the page is visible, on every tab (Inbox, Links, Daily Log, Graph, …). The result renders three numbers in the header (needsReview, links, projects, people) and a "last digest" time.
Impact
Per poll: 8 readdir + N lstat + N readFile + N JSON.parse, where N is every record (including tombstones) across the eight stores. A single ChatGPT import puts ~1,400 memories records on disk with up to 9,800 chars of content each (server/services/chatgptImport.js:27), so one poll parses on the order of 14 MB of JSON; at 120 polls/hour that is ~1.6 GB parsed per hour of having the Brain page open, to display counts that change a few times a day. The same walk runs concurrently with whatever the active tab is loading (Links limit: 5000, Inbox, Graph context), so the tab's own requests queue behind it on the same disk.
Fix
Add a summary projection index in server/services/brainStorage.js and answer both counters from it — no body reads in steady state.
- Next to
projectLinkSummary (:1047), add
const projectSummary = (record) => (record && !isTombstone(record)
? { status: record.status, isGitHubRepo: record.isGitHubRepo }
: null);
const summaryIndex = createRecordIndex(projectSummary);
createRecordIndex self-registers for invalidation (recordIndexes, :306), so every existing freshness signal (${type}:upserted / ${type}:deleted / record:changed, wired at :411-415) and invalidateAllCaches() (:1262) already cover it — nothing new to wire. A null projection = "not a user-visible record", exactly what getAll drops (missing, unparseable, tombstoned), so counts stay identical.
- Rewrite
getSummary() to Promise.all a resolveRecordIndex(summaryIndex, type, 0) per type, keep only non-null rows, and derive the same fields with the same predicates it uses today: counts.<type> = live rows (archived records are counted today via getAll — keep that), activeProjects = status === 'active', activeIdeas = !status || status === 'active', openAdmin = status === 'open', gitHubRepos = truthy isGitHubRepo, needsReview = inbox needs_review. loadMeta() stays as is.
- Rewrite
getInboxLogCounts() to tally status over the same index for 'inbox' (same seven keys; unknown statuses still only count toward total). Its other callers — GET /api/brain/inbox (server/routes/brainCapture.js:40), jobGates.brainReviewGate (server/services/jobGates.js:22) — need no change.
Steady-state cost becomes 8 readdir + N lstat (the listIds() membership check the #3508/#3509 indexes already accept) and zero reads/parses.
Rejected: caching the whole summary object with a TTL — a 30 s poll against a 2 s cache still walks every body 30× an hour, and a longer TTL makes the "needs review" badge lag captures; the projection index is invalidated per record and is the pattern the file already uses. Rejected: counting from listIds().length alone — can't distinguish tombstones or answer the status splits.
Files: server/services/brainStorage.js, server/services/brainStorage.test.js.
Tests (in brainStorage.test.js, beside describe('getLinksPage …', :438), using the same temp-dir store and the readJSONFile spy the #3508 test at :386 uses):
getSummary over a seeded store (live + archived + tombstoned + one unparseable record per type, mixed statuses, one GitHub link) returns the same object as the current implementation.
- a second
getSummary call reads zero record bodies (spy call count unchanged).
update(type, id, { status: 'done' }) and remove(type, id) are reflected on the next call (per-id invalidation).
getInboxLogCounts after remove('inbox', id) drops the tombstone from total and its status bucket.
Acceptance criteria
Problem
GET /api/brain/summary(server/routes/brainSettings.js:76-79) callsbrainStorage.getSummary()(server/services/brainStorage.js:1278-1310), which runsgetAll()on seven entity stores (people, projects, ideas, admin, memories, links, buckets) plusgetInboxLogCounts()(brainStorage.js:853-873, itselfgetAll('inbox')).getAll→loadRawMap(brainStorage.js:250-258) lists the type directory andloadOnes every record body (readFile+JSON.parseperdata/brain/<type>/<id>/index.json). All of that is thrown away except.length, onestatusfield, andisGitHubRepo.Brain already has the cheap primitive for exactly this question: the per-record projection index (
createRecordIndex/resolveRecordIndex,brainStorage.js:313-379) that #3508 (listLiveIds, :395) and #3509 (link summary index, :1047-1114) introduced so a store the process has already walked answers from memory with zero body reads.getSummaryandgetInboxLogCountsnever got moved onto it.Trigger
client/src/pages/Brain.jsx:43-51—useAutoRefetch(fetchData, 30_000)fetches/brain/summaryon every Brain page mount and every 30 s while the page is visible, on every tab (Inbox, Links, Daily Log, Graph, …). The result renders three numbers in the header (needsReview,links,projects,people) and a "last digest" time.Impact
Per poll: 8
readdir+ Nlstat+ NreadFile+ NJSON.parse, where N is every record (including tombstones) across the eight stores. A single ChatGPT import puts ~1,400memoriesrecords on disk with up to 9,800 chars ofcontenteach (server/services/chatgptImport.js:27), so one poll parses on the order of 14 MB of JSON; at 120 polls/hour that is ~1.6 GB parsed per hour of having the Brain page open, to display counts that change a few times a day. The same walk runs concurrently with whatever the active tab is loading (Linkslimit: 5000, Inbox, Graph context), so the tab's own requests queue behind it on the same disk.Fix
Add a summary projection index in
server/services/brainStorage.jsand answer both counters from it — no body reads in steady state.projectLinkSummary(:1047), addcreateRecordIndexself-registers for invalidation (recordIndexes, :306), so every existing freshness signal (${type}:upserted/${type}:deleted/record:changed, wired at :411-415) andinvalidateAllCaches()(:1262) already cover it — nothing new to wire. Anullprojection = "not a user-visible record", exactly whatgetAlldrops (missing, unparseable, tombstoned), so counts stay identical.getSummary()toPromise.allaresolveRecordIndex(summaryIndex, type, 0)per type, keep only non-null rows, and derive the same fields with the same predicates it uses today:counts.<type>= live rows (archived records are counted today viagetAll— keep that),activeProjects=status === 'active',activeIdeas=!status || status === 'active',openAdmin=status === 'open',gitHubRepos= truthyisGitHubRepo,needsReview= inboxneeds_review.loadMeta()stays as is.getInboxLogCounts()to tallystatusover the same index for'inbox'(same seven keys; unknown statuses still only count towardtotal). Its other callers —GET /api/brain/inbox(server/routes/brainCapture.js:40),jobGates.brainReviewGate(server/services/jobGates.js:22) — need no change.Steady-state cost becomes 8
readdir+ Nlstat(thelistIds()membership check the #3508/#3509 indexes already accept) and zero reads/parses.Rejected: caching the whole summary object with a TTL — a 30 s poll against a 2 s cache still walks every body 30× an hour, and a longer TTL makes the "needs review" badge lag captures; the projection index is invalidated per record and is the pattern the file already uses. Rejected: counting from
listIds().lengthalone — can't distinguish tombstones or answer the status splits.Files:
server/services/brainStorage.js,server/services/brainStorage.test.js.Tests (in
brainStorage.test.js, besidedescribe('getLinksPage …', :438), using the same temp-dir store and thereadJSONFilespy the #3508 test at :386 uses):getSummaryover a seeded store (live + archived + tombstoned + one unparseable record per type, mixed statuses, one GitHub link) returns the same object as the current implementation.getSummarycall reads zero record bodies (spy call count unchanged).update(type, id, { status: 'done' })andremove(type, id)are reflected on the next call (per-id invalidation).getInboxLogCountsafterremove('inbox', id)drops the tombstone fromtotaland its status bucket.Acceptance criteria
GET /api/brain/summaryreturns a byte-identical shape for the same data before/after (fields, predicates, archived-included counts).getSummary()/getInboxLogCounts()call with no writes in between performs noreadFileof anydata/brain/<type>/<id>/index.json(asserted by test).applyRemoteRecordchange are each reflected on the next call.server/services/brainStorage.test.jscovers the four cases above;cd server && npm testgreen.