You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
brainStorage.getInboxLog() (server/services/brainStorage.js:803-814) is getAll('inbox') — it reads and JSON.parses every inbox record (capturedText + classification + extracted + ai metadata) — then sorts with new Date(b.capturedAt) - new Date(a.capturedAt) inside the comparator (re-parsing each timestamp O(n log n) times), filters by status, and finally slices the page. GET /api/brain/inbox (server/routes/brainCapture.js:36-43) runs that andgetInboxLogCounts() (:853-873, a second full getAll('inbox')) per request.
The inbox never shrinks: markInboxDone, resolveReview, fixClassification only flip status, and there is no prune, so N is every capture the user has ever made. Links got the paged/indexed read path in #3509 (getLinksPage, :1074-1095); the inbox — polled far more often — still has the full-walk one.
Trigger
client/src/pages/OpenWorld.jsx:427-431 — useAutoRefetch(() => api.getBrainInbox({ status: 'needs_review', limit: 1 }), 60_000): two full inbox walks every 60 s while the OpenWorld page is open, to get one count for the memory-district well.
client/src/components/brain/tabs/InboxTab.jsx:77 (default page of 50) and TrustTab.jsx:33 (limit 100) on every tab open, filter change, and after each brain:classified socket event refresh.
server/services/jobGates.js:22brainReviewGate on every CoS job-scheduler eligibility check; server/services/askService.js:199 and server/services/voice/tools/brain.js:70,105 (limit 200) per ask/voice turn; runWeeklyReview (brain.js:660, limit 500); recoverStuckClassifications (brain.js:828) at boot.
Impact
N inbox records ⇒ 2N file reads + 2N parses + an O(n log n) date-parsing sort per request, for a page of ≤100 records. A user who has captured 5,000 thoughts pays 10,000 reads per minute from OpenWorld alone, and every capture makes the next Inbox tab open slower. Because the page bodies are the only thing actually rendered, all of the (N − limit) parsed bodies are discarded.
Fix
Page the inbox from a projection index, mirroring getLinksPage (#3509), in server/services/brainStorage.js.
Extend the summary projection from Brain summary poll parses every brain record body every 30s to render three counts #5438 (or add it if that lands second) with the inbox sort key: { status: record.status, isGitHubRepo: record.isGitHubRepo, capturedAtMs: safeDate(record.capturedAt) }, null for tombstone/unparseable — one index, one invalidation path, shared by getSummary, getInboxLogCounts, and this.
Rewrite getInboxLog({ status, limit = 50, offset = 0 }) to: resolve the index for 'inbox', drop null rows, filter on status when given (strict ===, as today), sort by capturedAtMs desc with an id tiebreak (bulk captures and imports share a timestamp; an unstable order across two page requests drops/duplicates rows at the slice boundary — same reasoning as getLinksPage:1084-1087), slice, then Promise.all(pageIds.map((id) => getById('inbox', id))) and drop nulls (deleted between index and body read). Keep the signature and the plain-array return so the seven callers above don't change.
Behavioral notes to preserve/accept: a missing/unparseable capturedAt currently yields NaN in the comparator (undefined order); with safeDate it becomes 0 and sorts last deterministically — same choice #3509 made for createdAt. createInboxLog always stamps capturedAt, so this only affects hand-edited records.
Rejected: a status-keyed sidecar index file on disk — a second source of truth that must be kept in step with per-record writes and peer applies; the in-memory projection rebuilds from readdir and is already the established pattern. Rejected: pruning done/filed entries — a data-retention policy change, not a perf fix.
Tests (in brainStorage.test.js beside describe('getLinksPage …', :438), same temp-dir store + readJSONFile spy as the :474 test):
seeded inbox of ~8 entries with mixed statuses, two sharing a capturedAt, one tombstoned, one with no capturedAt: getInboxLog({ limit, offset }), getInboxLog({ status }) return the same ids in the same order as the current implementation (tombstone excluded, missing-date last), and two adjacent pages neither overlap nor skip.
a repeat page read loads only limit record bodies (spy count), not N.
updateInboxLog(id, { status: 'done' }) moves the record across the status filter on the next call; deleteInboxLog(id) removes it.
Acceptance criteria
GET /api/brain/inbox?status=needs_review&limit=1 reads at most 1 record body from disk in steady state (index warm), not 2N.
Page contents, ordering, and counts are identical to the current implementation for a seeded store (tests above).
getInboxLog keeps its { status, limit, offset } signature and array return; no caller changes.
Problem
brainStorage.getInboxLog()(server/services/brainStorage.js:803-814) isgetAll('inbox')— it reads andJSON.parses every inbox record (capturedText+classification+extracted+aimetadata) — then sorts withnew Date(b.capturedAt) - new Date(a.capturedAt)inside the comparator (re-parsing each timestamp O(n log n) times), filters bystatus, and finally slices the page.GET /api/brain/inbox(server/routes/brainCapture.js:36-43) runs that andgetInboxLogCounts()(:853-873, a second fullgetAll('inbox')) per request.The inbox never shrinks:
markInboxDone,resolveReview,fixClassificationonly flipstatus, and there is no prune, so N is every capture the user has ever made. Links got the paged/indexed read path in #3509 (getLinksPage, :1074-1095); the inbox — polled far more often — still has the full-walk one.Trigger
client/src/pages/OpenWorld.jsx:427-431—useAutoRefetch(() => api.getBrainInbox({ status: 'needs_review', limit: 1 }), 60_000): two full inbox walks every 60 s while the OpenWorld page is open, to get one count for the memory-district well.client/src/components/brain/tabs/InboxTab.jsx:77(default page of 50) andTrustTab.jsx:33(limit 100) on every tab open, filter change, and after eachbrain:classifiedsocket event refresh.server/services/jobGates.js:22brainReviewGateon every CoS job-scheduler eligibility check;server/services/askService.js:199andserver/services/voice/tools/brain.js:70,105(limit 200) per ask/voice turn;runWeeklyReview(brain.js:660, limit 500);recoverStuckClassifications(brain.js:828) at boot.Impact
N inbox records ⇒ 2N file reads + 2N parses + an O(n log n) date-parsing sort per request, for a page of ≤100 records. A user who has captured 5,000 thoughts pays 10,000 reads per minute from OpenWorld alone, and every capture makes the next Inbox tab open slower. Because the page bodies are the only thing actually rendered, all of the (N − limit) parsed bodies are discarded.
Fix
Page the inbox from a projection index, mirroring
getLinksPage(#3509), inserver/services/brainStorage.js.{ status: record.status, isGitHubRepo: record.isGitHubRepo, capturedAtMs: safeDate(record.capturedAt) },nullfor tombstone/unparseable — one index, one invalidation path, shared bygetSummary,getInboxLogCounts, and this.getInboxLog({ status, limit = 50, offset = 0 })to: resolve the index for'inbox', drop null rows, filter onstatuswhen given (strict===, as today), sort bycapturedAtMsdesc with an id tiebreak (bulk captures and imports share a timestamp; an unstable order across two page requests drops/duplicates rows at the slice boundary — same reasoning asgetLinksPage:1084-1087), slice, thenPromise.all(pageIds.map((id) => getById('inbox', id)))and drop nulls (deleted between index and body read). Keep the signature and the plain-array return so the seven callers above don't change.getInboxLogCounts()talliesstatusfrom the same rows (done in Brain summary poll parses every brain record body every 30s to render three counts #5438 if it lands first).Behavioral notes to preserve/accept: a missing/unparseable
capturedAtcurrently yieldsNaNin the comparator (undefined order); withsafeDateit becomes 0 and sorts last deterministically — same choice #3509 made forcreatedAt.createInboxLogalways stampscapturedAt, so this only affects hand-edited records.Rejected: a
status-keyed sidecar index file on disk — a second source of truth that must be kept in step with per-record writes and peer applies; the in-memory projection rebuilds fromreaddirand is already the established pattern. Rejected: pruningdone/filedentries — a data-retention policy change, not a perf fix.Files:
server/services/brainStorage.js,server/services/brainStorage.test.js.Tests (in
brainStorage.test.jsbesidedescribe('getLinksPage …', :438), same temp-dir store +readJSONFilespy as the :474 test):capturedAt, one tombstoned, one with nocapturedAt:getInboxLog({ limit, offset }),getInboxLog({ status })return the same ids in the same order as the current implementation (tombstone excluded, missing-date last), and two adjacent pages neither overlap nor skip.limitrecord bodies (spy count), not N.updateInboxLog(id, { status: 'done' })moves the record across the status filter on the next call;deleteInboxLog(id)removes it.Acceptance criteria
GET /api/brain/inbox?status=needs_review&limit=1reads at most 1 record body from disk in steady state (index warm), not 2N.countsare identical to the current implementation for a seeded store (tests above).getInboxLogkeeps its{ status, limit, offset }signature and array return; no caller changes.cd server && npm testgreen.