crbro-memory 2.0.3 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -32,11 +32,15 @@ const prefrontal_js_1 = require("./engine/prefrontal.js");
32
32
  const index_js_1 = require("./search/index.js");
33
33
  const semantic_js_1 = require("./search/semantic.js");
34
34
  const budget_js_1 = require("./utils/budget.js");
35
+ const secrets_js_1 = require("./engine/secrets.js");
36
+ /** Listings carry the day, not the millisecond: "2026-09-07" says what "2026-09-07T14:02:11.483Z" says, in a third of the tokens. Full stamps stay on single-entry reads. */
37
+ const dia = (iso) => (typeof iso === 'string' && iso.length >= 10 ? iso.slice(0, 10) : iso);
35
38
  const maintenance_js_1 = require("./engine/maintenance.js");
36
39
  const space_js_1 = require("./sync/space.js");
37
40
  const keychain_js_1 = require("./engine/keychain.js");
38
41
  const fs_js_1 = require("./utils/fs.js");
39
42
  const hash_js_1 = require("./utils/hash.js");
43
+ const ops_js_1 = require("./sync/ops.js");
40
44
  /**
41
45
  * Old tool name → how to do the same thing on the 2.0 surface. Served by
42
46
  * crbro_boot as `retired_tools` for the whole 2.x line, and asserted by the
@@ -78,7 +82,7 @@ function textResult(text, isError = false) {
78
82
  return { content: [{ type: 'text', text }], ...(isError ? { isError: true } : {}) };
79
83
  }
80
84
  function jsonResult(payload) {
81
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
85
+ return { content: [{ type: 'text', text: JSON.stringify(payload) }] };
82
86
  }
83
87
  function errorResult(where, err) {
84
88
  return textResult(`CRBRO ${where} error: ${err instanceof Error ? err.message : String(err)}`, true);
@@ -86,9 +90,18 @@ function errorResult(where, err) {
86
90
  const NEURON_TYPES = ['project', 'tech', 'lang', 'person', 'domain', 'process', 'protocol'];
87
91
  const INSPECT_VIEWS = ['status', 'neuron', 'neurons', 'sessions', 'global_map'];
88
92
  function createServer() {
93
+ // Served at initialize for the clients that read it (Claude Desktop does
94
+ // not, as of anthropics/claude-code#43749; there the tool descriptions
95
+ // carry the same message). It is the one place to say how the memory is
96
+ // meant to be used, before any tool is called.
89
97
  const server = new mcp_js_1.McpServer({
90
98
  name: 'crbro-memory',
91
99
  version: runningVersion(),
100
+ }, {
101
+ instructions: 'CRBRO is this user\'s persistent memory, kept on their own machine. Start every conversation with crbro_boot: it loads what earlier sessions left — protocols to follow, open items, hot topics. ' +
102
+ 'Before answering anything about the user, their projects, preferences, decisions or past work, call crbro_recall: the answer is usually stored, and making them repeat it is the failure this memory exists to prevent. ' +
103
+ 'Questions about CRBRO itself (version, counts, whether semantic recall is on) are crbro_inspect view=status. Read one entry, not a whole neuron: view=neuron gives an index, entries=[ids] the text. ' +
104
+ 'Save with crbro_learn as you go, and close with crbro_consolidate before the conversation ends.',
92
105
  });
93
106
  // ─── Initialize engines ──────────────────────────────────────
94
107
  const brain = new brain_js_1.Brain();
@@ -138,7 +151,52 @@ function createServer() {
138
151
  // The last three real session logs, and last_session taken from them:
139
152
  // the manifest field brain.boot reads was never written by anything,
140
153
  // so it came back null in every session.
141
- response.recent_sessions = await hippocampus.listSessions(3);
154
+ // Headlines, not transcripts. Three full summaries were 82% of an
155
+ // 81,000-character boot on a mature brain — text the model had already
156
+ // read once, at consolidation, re-read at every start. The opening
157
+ // line is where a summary says what happened; the rest is a call away.
158
+ const recientes = await hippocampus.listSessions(3);
159
+ response.recent_sessions = recientes.map((s) => {
160
+ const texto = String(s.summary || '');
161
+ const titular = texto.length > 240 ? `${texto.slice(0, 240).trimEnd()}…` : texto;
162
+ const topics = s.topics_touched || [];
163
+ // Same key as before, `summary`, so nothing that reads it breaks; when
164
+ // it is only the opening, summary_truncated says so next to it.
165
+ return {
166
+ session_id: s.session_id, date: s.date, summary: titular,
167
+ ...(texto.length > 240 ? { summary_truncated: true, summary_chars: texto.length } : {}),
168
+ topics_touched: topics.slice(0, 5),
169
+ ...(topics.length > 5 ? { topics_count: topics.length } : {}),
170
+ key_facts_added: s.key_facts_added, decisions_made: s.decisions_made,
171
+ new_neurons_created: s.new_neurons_created, synapses_updated: s.synapses_updated,
172
+ duration_estimate: s.duration_estimate,
173
+ };
174
+ });
175
+ if (recientes.some((s) => String(s.summary || '').length > 240)) {
176
+ response.sessions_note = 'recent_sessions carries the first 240 characters of each summary (summary_chars is the full size). crbro_inspect view=sessions limit=1 returns the latest one whole; limit=3 returns the three, each capped at 3,000 characters and declared.';
177
+ }
178
+ // Ten hot topics with the day, not twenty with the millisecond: each
179
+ // row is a pointer the model follows with recall, not a record.
180
+ response.hot_topics = (result.hot_topics || []).slice(0, 10)
181
+ .map((h) => ({ ...h, last_access: dia(h.last_access) }));
182
+ // active_context repeated open_items and recently_closed, which boot
183
+ // already serves at the top level: 1,414 characters said twice.
184
+ if (result.active_context && typeof result.active_context === 'object') {
185
+ const { pending_tasks: _p, recently_closed: _r, ...resto } = result.active_context;
186
+ response.active_context = resto;
187
+ }
188
+ for (const [k, cap] of [['open_items', 12], ['recently_closed', 8]]) {
189
+ const lista = response[k];
190
+ if (!Array.isArray(lista))
191
+ continue;
192
+ if (lista.length > cap)
193
+ response[`${k}_total`] = lista.length;
194
+ response[k] = lista.slice(0, cap).map((it) => ({
195
+ ...it,
196
+ ...(it.added ? { added: dia(it.added) } : {}),
197
+ ...(it.closed ? { closed: dia(it.closed) } : {}),
198
+ }));
199
+ }
142
200
  response.last_session = response.recent_sessions[0]?.session_id ?? result.last_session ?? null;
143
201
  response.retired_tools = exports.RETIRED_TOOLS;
144
202
  // Inject protocol enforcement.
@@ -187,8 +245,9 @@ function createServer() {
187
245
  response.memory_discipline =
188
246
  'Before crbro_learn, crbro_recall: what you are about to save may already exist — then pass ' +
189
247
  'supersedes instead of adding a sibling (two versions of one fact compete on recall as equals). ' +
190
- 'crbro_recall searches by content; to read one neuron by id or name, list neurons, sessions or ' +
191
- 'the global map, use crbro_inspect. ' +
248
+ 'Read what you need, not the neuron: crbro_recall finds the entry by content; crbro_inspect view=neuron ' +
249
+ 'gives an index (id, kind, preview per entry) and entries=[ids] returns just those in full. Ask for a ' +
250
+ 'whole neuron only when you truly need all of it. ' +
192
251
  'Structure — paths, what serves what, traps — goes in crbro_map, not in facts; anything derivable ' +
193
252
  'from the repo or git history is not worth storing. Write facts dense and self-contained: they are ' +
194
253
  'recalled without this conversation, and add keywords: the words a future question may use that ' +
@@ -197,7 +256,8 @@ function createServer() {
197
256
  'deliberate deferral with its ceiling and revisit trigger. Credentials never go in the brain: ' +
198
257
  'crbro_secret, then record only the NAME. Recall results carry confidence — "weak" means the match ' +
199
258
  'covers little of the question, verify before relying on it — and when two facts disagree, prefer ' +
200
- 'the more recent. ' + THREE_STAGES + ' ' +
259
+ 'the more recent. Lifecycle: supersedes replaces, crbro_revise retires, crbro_forget removes what ' +
260
+ 'must not exist on disk — each tool describes its own stage. ' +
201
261
  'Call crbro_consolidate before the conversation ends; it logs the session too.';
202
262
  // A mature brain outgrew the boot payload: on a 1,145-neuron brain it
203
263
  // reached 81,406 characters (~20,000 tokens), three quarters of it the
@@ -219,17 +279,19 @@ function createServer() {
219
279
  // ═══════════════════════════════════════════════════════════════
220
280
  server.registerTool('crbro_inspect', {
221
281
  title: 'Inspect the brain',
222
- description: 'Read-only views of the brain by id or name; to search by content use crbro_recall. Nothing is written by any view: every read leaves the brain untouched. view=status: version, brain path, totals, last boot/consolidation, semantic state, hot_topics_recalculated. view=neuron: one neuron in full facts newest first, paged with limit/offset (superseded hidden unless include_superseded), decisions, patterns, preferences, errors, debts, entry_status, system map, and its connections resolved with name, type and strength (min_strength filters). view=neurons: rows hottest first (id, name, domain, type, heat, last_accessed, facts_count), filtered by domain, type, min_heat, paged with limit/offset. view=sessions: day logs newest first, the only place session summaries are read. view=global_map: one cluster per domain plus cross-domain bridges, computed live. Params of other views are ignored.',
282
+ description: 'Read-only views of the brain by id or name; to search by content use crbro_recall. Nothing is written by any view: every read leaves the brain untouched. view=status answers any question about CRBRO itself: version, brain path, totals, last boot/consolidation, whether semantic recall is installed and on, hot_topics_recalculated. view=neuron: an index of one neuron — header, counts, connections (min_strength filters) and every entry as id, kind, date and preview, paged with limit/offset; entries=[ids or exact text] reads those in full, detail=full returns the whole neuron, shortened and declared when large. view=neurons: rows hottest first (id, name, domain, type, heat, last_accessed, facts_count), filtered by domain, type, min_heat, paged with limit/offset. view=sessions: day logs newest first, the only place session summaries are read. view=global_map: one cluster per domain plus cross-domain bridges, computed live. Params of other views are ignored.',
223
283
  inputSchema: {
224
284
  view: zod_1.z.enum(INSPECT_VIEWS).describe('Which read to perform. Only the params listed for that view are honoured; the rest are ignored, never an error.'),
225
285
  neuron: zod_1.z.string().optional().describe('view=neuron only, required there: neuron id (e.g. "project_octochat") or name (e.g. "OctoChat").'),
286
+ detail: zod_1.z.enum(['index', 'full']).optional().describe('view=neuron: "index" (default) returns the header, counts and every entry as id, kind, date and a short preview — cheap, then read what matters with `entries`. "full" returns the whole neuron with facts paged by limit/offset; large neurons are shortened and say so.'),
287
+ entries: zod_1.z.array(zod_1.z.string()).optional().describe('view=neuron: read these entries in full — their ids from the index or from crbro_recall, or their exact text. Any kind: fact, decision, pattern, preference, error, debt, or "map" for the system map. Ignores detail.'),
226
288
  domain: zod_1.z.string().optional().describe('view=neurons: exact domain match, e.g. "proyectos-web".'),
227
289
  type: zod_1.z.enum(NEURON_TYPES).optional().describe('view=neurons: only this neuron type.'),
228
290
  min_heat: zod_1.z.number().min(0).max(1).optional().describe('view=neurons: minimum heat, 0.0-1.0. Heat blends access frequency, recency and connectivity.'),
229
291
  min_strength: zod_1.z.number().min(0).max(1).optional().describe('view=neuron: drop connections weaker than this (0.0-1.0). Omit or 0 = all.'),
230
- limit: zod_1.z.number().int().positive().optional().describe('Page size. view=neuron: facts per page (default 40, max 200); view=neurons: rows (default 50, max 500); view=sessions: day logs (default 10, max 100). Other views ignore it.'),
231
- offset: zod_1.z.number().int().min(0).optional().describe('Items to skip. view=neuron: facts (newest first); view=neurons: rows after the heat sort. Default 0.'),
232
- include_superseded: zod_1.z.boolean().optional().describe('view=neuron: also return superseded and retracted facts (default false). Retired decisions, patterns, errors and debts are always returned, with entry_status saying which are retired.'),
292
+ limit: zod_1.z.number().int().positive().optional().describe('Page size. view=neuron: index entries per page (default 25, max 200); view=neurons: rows (default 50, max 500); view=sessions: day logs (default 10, max 100). Other views ignore it.'),
293
+ offset: zod_1.z.number().int().min(0).optional().describe('Items to skip. view=neuron: index entries; view=neurons: rows after the heat sort. Default 0.'),
294
+ include_superseded: zod_1.z.boolean().optional().describe('view=neuron: also list superseded and retracted entries of every kind (default false; the index reports how many are hidden in entries_pagination.hidden_retired). detail=full always returns entry_status.'),
233
295
  },
234
296
  outputSchema: {
235
297
  view: zod_1.z.enum(INSPECT_VIEWS),
@@ -280,12 +342,13 @@ function createServer() {
280
342
  // a view added later inherits it without remembering to. It has to be
281
343
  // this strict because the payload travels TWICE — once as text, once as
282
344
  // structuredContent — so a client pays double for what a view emits.
283
- const done = (payload) => {
345
+ const done = (payload, extra = {}) => {
284
346
  const fitted = (0, budget_js_1.fitToBudget)(payload, {
285
347
  howToGetMore: `Ask for a slice instead of the whole: crbro_inspect view=${args.view} with limit and offset, or crbro_recall to search by content.`,
348
+ ...extra,
286
349
  });
287
350
  return {
288
- content: [{ type: 'text', text: JSON.stringify(fitted, null, 2) }],
351
+ content: [{ type: 'text', text: JSON.stringify(fitted) }],
289
352
  structuredContent: { view: args.view, [args.view]: fitted },
290
353
  };
291
354
  };
@@ -323,29 +386,119 @@ function createServer() {
323
386
  if (!neuron) {
324
387
  return textResult(`Neuron not found: "${args.neuron}". Find the id with crbro_recall or crbro_inspect view=neurons.`, true);
325
388
  }
326
- // A whole neuron can be enormous - the biggest on the reference brain
327
- // serialises to 528,836 characters, more than most models can hold - so
328
- // facts are paged instead of dumped.
329
- const limit = Math.min(Math.max(args.limit ?? 40, 1), 200);
389
+ // Three ways to read a neuron, cheapest first. The default is an
390
+ // INDEX: header, counts and one line per entry with a stable id. The
391
+ // biggest neuron on the reference brain serialises to ~530,000
392
+ // characters; no client carries that, and a model rarely needs it —
393
+ // it needs two or three entries it can now name. `entries` reads
394
+ // those in full; `detail=full` is the old dump, and it goes through
395
+ // the ceiling like every other read.
396
+ const limit = Math.min(Math.max(args.limit ?? 25, 1), 200);
330
397
  const offset = Math.max(args.offset ?? 0, 0);
331
- const visible = (neuron.facts || []).filter(f => args.include_superseded ? true : (f.status !== 'superseded' && f.status !== 'retracted'));
332
- const ordered = [...visible].sort((a, b) => String(b.added || '').localeCompare(String(a.added || '')));
333
- const page = ordered.slice(offset, offset + limit);
334
398
  const connections = await synapses.getConnections(neuron.id, args.min_strength);
399
+ const retired = (id) => neuron.entry_status?.[id]?.status;
400
+ const rows = [];
401
+ for (const f of neuron.facts || []) {
402
+ rows.push({ id: f.id || (0, hash_js_1.factId)(f.text), kind: 'fact', text: f.text, added: f.added || '',
403
+ status: f.status, confidence: f.confidence, keys: f.keys, revision_note: f.revision_note, revised: f.revised });
404
+ }
405
+ for (const d of neuron.decisions || []) {
406
+ const id = d.id || (0, ops_js_1.entryId)(d.text);
407
+ rows.push({ id, kind: 'decision', text: d.text, added: d.date || '', rationale: d.rationale, status: retired(id), revised: neuron.entry_status?.[id]?.revised, revision_note: neuron.entry_status?.[id]?.note });
408
+ }
409
+ const sidecar = (kind, list) => {
410
+ for (const t of list || []) {
411
+ const id = (0, ops_js_1.entryId)(t);
412
+ rows.push({ id, kind, text: t, added: neuron.entry_dates?.[id] || '', status: retired(id), revised: neuron.entry_status?.[id]?.revised, revision_note: neuron.entry_status?.[id]?.note });
413
+ }
414
+ };
415
+ sidecar('pattern', neuron.patterns);
416
+ sidecar('preference', neuron.preferences);
417
+ sidecar('error', neuron.errors);
418
+ sidecar('debt', neuron.debts);
419
+ if (neuron.map?.text)
420
+ rows.push({ id: 'map', kind: 'map', text: neuron.map.text, added: neuron.map.updated || '' });
421
+ const header = {
422
+ id: neuron.id, name: neuron.name, domain: neuron.domain, type: neuron.type,
423
+ heat: neuron.heat, summary: neuron.summary, tags: neuron.tags,
424
+ created: neuron.created, last_accessed: neuron.last_accessed,
425
+ };
426
+ // ── entries=[…]: exactly what was asked for, in full ──────────
427
+ const wanted = (args.entries || []).map(s => s.trim()).filter(Boolean);
428
+ if (wanted.length) {
429
+ const found = [];
430
+ const missing = [];
431
+ for (const w of wanted) {
432
+ const hit = rows.find(r => r.id === w) || rows.find(r => r.text.trim().toLowerCase() === w.toLowerCase());
433
+ if (hit) {
434
+ if (!found.includes(hit))
435
+ found.push(hit);
436
+ }
437
+ else
438
+ missing.push(w);
439
+ }
440
+ return done({
441
+ ...header,
442
+ entries: found,
443
+ returned: found.length,
444
+ ...(missing.length ? { not_found: missing, hint: 'Ids come from the index (view=neuron) or from crbro_recall as entry_id; exact text also works.' } : {}),
445
+ });
446
+ }
447
+ // ── detail=full: the whole neuron, facts paged, as before ──────
448
+ if (args.detail === 'full') {
449
+ const visible = (neuron.facts || []).filter(f => args.include_superseded ? true : (f.status !== 'superseded' && f.status !== 'retracted'));
450
+ const ordered = [...visible].sort((a, b) => String(b.added || '').localeCompare(String(a.added || '')));
451
+ const page = ordered.slice(offset, offset + limit);
452
+ return done({
453
+ ...neuron,
454
+ connection_ids: neuron.connections || [],
455
+ connections,
456
+ total_connections: connections.length,
457
+ facts: page,
458
+ facts_pagination: {
459
+ total: ordered.length,
460
+ returned: page.length,
461
+ offset,
462
+ has_more: offset + page.length < ordered.length,
463
+ order: 'newest first',
464
+ hidden_superseded: (neuron.facts || []).length - visible.length,
465
+ },
466
+ });
467
+ }
468
+ // ── default: the index ────────────────────────────────────────
469
+ // A fact may carry status 'active' explicitly; only the two retired
470
+ // states hide an entry from the default page.
471
+ const isRetired = (s) => s === 'superseded' || s === 'retracted';
472
+ const live = rows.filter(r => args.include_superseded ? true : !isRetired(r.status));
473
+ const byKind = (k) => live.filter(r => r.kind === k)
474
+ .sort((a, b) => String(b.added).localeCompare(String(a.added)));
475
+ // Few and heavy first — map, errors, debts — then the many: facts are the
476
+ // long tail and get paged; the map is one entry and rarely fits a preview.
477
+ const ordered = ['map', 'error', 'debt', 'preference', 'pattern', 'decision', 'fact'].flatMap(byKind);
478
+ const page = ordered.slice(offset, offset + limit);
479
+ const PREVIEW = 160;
480
+ const counts = {};
481
+ for (const r of rows)
482
+ counts[r.kind] = (counts[r.kind] || 0) + 1;
335
483
  return done({
336
- ...neuron,
337
- connection_ids: neuron.connections || [],
484
+ ...header,
485
+ counts: { ...counts, connections: connections.length, retired: rows.filter(r => isRetired(r.status)).length },
338
486
  connections,
339
- total_connections: connections.length,
340
- facts: page,
341
- facts_pagination: {
487
+ entries: page.map(r => ({
488
+ id: r.id, kind: r.kind, added: dia(r.added),
489
+ preview: r.text.length > PREVIEW ? `${r.text.slice(0, PREVIEW).trimEnd()}…` : r.text,
490
+ chars: r.text.length,
491
+ ...(isRetired(r.status) ? { status: r.status, ...(r.revised ? { revised: dia(r.revised) } : {}), ...(r.revision_note ? { retired_note: r.revision_note } : {}) } : {}),
492
+ })),
493
+ entries_pagination: {
342
494
  total: ordered.length,
343
495
  returned: page.length,
344
496
  offset,
345
497
  has_more: offset + page.length < ordered.length,
346
- order: 'newest first',
347
- hidden_superseded: (neuron.facts || []).length - visible.length,
498
+ order: 'map, errors, debts, preferences, patterns, decisions, facts — newest first within each',
499
+ ...(args.include_superseded ? {} : { hidden_retired: rows.length - live.length, see_retired: 'pass include_superseded=true' }),
348
500
  },
501
+ how_to_read: `crbro_inspect view=neuron neuron="${neuron.id}" entries=[<ids>] returns those in full; detail=full returns everything.`,
349
502
  });
350
503
  }
351
504
  if (args.view === 'neurons') {
@@ -364,9 +517,21 @@ function createServer() {
364
517
  }
365
518
  if (args.view === 'sessions') {
366
519
  const limit = Math.min(Math.max(args.limit ?? 10, 1), 100);
367
- const sessions = await hippocampus.listSessions(limit);
520
+ const offset = Math.max(args.offset ?? 0, 0);
521
+ const pagina = (await hippocampus.listSessions(limit + offset)).slice(offset, offset + limit);
368
522
  const totalSessions = (await brain.getManifest()).total_sessions;
369
- return done({ total: totalSessions, returned: sessions.length, sessions });
523
+ // One log asked for by itself comes whole: it is the door boot points
524
+ // at when it shortens a summary. A list caps each one and says so.
525
+ const CAP = 3_000;
526
+ const sessions = limit === 1 ? pagina : pagina.map((s) => {
527
+ const t = String(s.summary || '');
528
+ return t.length > CAP
529
+ ? { ...s, summary: `${t.slice(0, CAP).trimEnd()}…`, summary_truncated: true, summary_chars: t.length }
530
+ : s;
531
+ });
532
+ return done({ total: totalSessions, returned: sessions.length, offset, has_more: offset + sessions.length < totalSessions, sessions }, limit === 1
533
+ ? { stringCap: budget_js_1.DEFAULT_BUDGET_CHARS - 2_000, howToGetMore: 'This is one full session log; a longer one is only readable from disk, in hippocampus/<session_id>.json.' }
534
+ : { howToGetMore: 'Page with limit and offset; limit=1 returns a single log whole.' });
370
535
  }
371
536
  // view === 'global_map': computed live, never cached, nothing written.
372
537
  const globalMap = await prefrontal.getGlobalMap();
@@ -503,12 +668,12 @@ function createServer() {
503
668
  // ═══════════════════════════════════════════════════════════════
504
669
  server.registerTool('crbro_recall', {
505
670
  title: 'Recall',
506
- description: 'Read-only search of everything saved in earlier sessions — the full text of facts, decisions, patterns, preferences, errors, debts and maps, not just topic names; to read one neuron by id or name use crbro_inspect view=neuron. One result per neuron: its best matching chunk with matched_kind and matched_added, a confidence label (weak = little of the question covered; verify first), plus also_matched. Retired facts and entries never surface. Call it before asking the user what they may already have told you, and before crbro_learn. If nothing matches, retry with 2-4 phrasings in queries or fewer, distinctive words. has_map:true: read the system map with crbro_map before touching that system.',
671
+ description: 'Read-only search of everything saved in earlier sessions — facts, decisions, patterns, preferences, errors, debts and maps by their full text. Call it BEFORE answering anything about the user, their projects, preferences, decisions or past work: the answer is usually stored, and making them repeat it is the failure this memory exists to prevent. Also before crbro_learn, so a fact is superseded instead of duplicated. One result per neuron: the best matching entry with entry_id (read it whole with crbro_inspect view=neuron entries=[id]), matched_kind, matched_added, a confidence label (weak = little of the question covered; verify) and also_matched previews. Retired entries never surface. Five ranked results by default; matched_neurons says how many more matched. If nothing matches, retry with 2-4 phrasings in queries or fewer, distinctive words. has_map:true: read the system map with crbro_map before touching that system.',
507
672
  inputSchema: {
508
673
  query: zod_1.z.string().describe('What to look for, e.g. "Firebase authentication setup". Fewer, distinctive terms beat full sentences.'),
509
674
  queries: zod_1.z.array(zod_1.z.string()).optional().describe('Alternative phrasings of the same question, searched together with query and fused by rank. Use synonyms, the other language and the concrete product name; 2-4 is plenty.'),
510
675
  domain: zod_1.z.string().optional().describe('Only neurons in this domain (exact match, e.g. "proyectos-web").'),
511
- limit: zod_1.z.number().optional().describe('Max neurons returned (default 10).'),
676
+ limit: zod_1.z.number().int().positive().optional().describe('Max neurons returned (default 5, ranked; ask for more only when the top five did not answer).'),
512
677
  },
513
678
  outputSchema: {
514
679
  query: zod_1.z.string(),
@@ -520,25 +685,55 @@ function createServer() {
520
685
  heat: zod_1.z.number(), has_map: zod_1.z.boolean().optional(),
521
686
  matched_terms: zod_1.z.number().optional(), query_terms: zod_1.z.number().optional(),
522
687
  confidence: zod_1.z.enum(['strong', 'weak']).optional(),
523
- also_matched: zod_1.z.array(zod_1.z.object({ text: zod_1.z.string(), kind: zod_1.z.string(), added: zod_1.z.string() })).optional(),
688
+ entry_id: zod_1.z.string().optional(),
689
+ content_truncated: zod_1.z.boolean().optional(), content_chars: zod_1.z.number().optional(),
690
+ also_matched: zod_1.z.array(zod_1.z.object({ entry_id: zod_1.z.string().optional(), kind: zod_1.z.string(), added: zod_1.z.string(), preview: zod_1.z.string(), chars: zod_1.z.number() }).loose()).optional(),
524
691
  }).loose()),
692
+ returned: zod_1.z.number().optional(),
693
+ matched_neurons: zod_1.z.number().optional().describe('Neurons with any hit before limit; total_results is what came back'),
694
+ has_more: zod_1.z.boolean().optional(),
525
695
  hint: zod_1.z.string(),
526
696
  truncated: zod_1.z.object({}).loose().optional(),
527
697
  },
528
698
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
529
699
  }, async (args) => {
530
700
  try {
531
- const results = await searchEngine.searchMany([args.query, ...(args.queries || [])], {
532
- domain: args.domain,
533
- limit: args.limit,
701
+ // Five by default, down from ten: results are ranked and each one
702
+ // carries its entry, so ten cost ~12,000 tokens on a dense brain for
703
+ // answers that live in the top three (recall@3 is the metric).
704
+ const { results, matched_neurons } = await searchEngine.searchManyWithStats([args.query, ...(args.queries || [])], { domain: args.domain, limit: args.limit ?? 5 });
705
+ // A hit carries its entry, but not without limit: one 6,000-character
706
+ // fact must not cost more than the five results around it. Past the
707
+ // cap the opening comes back, declared, and entry_id reads the rest.
708
+ const CONTENT_CAP = 1_200;
709
+ let cortados = 0;
710
+ const rows = results.map(r => {
711
+ const mc = r.matching_content;
712
+ const largo = mc.length > CONTENT_CAP;
713
+ if (largo)
714
+ cortados++;
715
+ return {
716
+ ...r,
717
+ matched_added: dia(r.matched_added),
718
+ ...(largo ? { matching_content: `${mc.slice(0, CONTENT_CAP).trimEnd()}…`, content_truncated: true, content_chars: mc.length } : {}),
719
+ ...(r.also_matched ? { also_matched: r.also_matched.map(a => ({ ...a, added: dia(a.added) })) } : {}),
720
+ };
534
721
  });
722
+ const sobran = matched_neurons - results.length;
535
723
  const payload = {
536
724
  query: args.query,
725
+ // total_results is what came back (capped by limit); matched_neurons is
726
+ // how many neurons had a hit at all, so five never reads as "only five".
537
727
  total_results: results.length,
538
- results,
728
+ returned: results.length,
729
+ matched_neurons,
730
+ has_more: sobran > 0,
731
+ results: rows,
539
732
  hint: results.length === 0
540
733
  ? 'Nothing matched. Try fewer, more distinctive words - names, ids, filenames - rather than a full sentence.'
541
- : 'matching_content is the chunk that matched; matched_added is when it was recorded; confidence "weak" means little of the question was covered - verify before relying on it. Prefer recent facts when two disagree. has_map: true means the neuron holds a system map - read it with crbro_map before working on that system. To read the whole neuron: crbro_inspect view=neuron.',
734
+ : 'weak: verify. Newer wins on conflict. entry_id crbro_inspect view=neuron entries=[id]. has_map crbro_map first.'
735
+ + (sobran > 0 ? ` ${sobran} more neuron${sobran === 1 ? '' : 's'} matched: raise limit or narrow the query.` : '')
736
+ + (cortados > 0 ? ' content_truncated → read the entry by entry_id.' : ''),
542
737
  };
543
738
  // Recall is the one read whose size the caller sets, with limit: ten
544
739
  // results already cost ~12,000 tokens on a dense brain because each
@@ -547,7 +742,7 @@ function createServer() {
547
742
  howToGetMore: 'Ask again with a smaller limit, or narrow the query — the matches are still there.',
548
743
  });
549
744
  return {
550
- content: [{ type: 'text', text: JSON.stringify(fitted, null, 2) }],
745
+ content: [{ type: 'text', text: JSON.stringify(fitted) }],
551
746
  structuredContent: fitted,
552
747
  };
553
748
  }
@@ -956,13 +1151,26 @@ function createServer() {
956
1151
  title: 'Consolidate the session',
957
1152
  description: 'Write: close the session — the only way to log a session. Call it before the conversation ends. Persists pending knowledge and index writes, logs the session from summary (credentials stripped, kinds in redacted), sets the context\'s last_session, recalculates heat, links the neurons written this session with weak temporal synapses (synapses_updated), updates the manifest and syncs shared team spaces (offline is normal). Returns session_id, facts_saved, decisions_saved, topics_touched and per-space sync state; topics_touched logs neurons you only read. Not consolidating loses the session\'s knowledge. Mid-session open items go to crbro_context; housekeeping is crbro_maintenance.',
958
1153
  inputSchema: {
959
- summary: zod_1.z.string().describe('What was accomplished: concrete work, decisions, outcomes. Stored (after credential redaction) as the session log later sessions read.'),
1154
+ summary: zod_1.z.string().describe('A headline paragraph, not a report: what was done, decided and left open, in a few sentences. The facts themselves belong in crbro_learn, where recall finds them; this text is re-read at every boot. Stored whole, after credential redaction. Session logs are not searched by recall: what only lives here is invisible to it.'),
960
1155
  topics_touched: zod_1.z.array(zod_1.z.string()).optional().describe('Neuron ids this session used WITHOUT writing (recalled, inspected, discussed). Added to the log\'s topics_touched next to the ids written this session; write counters stay real. Unknown ids are dropped and listed in topics_unknown.'),
961
1156
  },
962
1157
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
963
1158
  }, async (args) => {
964
1159
  try {
965
- const result = await maintenance.consolidate(args.summary, { topicsTouched: args.topics_touched });
1160
+ // A summary is re-read at every boot of every later session. The card
1161
+ // asks for one line; the field was taking whole reports — median 8,233
1162
+ // characters on one brain — so the ceiling is enforced here and declared,
1163
+ // never applied in silence. What a session learned goes through
1164
+ // crbro_learn, where it is found by content instead of re-read whole.
1165
+ // Stored whole. 2.1.0 cut it at 3,000 characters to protect boot, which
1166
+ // re-read three summaries in full; the same release made boot read only
1167
+ // their first 240, so the cut only ever lost the tail of what the
1168
+ // caller wrote. What is still true: session logs are not searched by
1169
+ // recall, so a fact that lives only here is invisible to it — the note
1170
+ // below says so past the size where that starts to matter.
1171
+ const SUMMARY_LONG = 3_000;
1172
+ const summary = (0, secrets_js_1.redact)(args.summary).text;
1173
+ const result = await maintenance.consolidate(summary, { topicsTouched: args.topics_touched });
966
1174
  // Flush any index writes still sitting in the debounce window, so a
967
1175
  // session that ends right after a learn does not lose it.
968
1176
  await searchEngine.flush();
@@ -974,6 +1182,10 @@ function createServer() {
974
1182
  ? compartidos.map(c => ({ space: c.space, state: c.state, pushed: c.pushed }))
975
1183
  : undefined,
976
1184
  message: 'Session consolidated. Brain state persisted.',
1185
+ summary_chars: summary.length,
1186
+ ...(summary.length > SUMMARY_LONG ? {
1187
+ note: `Long summary (${summary.length} characters). Session logs are not searched by crbro_recall: anything that lives only here is invisible to it. Store the facts with crbro_learn; boot reads only the first 240 characters of this text.`,
1188
+ } : {}),
977
1189
  });
978
1190
  }
979
1191
  catch (err) {
@@ -1059,7 +1271,7 @@ function createServer() {
1059
1271
  note: 'Values are never shown here, by design.',
1060
1272
  };
1061
1273
  return {
1062
- content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
1274
+ content: [{ type: 'text', text: JSON.stringify(payload) }],
1063
1275
  structuredContent: payload,
1064
1276
  };
1065
1277
  }