Skip to content
Merged
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
1 change: 1 addition & 0 deletions .changelog/next/fixed-issue-4196.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Brain graph views now load lightweight projections, keeping large song sheets out of graph data while preserving node summaries and tags.
14 changes: 7 additions & 7 deletions server/services/brainGraph.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@
* O(n²) tag explosion can't happen.
*
* The two edge-bearing views need every record's tags and summary, so they go
* through `loadNodes()` (a full read of each entity store). The search index
* needs only `{ id, label, brainType }`, so it reads the cached field
* projections in `brainSearchIndex` instead — focusing the graph's search box
* through `loadNodes()` (the cached field projections for each entity store).
* The search index needs only `{ id, label, brainType }`, so it reads those
* same projections in `brainSearchIndex` instead — focusing the graph's search box
* no longer walks and JSON-parses every record body and Daily Log entry on disk
* (issue #3507).
*/
Expand Down Expand Up @@ -87,9 +87,9 @@ const journalDate = (entry) => entry.id || entry.date;
// Load all non-archived brain entities as graph nodes (no edges).
async function loadNodes() {
const nodes = [];
for (const type of ENTITY_TYPES) {
const records = await brainStorage.getAll(type);
for (const record of records) {
const perType = await Promise.all(ENTITY_TYPES.map((type) => getBrainProjections(type, { ranked: false })));
ENTITY_TYPES.forEach((type, i) => {
for (const record of perType[i]) {
if (record.archived) continue;
nodes.push({
id: record.id,
Expand All @@ -101,7 +101,7 @@ async function loadNodes() {
status: record.status
});
}
}
});

// Goals (identity system): active goals only (completed/abandoned excluded)
const goalsData = await getGoals().catch(() => null);
Expand Down
27 changes: 27 additions & 0 deletions server/services/brainGraph.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,11 @@ describe('getBrainGraphSearchIndex', () => {
content: 'z'.repeat(5000),
segments: [{ text: 'z'.repeat(5000) }]
}];
if (type === 'songs') return [{
id: 's1',
title: 'Example song',
content: { format: 'chordpro', text: 'x'.repeat(5000) }
}];
return [];
});

Expand All @@ -158,6 +163,9 @@ describe('getBrainGraphSearchIndex', () => {
expect(memory.embedding).toBeUndefined();
expect(memory.attachments).toBeUndefined();

const [song] = await getBrainProjections('songs', { ranked: false });
expect(song.content).toBeUndefined();

// The Daily Log body is the biggest record in the brain — the graph only
// needs "is this day non-empty?", so the text must never reach the cache.
expect(await getBrainProjections('journals', { ranked: false })).toEqual([
Expand Down Expand Up @@ -186,6 +194,25 @@ describe('getBrainGraphOverview', () => {
expect(result.nodes.map(n => n.label).sort()).toEqual(['Alice', 'Phoenix']);
});

it('keeps projected summaries, tags, and status for edge-bearing views', async () => {
onlyType('people', [{
id: 'p1',
name: 'Ada Placeholder',
description: 'Example description',
tags: ['example'],
status: 'active'
}]);

const result = await getBrainGraphOverview({ limit: 100 });

expect(result.nodes).toMatchObject([{
id: 'p1',
summary: 'Example description',
tags: ['example'],
status: 'active'
}]);
});

it('summarizes a SongBook node with its artist, never its content object (#4105)', async () => {
onlyType('songs', [{
id: 's1',
Expand Down
39 changes: 26 additions & 13 deletions server/services/brainSearchIndex.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,37 +47,50 @@ import { safeDate } from '../lib/fileUtils.js';
* from `capturedText` / `name` / `context` / `title` / `oneLiner` / `notes`
* / `nextAction` / `content` / `mood` / `url` / `description`.
* - `getBrainGraphSearchIndex` (server/services/brainGraph.js) derives each
* node's label from `name || title` and drops archived records, so the
* graph entity types also project `name`, `title` and `archived`.
* node's label from `name || title` and drops archived records, while the
* edge-bearing graph views also need tags, status, and summary fields.
* `journals` is graph-only (the Daily Log is not a unified-search source), and
* projects no body — see `journalHasBody` below. `songs` (SongBook) is
* graph-only too, and projects only its label field: the sheet body lives in
* `content.text` (up to 200k chars of tab/ChordPro per song) and nothing here
* renders it, so it must never reach the cache.
* graph-only too, and its sheet body lives in `content.text` (up to 200k
* chars of tab/ChordPro per song). The graph projection keeps only a string
* `content` value, so the SongBook object never reaches the cache.
*/
const GRAPH_PROJECTION_FIELDS = Object.freeze([
'name', 'title', 'archived', 'tags', 'status',
'description', 'context', 'oneLiner', 'artist', 'notes'
]);

const PROJECTED_FIELDS = Object.freeze({
inbox: Object.freeze(['capturedText']),
people: Object.freeze(['name', 'title', 'context', 'archived']),
projects: Object.freeze(['name', 'title', 'notes', 'archived']),
ideas: Object.freeze(['name', 'title', 'oneLiner', 'notes', 'archived']),
admin: Object.freeze(['name', 'title', 'notes', 'nextAction', 'archived']),
memories: Object.freeze(['name', 'title', 'content', 'mood', 'archived']),
people: GRAPH_PROJECTION_FIELDS,
projects: GRAPH_PROJECTION_FIELDS,
ideas: GRAPH_PROJECTION_FIELDS,
admin: Object.freeze([...GRAPH_PROJECTION_FIELDS, 'nextAction']),
memories: Object.freeze([...GRAPH_PROJECTION_FIELDS, 'mood']),
links: Object.freeze(['title', 'url', 'description']),
journals: Object.freeze(['date']),
songs: Object.freeze(['title', 'archived']),
songs: GRAPH_PROJECTION_FIELDS,
});

/**
* Is this Daily Log entry non-empty? The graph only asks the question — it
* never renders the answer's source — and a journal body is the largest record
* in the brain, so the predicate is projected and `content`/`segments` are not.
* Exported so `brainGraph`'s full-record path (which still loads bodies, for
* summaries and tags) applies the identical rule.
* Exported so `brainGraph`'s journal path applies the identical rule.
*/
export const journalHasBody = (record) => !!(record?.content || record?.segments?.length);

const stringContent = (record) => typeof record?.content === 'string' ? record.content : undefined;
const GRAPH_DERIVED_FIELDS = Object.freeze({ content: stringContent });

// Fields computed from the record rather than copied off it, per type.
const DERIVED_FIELDS = Object.freeze({
people: GRAPH_DERIVED_FIELDS,
projects: GRAPH_DERIVED_FIELDS,
ideas: GRAPH_DERIVED_FIELDS,
admin: GRAPH_DERIVED_FIELDS,
memories: GRAPH_DERIVED_FIELDS,
songs: GRAPH_DERIVED_FIELDS,
journals: Object.freeze({ hasBody: journalHasBody }),
});

Expand Down
36 changes: 34 additions & 2 deletions server/services/brainSearchIndex.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,47 @@ describe('brainSearchIndex', () => {

it('projects only the fields its consumers read', async () => {
getAll.mockResolvedValue([
{ id: 'p1', name: 'Ada Placeholder', context: 'colleague', avatarBlob: 'x'.repeat(50), embedding: [1, 2, 3] }
{
id: 'p1',
name: 'Ada Placeholder',
context: 'colleague',
tags: ['example'],
status: 'active',
avatarBlob: 'x'.repeat(50),
embedding: [1, 2, 3]
}
])

const [projection] = await getBrainProjections('people')
expect(projection).toEqual({ id: 'p1', name: 'Ada Placeholder', context: 'colleague' })
expect(projection).toMatchObject({
id: 'p1',
name: 'Ada Placeholder',
context: 'colleague',
tags: ['example'],
status: 'active'
})
expect(projection).not.toHaveProperty('avatarBlob')
expect(projection).not.toHaveProperty('embedding')
})

it('keeps string content but drops object-shaped content from graph projections', async () => {
getAll.mockImplementation(async (type) => {
if (type === 'memories') return [{ id: 'm1', title: 'Example memory', content: 'searchable text' }]
if (type === 'songs') return [{
id: 's1',
title: 'Example song',
content: { format: 'chordpro', text: 'large sheet body' }
}]
return []
})

const [memory] = await getBrainProjections('memories', { ranked: false })
const [song] = await getBrainProjections('songs', { ranked: false })

expect(memory.content).toBe('searchable text')
expect(song.content).toBeUndefined()
})

it('projects a journal entry as a body predicate, never the body', async () => {
getAll.mockResolvedValue([
{ id: '2026-01-01', content: 'x'.repeat(500), segments: [{ text: 'x' }] },
Expand Down