Skip to content

Commit f01da6c

Browse files
Skiipy11claude
andcommitted
fix: Gemini thinking token budget, Qdrant indexes, NER quality, entity cleanup
- Fix brain_reflect/consolidation 502: Gemini 2.5 Flash thinking tokens consumed maxOutputTokens:4096 budget — bumped to 65536 with 8192 thinking cap, added finishReason check and non-thinking part selection - Fix Qdrant payload index creation: 'Keyword' → 'keyword' (4 places) - Tighten NER extraction: reject verb phrases, digits, 4+ word phrases, sentence starters in both isJunkPhrase and isJunkQuotedName - Add /reconcile endpoint for Qdrant/Postgres/keyword data layer sync - Add DELETE /entities/:name and POST /entities/:name/merge endpoints - Fix query limit: listEvents/listFacts/listStatuses accept limit param (was hardcoded at 50, now configurable up to 500) - Add query param auth fallback (req.query.key) for dashboard SSE/graph - Add 'general' catch-all category to client briefing endpoint - Add temporal validity post-filter for keyword/graph search results - Rebrand dashboard to Zengram, fix entity/graph auth in dashboard HTML - Bump MCP server version to 2.5.1 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 08a2170 commit f01da6c

11 files changed

Lines changed: 486 additions & 25 deletions

File tree

api/src/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { reflectRouter } from './routes/reflect.js';
1515
import { subscribeRouter } from './routes/subscribe.js';
1616
import { dashboardRouter } from './routes/dashboard.js';
1717
import { collectionsRouter } from './routes/collections.js';
18+
import { reconcileRouter } from './routes/reconcile.js';
1819
import { initQdrant, ensureEntityIndex } from './services/qdrant.js';
1920
import { initEmbeddings } from './services/embedders/interface.js';
2021
import { initStore, isEntityStoreAvailable, loadAllAliases, _getStoreInstance, getBackendType } from './services/stores/interface.js';
@@ -72,6 +73,7 @@ app.use('/graph', graphRouter);
7273
app.use('/reflect', reflectRouter);
7374
app.use('/subscribe', subscribeRouter);
7475
app.use('/collections', collectionsRouter);
76+
app.use('/reconcile', reconcileRouter);
7577

7678
async function start() {
7779
try {

api/src/middleware/auth.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export function authMiddleware(req, res, next) {
5959
return res.status(429).json({ error: 'Too many failed attempts. Try again later.' });
6060
}
6161

62-
const key = req.headers['x-api-key'];
62+
const key = req.headers['x-api-key'] || req.query.key;
6363
if (!key) {
6464
recordFailure(ip);
6565
return res.status(401).json({ error: 'Missing API key' });

api/src/routes/client.js

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,16 +90,25 @@ clientRouter.get('/:clientId', async (req, res) => {
9090
// --- Briefing mode ---
9191
const knowledge = {};
9292

93-
const categoryResults = await Promise.all(KNOWLEDGE_CATEGORIES.map(cat => {
93+
// Query each named category + a "general" catch-all for uncategorized memories
94+
const allCategories = [...KNOWLEDGE_CATEGORIES, 'general'];
95+
const categoryResults = await Promise.all(allCategories.map(cat => {
9496
const scrollFilter = {
9597
client_id: effectiveClientId,
9698
active: true,
97-
knowledge_category: cat,
9899
};
99-
return scrollPoints(scrollFilter, SCROLL_LIMIT).then(r => ({ cat, points: r.points || [] }));
100+
if (cat !== 'general') {
101+
scrollFilter.knowledge_category = cat;
102+
}
103+
// For 'general': no knowledge_category filter — gets all memories for this client
104+
return scrollPoints(scrollFilter, cat === 'general' ? 50 : SCROLL_LIMIT).then(r => ({ cat, points: r.points || [] }));
100105
}));
101106

107+
// Collect IDs from categorized results to exclude from general
108+
const categorizedIds = new Set();
109+
102110
for (const { cat, points } of categoryResults) {
111+
if (cat === 'general') continue; // process general last
103112
// Sort by created_at descending (Qdrant scroll doesn't sort by payload)
104113
points.sort((a, b) => {
105114
const dateA = a.payload?.created_at || '';
@@ -111,6 +120,7 @@ clientRouter.get('/:clientId', async (req, res) => {
111120
const topPoints = points.slice(0, BRIEFING_PER_CATEGORY);
112121

113122
knowledge[cat] = topPoints.map(p => {
123+
categorizedIds.add(p.id);
114124
const payload = p.payload;
115125
const text = payload.text || '';
116126
if (isCompact) {
@@ -135,6 +145,37 @@ clientRouter.get('/:clientId', async (req, res) => {
135145
});
136146
}
137147

148+
// Process "general" — uncategorized memories not already in a named category
149+
const generalResult = categoryResults.find(r => r.cat === 'general');
150+
if (generalResult) {
151+
const uncategorized = generalResult.points.filter(p => !categorizedIds.has(p.id));
152+
uncategorized.sort((a, b) => (b.payload?.created_at || '').localeCompare(a.payload?.created_at || ''));
153+
const topGeneral = uncategorized.slice(0, SCROLL_LIMIT);
154+
155+
knowledge.general = topGeneral.map(p => {
156+
const payload = p.payload;
157+
const text = payload.text || '';
158+
if (isCompact) {
159+
return {
160+
id: p.id,
161+
type: payload.type,
162+
content: text.length > COMPACT_MAX ? text.slice(0, COMPACT_MAX) + '...' : text,
163+
source_agent: payload.source_agent,
164+
created_at: payload.created_at,
165+
};
166+
}
167+
return {
168+
id: p.id,
169+
type: payload.type,
170+
content: text,
171+
source_agent: payload.source_agent,
172+
importance: payload.importance,
173+
created_at: payload.created_at,
174+
metadata: payload.metadata || null,
175+
};
176+
});
177+
}
178+
138179
res.json({
139180
client_id: effectiveClientId,
140181
mode: 'briefing',

api/src/routes/entities.js

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,126 @@ entitiesRouter.get('/:name', async (req, res) => {
216216
}
217217
});
218218

219+
// DELETE /entities/:name — Delete an entity and its links
220+
entitiesRouter.delete('/:name', async (req, res) => {
221+
try {
222+
if (!isEntityStoreAvailable()) {
223+
return res.status(400).json({ error: 'Entity queries require sqlite or postgres backend.' });
224+
}
225+
226+
const entity = await findEntity(req.params.name);
227+
if (!entity) {
228+
return res.status(404).json({ error: 'Entity not found' });
229+
}
230+
231+
const store = _getStoreInstance();
232+
if (!store?.pool && !store?.db) {
233+
return res.status(500).json({ error: 'No writable store available' });
234+
}
235+
236+
// CASCADE handles entity_memory_links and entity_aliases
237+
if (store.pool) {
238+
await store.pool.query('DELETE FROM entities WHERE id = $1', [entity.id]);
239+
} else if (store.db) {
240+
store.db.prepare('DELETE FROM entities WHERE id = @id').run({ id: entity.id });
241+
}
242+
243+
console.log(`[entities:delete] Entity "${entity.canonical_name}" (${entity.entity_type}) deleted`);
244+
245+
res.json({
246+
deleted: true,
247+
name: entity.canonical_name,
248+
type: entity.entity_type,
249+
id: entity.id,
250+
});
251+
} catch (err) {
252+
console.error('[entities:delete]', err.message);
253+
res.status(500).json({ error: 'Internal server error' });
254+
}
255+
});
256+
257+
// POST /entities/:name/merge — Merge another entity into this one
258+
entitiesRouter.post('/:name/merge', async (req, res) => {
259+
try {
260+
if (!isEntityStoreAvailable()) {
261+
return res.status(400).json({ error: 'Entity queries require sqlite or postgres backend.' });
262+
}
263+
264+
const { merge_from } = req.body;
265+
if (!merge_from) {
266+
return res.status(400).json({ error: 'merge_from is required (entity name to merge into this one)' });
267+
}
268+
269+
const primary = await findEntity(req.params.name);
270+
if (!primary) {
271+
return res.status(404).json({ error: `Primary entity "${req.params.name}" not found` });
272+
}
273+
274+
const secondary = await findEntity(merge_from);
275+
if (!secondary) {
276+
return res.status(404).json({ error: `Source entity "${merge_from}" not found` });
277+
}
278+
279+
const store = _getStoreInstance();
280+
if (!store?.pool) {
281+
return res.status(500).json({ error: 'Merge requires postgres backend' });
282+
}
283+
284+
// Move memory links from secondary to primary (skip conflicts)
285+
const moveResult = await store.pool.query(`
286+
UPDATE entity_memory_links SET entity_id = $1
287+
WHERE entity_id = $2
288+
AND NOT EXISTS (
289+
SELECT 1 FROM entity_memory_links existing
290+
WHERE existing.entity_id = $1
291+
AND existing.memory_id = entity_memory_links.memory_id
292+
AND existing.role = entity_memory_links.role
293+
)
294+
`, [primary.id, secondary.id]);
295+
const movedLinks = moveResult.rowCount || 0;
296+
297+
// Move relationships from secondary to primary
298+
await store.pool.query(`
299+
UPDATE entity_relationships SET source_entity_id = $1
300+
WHERE source_entity_id = $2
301+
AND target_entity_id != $1
302+
`, [primary.id, secondary.id]).catch(() => {});
303+
await store.pool.query(`
304+
UPDATE entity_relationships SET target_entity_id = $1
305+
WHERE target_entity_id = $2
306+
AND source_entity_id != $1
307+
`, [primary.id, secondary.id]).catch(() => {});
308+
309+
// Create alias from secondary name
310+
await store.pool.query(
311+
`INSERT INTO entity_aliases (entity_id, alias, created_at) VALUES ($1, $2, NOW()) ON CONFLICT DO NOTHING`,
312+
[primary.id, secondary.canonical_name]
313+
);
314+
315+
// Update mention count on primary
316+
await store.pool.query(
317+
'UPDATE entities SET mention_count = mention_count + $1 WHERE id = $2',
318+
[secondary.mention_count || 0, primary.id]
319+
);
320+
321+
// Delete secondary (CASCADE removes remaining links/aliases)
322+
await store.pool.query('DELETE FROM entities WHERE id = $1', [secondary.id]);
323+
324+
console.log(`[entities:merge] Merged "${secondary.canonical_name}" → "${primary.canonical_name}" (${movedLinks} links moved)`);
325+
326+
res.json({
327+
merged: true,
328+
primary: primary.canonical_name,
329+
absorbed: secondary.canonical_name,
330+
links_moved: movedLinks,
331+
alias_created: secondary.canonical_name,
332+
});
333+
} catch (err) {
334+
console.error('[entities:merge]', err.message);
335+
res.status(500).json({ error: 'Internal server error' });
336+
}
337+
});
338+
219339
// GET /entities/:name/memories — All memories linked to an entity
220340
entitiesRouter.get('/:name/memories', async (req, res) => {
221341
try {

api/src/routes/memory.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,17 @@ memoryRouter.get('/search', async (req, res) => {
454454
finalResults = vectorResults.slice(0, maxResults);
455455
}
456456

457+
// Post-filter for temporal validity (at_time) — applies to ALL search paths
458+
// Vector search already filters via Qdrant range query, but keyword/graph results bypass it
459+
if (at_time) {
460+
finalResults = finalResults.filter(r => {
461+
const p = r.payload;
462+
if (p.valid_from && p.valid_from > at_time) return false; // not yet valid
463+
if (p.valid_to && p.valid_to <= at_time) return false; // already expired
464+
return true;
465+
});
466+
}
467+
457468
// Apply confidence decay + access-weighted ranking + temporal boost
458469
const COMPACT_MAX = 200;
459470
const refDateForBoost = reference_date || at_time || null;
@@ -665,10 +676,10 @@ memoryRouter.get('/query', async (req, res) => {
665676
});
666677
}
667678

668-
const { type, source_agent, category, client_id, since, key, subject } = req.query;
679+
const { type, source_agent, category, client_id, since, key, subject, limit } = req.query;
669680

670681
let results;
671-
const filters = { source_agent, category, client_id };
682+
const filters = { source_agent, category, client_id, limit };
672683

673684
if (type === 'fact' || type === 'facts') {
674685
if (key) filters.key = key;

0 commit comments

Comments
 (0)