crbro-memory 2.0.2 → 2.1.0

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
@@ -31,11 +31,16 @@ const hippocampus_js_1 = require("./engine/hippocampus.js");
31
31
  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
+ 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);
34
38
  const maintenance_js_1 = require("./engine/maintenance.js");
35
39
  const space_js_1 = require("./sync/space.js");
36
40
  const keychain_js_1 = require("./engine/keychain.js");
37
41
  const fs_js_1 = require("./utils/fs.js");
38
42
  const hash_js_1 = require("./utils/hash.js");
43
+ const ops_js_1 = require("./sync/ops.js");
39
44
  /**
40
45
  * Old tool name → how to do the same thing on the 2.0 surface. Served by
41
46
  * crbro_boot as `retired_tools` for the whole 2.x line, and asserted by the
@@ -77,7 +82,7 @@ function textResult(text, isError = false) {
77
82
  return { content: [{ type: 'text', text }], ...(isError ? { isError: true } : {}) };
78
83
  }
79
84
  function jsonResult(payload) {
80
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
85
+ return { content: [{ type: 'text', text: JSON.stringify(payload) }] };
81
86
  }
82
87
  function errorResult(where, err) {
83
88
  return textResult(`CRBRO ${where} error: ${err instanceof Error ? err.message : String(err)}`, true);
@@ -137,7 +142,52 @@ function createServer() {
137
142
  // The last three real session logs, and last_session taken from them:
138
143
  // the manifest field brain.boot reads was never written by anything,
139
144
  // so it came back null in every session.
140
- response.recent_sessions = await hippocampus.listSessions(3);
145
+ // Headlines, not transcripts. Three full summaries were 82% of an
146
+ // 81,000-character boot on a mature brain — text the model had already
147
+ // read once, at consolidation, re-read at every start. The opening
148
+ // line is where a summary says what happened; the rest is a call away.
149
+ const recientes = await hippocampus.listSessions(3);
150
+ response.recent_sessions = recientes.map((s) => {
151
+ const texto = String(s.summary || '');
152
+ const titular = texto.length > 240 ? `${texto.slice(0, 240).trimEnd()}…` : texto;
153
+ const topics = s.topics_touched || [];
154
+ // Same key as before, `summary`, so nothing that reads it breaks; when
155
+ // it is only the opening, summary_truncated says so next to it.
156
+ return {
157
+ session_id: s.session_id, date: s.date, summary: titular,
158
+ ...(texto.length > 240 ? { summary_truncated: true, summary_chars: texto.length } : {}),
159
+ topics_touched: topics.slice(0, 5),
160
+ ...(topics.length > 5 ? { topics_count: topics.length } : {}),
161
+ key_facts_added: s.key_facts_added, decisions_made: s.decisions_made,
162
+ new_neurons_created: s.new_neurons_created, synapses_updated: s.synapses_updated,
163
+ duration_estimate: s.duration_estimate,
164
+ };
165
+ });
166
+ if (recientes.some((s) => String(s.summary || '').length > 240)) {
167
+ 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.';
168
+ }
169
+ // Ten hot topics with the day, not twenty with the millisecond: each
170
+ // row is a pointer the model follows with recall, not a record.
171
+ response.hot_topics = (result.hot_topics || []).slice(0, 10)
172
+ .map((h) => ({ ...h, last_access: dia(h.last_access) }));
173
+ // active_context repeated open_items and recently_closed, which boot
174
+ // already serves at the top level: 1,414 characters said twice.
175
+ if (result.active_context && typeof result.active_context === 'object') {
176
+ const { pending_tasks: _p, recently_closed: _r, ...resto } = result.active_context;
177
+ response.active_context = resto;
178
+ }
179
+ for (const [k, cap] of [['open_items', 12], ['recently_closed', 8]]) {
180
+ const lista = response[k];
181
+ if (!Array.isArray(lista))
182
+ continue;
183
+ if (lista.length > cap)
184
+ response[`${k}_total`] = lista.length;
185
+ response[k] = lista.slice(0, cap).map((it) => ({
186
+ ...it,
187
+ ...(it.added ? { added: dia(it.added) } : {}),
188
+ ...(it.closed ? { closed: dia(it.closed) } : {}),
189
+ }));
190
+ }
141
191
  response.last_session = response.recent_sessions[0]?.session_id ?? result.last_session ?? null;
142
192
  response.retired_tools = exports.RETIRED_TOOLS;
143
193
  // Inject protocol enforcement.
@@ -186,8 +236,9 @@ function createServer() {
186
236
  response.memory_discipline =
187
237
  'Before crbro_learn, crbro_recall: what you are about to save may already exist — then pass ' +
188
238
  'supersedes instead of adding a sibling (two versions of one fact compete on recall as equals). ' +
189
- 'crbro_recall searches by content; to read one neuron by id or name, list neurons, sessions or ' +
190
- 'the global map, use crbro_inspect. ' +
239
+ 'Read what you need, not the neuron: crbro_recall finds the entry by content; crbro_inspect view=neuron ' +
240
+ 'gives an index (id, kind, preview per entry) and entries=[ids] returns just those in full. Ask for a ' +
241
+ 'whole neuron only when you truly need all of it. ' +
191
242
  'Structure — paths, what serves what, traps — goes in crbro_map, not in facts; anything derivable ' +
192
243
  'from the repo or git history is not worth storing. Write facts dense and self-contained: they are ' +
193
244
  'recalled without this conversation, and add keywords: the words a future question may use that ' +
@@ -196,9 +247,19 @@ function createServer() {
196
247
  'deliberate deferral with its ceiling and revisit trigger. Credentials never go in the brain: ' +
197
248
  'crbro_secret, then record only the NAME. Recall results carry confidence — "weak" means the match ' +
198
249
  'covers little of the question, verify before relying on it — and when two facts disagree, prefer ' +
199
- 'the more recent. ' + THREE_STAGES + ' ' +
250
+ 'the more recent. Lifecycle: supersedes replaces, crbro_revise retires, crbro_forget removes what ' +
251
+ 'must not exist on disk — each tool describes its own stage. ' +
200
252
  'Call crbro_consolidate before the conversation ends; it logs the session too.';
201
- return jsonResult(response);
253
+ // A mature brain outgrew the boot payload: on a 1,145-neuron brain it
254
+ // reached 81,406 characters (~20,000 tokens), three quarters of it the
255
+ // full text of three session summaries, and the client dumped it to a
256
+ // file 154 times instead of showing it. The blocks that make a session
257
+ // start correctly are kept whole; what grows without bound is shortened
258
+ // and says so, with the call that reads it in full.
259
+ return jsonResult((0, budget_js_1.fitToBudget)(response, {
260
+ keep: ['protocol_enforcement', 'memory_discipline', 'retired_tools', 'pending_guidance', 'semantic_hint'],
261
+ howToGetMore: 'Session summaries were shortened. Read one in full with crbro_inspect view=sessions.',
262
+ }));
202
263
  }
203
264
  catch (err) {
204
265
  return errorResult('boot', err);
@@ -209,17 +270,19 @@ function createServer() {
209
270
  // ═══════════════════════════════════════════════════════════════
210
271
  server.registerTool('crbro_inspect', {
211
272
  title: 'Inspect the brain',
212
- 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.',
273
+ 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: 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.',
213
274
  inputSchema: {
214
275
  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.'),
215
276
  neuron: zod_1.z.string().optional().describe('view=neuron only, required there: neuron id (e.g. "project_octochat") or name (e.g. "OctoChat").'),
277
+ 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.'),
278
+ 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.'),
216
279
  domain: zod_1.z.string().optional().describe('view=neurons: exact domain match, e.g. "proyectos-web".'),
217
280
  type: zod_1.z.enum(NEURON_TYPES).optional().describe('view=neurons: only this neuron type.'),
218
281
  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.'),
219
282
  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.'),
220
- 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.'),
221
- 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.'),
222
- 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.'),
283
+ 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.'),
284
+ 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.'),
285
+ 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.'),
223
286
  },
224
287
  outputSchema: {
225
288
  view: zod_1.z.enum(INSPECT_VIEWS),
@@ -234,8 +297,11 @@ function createServer() {
234
297
  last_consolidation: zod_1.z.string().nullable().optional(),
235
298
  semantic: zod_1.z.object({ installed: zod_1.z.boolean(), enabled: zod_1.z.boolean(), mode: zod_1.z.string(), model_downloaded: zod_1.z.boolean(), home: zod_1.z.string(), model: zod_1.z.string() }).optional(),
236
299
  hot_topics_recalculated: zod_1.z.string().nullable(),
237
- }).optional(),
300
+ }).loose().optional(),
238
301
  neuron: zod_1.z.object({}).loose().optional(),
302
+ // Every wrapper is loose on purpose: an oversized view comes back with
303
+ // a `truncated` block describing what was shortened, and a strict
304
+ // schema here would turn that honesty into a validation error.
239
305
  neurons: zod_1.z.object({
240
306
  total: zod_1.z.number(),
241
307
  offset: zod_1.z.number(),
@@ -243,7 +309,7 @@ function createServer() {
243
309
  id: zod_1.z.string(), name: zod_1.z.string(), domain: zod_1.z.string(), type: zod_1.z.string(),
244
310
  heat: zod_1.z.number(), last_accessed: zod_1.z.string(), facts_count: zod_1.z.number(),
245
311
  }).loose()),
246
- }).optional(),
312
+ }).loose().optional(),
247
313
  sessions: zod_1.z.object({
248
314
  total: zod_1.z.number(),
249
315
  sessions: zod_1.z.array(zod_1.z.object({
@@ -251,22 +317,32 @@ function createServer() {
251
317
  topics_touched: zod_1.z.array(zod_1.z.string()).optional(),
252
318
  key_facts_added: zod_1.z.number().optional(), decisions_made: zod_1.z.number().optional(),
253
319
  }).loose()),
254
- }).optional(),
320
+ }).loose().optional(),
255
321
  global_map: zod_1.z.object({
256
322
  total_clusters: zod_1.z.number(),
257
323
  total_bridges: zod_1.z.number(),
258
324
  computed_at: zod_1.z.string(),
259
325
  clusters: zod_1.z.array(zod_1.z.object({}).loose()),
260
326
  bridges: zod_1.z.array(zod_1.z.object({}).loose()),
261
- }).optional(),
327
+ }).loose().optional(),
262
328
  },
263
329
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
264
330
  }, async (args) => {
265
331
  try {
266
- const done = (payload) => ({
267
- content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
268
- structuredContent: { view: args.view, [args.view]: payload },
269
- });
332
+ // Every view leaves through here, so the size ceiling lives here too:
333
+ // a view added later inherits it without remembering to. It has to be
334
+ // this strict because the payload travels TWICE — once as text, once as
335
+ // structuredContent — so a client pays double for what a view emits.
336
+ const done = (payload, extra = {}) => {
337
+ const fitted = (0, budget_js_1.fitToBudget)(payload, {
338
+ 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.`,
339
+ ...extra,
340
+ });
341
+ return {
342
+ content: [{ type: 'text', text: JSON.stringify(fitted) }],
343
+ structuredContent: { view: args.view, [args.view]: fitted },
344
+ };
345
+ };
270
346
  if (args.view === 'status') {
271
347
  const manifest = await brain.getManifest();
272
348
  const hot = await (0, fs_js_1.readJSON)(brain.paths.hotTopics());
@@ -301,47 +377,152 @@ function createServer() {
301
377
  if (!neuron) {
302
378
  return textResult(`Neuron not found: "${args.neuron}". Find the id with crbro_recall or crbro_inspect view=neurons.`, true);
303
379
  }
304
- // A whole neuron can be enormous - the biggest on the reference brain
305
- // serialises to 528,836 characters, more than most models can hold - so
306
- // facts are paged instead of dumped.
307
- const limit = Math.min(Math.max(args.limit ?? 40, 1), 200);
380
+ // Three ways to read a neuron, cheapest first. The default is an
381
+ // INDEX: header, counts and one line per entry with a stable id. The
382
+ // biggest neuron on the reference brain serialises to ~530,000
383
+ // characters; no client carries that, and a model rarely needs it —
384
+ // it needs two or three entries it can now name. `entries` reads
385
+ // those in full; `detail=full` is the old dump, and it goes through
386
+ // the ceiling like every other read.
387
+ const limit = Math.min(Math.max(args.limit ?? 25, 1), 200);
308
388
  const offset = Math.max(args.offset ?? 0, 0);
309
- const visible = (neuron.facts || []).filter(f => args.include_superseded ? true : (f.status !== 'superseded' && f.status !== 'retracted'));
310
- const ordered = [...visible].sort((a, b) => String(b.added || '').localeCompare(String(a.added || '')));
311
- const page = ordered.slice(offset, offset + limit);
312
389
  const connections = await synapses.getConnections(neuron.id, args.min_strength);
390
+ const retired = (id) => neuron.entry_status?.[id]?.status;
391
+ const rows = [];
392
+ for (const f of neuron.facts || []) {
393
+ rows.push({ id: f.id || (0, hash_js_1.factId)(f.text), kind: 'fact', text: f.text, added: f.added || '',
394
+ status: f.status, confidence: f.confidence, keys: f.keys, revision_note: f.revision_note, revised: f.revised });
395
+ }
396
+ for (const d of neuron.decisions || []) {
397
+ const id = d.id || (0, ops_js_1.entryId)(d.text);
398
+ 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 });
399
+ }
400
+ const sidecar = (kind, list) => {
401
+ for (const t of list || []) {
402
+ const id = (0, ops_js_1.entryId)(t);
403
+ 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 });
404
+ }
405
+ };
406
+ sidecar('pattern', neuron.patterns);
407
+ sidecar('preference', neuron.preferences);
408
+ sidecar('error', neuron.errors);
409
+ sidecar('debt', neuron.debts);
410
+ if (neuron.map?.text)
411
+ rows.push({ id: 'map', kind: 'map', text: neuron.map.text, added: neuron.map.updated || '' });
412
+ const header = {
413
+ id: neuron.id, name: neuron.name, domain: neuron.domain, type: neuron.type,
414
+ heat: neuron.heat, summary: neuron.summary, tags: neuron.tags,
415
+ created: neuron.created, last_accessed: neuron.last_accessed,
416
+ };
417
+ // ── entries=[…]: exactly what was asked for, in full ──────────
418
+ const wanted = (args.entries || []).map(s => s.trim()).filter(Boolean);
419
+ if (wanted.length) {
420
+ const found = [];
421
+ const missing = [];
422
+ for (const w of wanted) {
423
+ const hit = rows.find(r => r.id === w) || rows.find(r => r.text.trim().toLowerCase() === w.toLowerCase());
424
+ if (hit) {
425
+ if (!found.includes(hit))
426
+ found.push(hit);
427
+ }
428
+ else
429
+ missing.push(w);
430
+ }
431
+ return done({
432
+ ...header,
433
+ entries: found,
434
+ returned: found.length,
435
+ ...(missing.length ? { not_found: missing, hint: 'Ids come from the index (view=neuron) or from crbro_recall as entry_id; exact text also works.' } : {}),
436
+ });
437
+ }
438
+ // ── detail=full: the whole neuron, facts paged, as before ──────
439
+ if (args.detail === 'full') {
440
+ const visible = (neuron.facts || []).filter(f => args.include_superseded ? true : (f.status !== 'superseded' && f.status !== 'retracted'));
441
+ const ordered = [...visible].sort((a, b) => String(b.added || '').localeCompare(String(a.added || '')));
442
+ const page = ordered.slice(offset, offset + limit);
443
+ return done({
444
+ ...neuron,
445
+ connection_ids: neuron.connections || [],
446
+ connections,
447
+ total_connections: connections.length,
448
+ facts: page,
449
+ facts_pagination: {
450
+ total: ordered.length,
451
+ returned: page.length,
452
+ offset,
453
+ has_more: offset + page.length < ordered.length,
454
+ order: 'newest first',
455
+ hidden_superseded: (neuron.facts || []).length - visible.length,
456
+ },
457
+ });
458
+ }
459
+ // ── default: the index ────────────────────────────────────────
460
+ // A fact may carry status 'active' explicitly; only the two retired
461
+ // states hide an entry from the default page.
462
+ const isRetired = (s) => s === 'superseded' || s === 'retracted';
463
+ const live = rows.filter(r => args.include_superseded ? true : !isRetired(r.status));
464
+ const byKind = (k) => live.filter(r => r.kind === k)
465
+ .sort((a, b) => String(b.added).localeCompare(String(a.added)));
466
+ // Few and heavy first — map, errors, debts — then the many: facts are the
467
+ // long tail and get paged; the map is one entry and rarely fits a preview.
468
+ const ordered = ['map', 'error', 'debt', 'preference', 'pattern', 'decision', 'fact'].flatMap(byKind);
469
+ const page = ordered.slice(offset, offset + limit);
470
+ const PREVIEW = 160;
471
+ const counts = {};
472
+ for (const r of rows)
473
+ counts[r.kind] = (counts[r.kind] || 0) + 1;
313
474
  return done({
314
- ...neuron,
315
- connection_ids: neuron.connections || [],
475
+ ...header,
476
+ counts: { ...counts, connections: connections.length, retired: rows.filter(r => isRetired(r.status)).length },
316
477
  connections,
317
- total_connections: connections.length,
318
- facts: page,
319
- facts_pagination: {
478
+ entries: page.map(r => ({
479
+ id: r.id, kind: r.kind, added: dia(r.added),
480
+ preview: r.text.length > PREVIEW ? `${r.text.slice(0, PREVIEW).trimEnd()}…` : r.text,
481
+ chars: r.text.length,
482
+ ...(isRetired(r.status) ? { status: r.status, ...(r.revised ? { revised: dia(r.revised) } : {}), ...(r.revision_note ? { retired_note: r.revision_note } : {}) } : {}),
483
+ })),
484
+ entries_pagination: {
320
485
  total: ordered.length,
321
486
  returned: page.length,
322
487
  offset,
323
488
  has_more: offset + page.length < ordered.length,
324
- order: 'newest first',
325
- hidden_superseded: (neuron.facts || []).length - visible.length,
489
+ order: 'map, errors, debts, preferences, patterns, decisions, facts — newest first within each',
490
+ ...(args.include_superseded ? {} : { hidden_retired: rows.length - live.length, see_retired: 'pass include_superseded=true' }),
326
491
  },
492
+ how_to_read: `crbro_inspect view=neuron neuron="${neuron.id}" entries=[<ids>] returns those in full; detail=full returns everything.`,
327
493
  });
328
494
  }
329
495
  if (args.view === 'neurons') {
330
496
  const limit = Math.min(Math.max(args.limit ?? 50, 1), 500);
331
497
  const offset = Math.max(args.offset ?? 0, 0);
332
- const rows = await cortex.list({
498
+ const { total, rows } = await cortex.listWithTotal({
333
499
  domain: args.domain,
334
500
  type: args.type,
335
501
  min_heat: args.min_heat,
336
502
  limit,
337
503
  offset,
338
504
  });
339
- return done({ total: rows.length, offset, neurons: rows });
505
+ // total is what matched the filters, not what this page carries:
506
+ // a pager told "total: 50" on a 1,145-neuron brain stops at the first page.
507
+ return done({ total, offset, returned: rows.length, has_more: offset + rows.length < total, neurons: rows });
340
508
  }
341
509
  if (args.view === 'sessions') {
342
510
  const limit = Math.min(Math.max(args.limit ?? 10, 1), 100);
343
- const sessions = await hippocampus.listSessions(limit);
344
- return done({ total: sessions.length, sessions });
511
+ const offset = Math.max(args.offset ?? 0, 0);
512
+ const pagina = (await hippocampus.listSessions(limit + offset)).slice(offset, offset + limit);
513
+ const totalSessions = (await brain.getManifest()).total_sessions;
514
+ // One log asked for by itself comes whole: it is the door boot points
515
+ // at when it shortens a summary. A list caps each one and says so.
516
+ const CAP = 3_000;
517
+ const sessions = limit === 1 ? pagina : pagina.map((s) => {
518
+ const t = String(s.summary || '');
519
+ return t.length > CAP
520
+ ? { ...s, summary: `${t.slice(0, CAP).trimEnd()}…`, summary_truncated: true, summary_chars: t.length }
521
+ : s;
522
+ });
523
+ return done({ total: totalSessions, returned: sessions.length, offset, has_more: offset + sessions.length < totalSessions, sessions }, limit === 1
524
+ ? { 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.' }
525
+ : { howToGetMore: 'Page with limit and offset; limit=1 returns a single log whole.' });
345
526
  }
346
527
  // view === 'global_map': computed live, never cached, nothing written.
347
528
  const globalMap = await prefrontal.getGlobalMap();
@@ -364,13 +545,20 @@ function createServer() {
364
545
  title: 'Learn something',
365
546
  description: 'Write: store a fact, decision, pattern, preference, error or debt on a topic; the neuron is created if missing (or pass neuron_id). Stage 1 of the lifecycle: a new truth that REPLACES an old one → crbro_learn with supersedes (one call does both); to retire with no replacement use crbro_revise; to delete from disk use crbro_forget. crbro_recall first — it may already exist. The same fact text again is not duplicated: keywords merge (or keywords_replace) and a changed confidence applies (updated_in_place); text matching a retired fact or entry is refused with skipped_retired. Decisions always append; preferences never leave this machine. Credentials are replaced with a marker and listed in redacted — crbro_secret them, record only the name. Returns neuron_id, action, superseded count, near_duplicates (stored anyway; retire the old telling), supersedes_unmatched (still live) and totals.',
366
547
  inputSchema: {
367
- topic: zod_1.z.string().describe('Topic name, e.g. "OctoChat", "Firebase", "SEO Strategy".'),
548
+ // Optional since 2.0.3, and the reason is measured: the description
549
+ // told callers that neuron_id "skips name matching entirely", the
550
+ // schema still demanded topic, and the call died at the SDK before the
551
+ // handler existed — 38 times in one user's transcripts, always the same
552
+ // -32602 on a path the tool itself recommends. The engine never reads
553
+ // topic when the id resolves. Missing both is caught below, with a
554
+ // message that says what to pass.
555
+ topic: zod_1.z.string().optional().describe('Topic name, e.g. "OctoChat", "Firebase", "SEO Strategy". Required UNLESS you pass neuron_id, in which case the topic is taken from that neuron.'),
368
556
  type: zod_1.z.enum(['fact', 'decision', 'pattern', 'preference', 'error', 'debt']).describe('error = a mistake plus its correction, in one entry. debt = a deliberate deferral: what was NOT done on purpose, its ceiling, and the revisit condition, e.g. "DEFERRED: protecting the PDFs. CEILING: anyone can download them without signing up. REVISIT WHEN: the signup flow works."'),
369
557
  content: zod_1.z.string().describe('The knowledge itself. Dense and self-contained: it is recalled without this conversation as context.'),
370
558
  confidence: zod_1.z.number().min(0).max(1).optional().describe('0.0-1.0, default 1.0. Facts only. On an exact-duplicate active fact the stored confidence is updated to this value (updated_in_place:true).'),
371
559
  domain: zod_1.z.string().optional().describe('Domain, e.g. "proyectos-web". Applied when the neuron is created; on an existing neuron it only replaces the default "general" (crbro_revise domain replaces it unconditionally).'),
372
560
  rationale: zod_1.z.string().optional().describe('Why the decision was taken. Stored and indexed with it; ignored for other types.'),
373
- neuron_id: zod_1.z.string().optional().describe('Exact neuron id from crbro_recall, e.g. "project_octochat". Skips name matching entirely.'),
561
+ neuron_id: zod_1.z.string().optional().describe('Exact neuron id from crbro_recall, e.g. "project_octochat". Skips name matching entirely, and then topic is not needed.'),
374
562
  supersedes: zod_1.z.array(zod_1.z.string()).optional().describe('Facts this one replaces: their ids or exact text. They leave recall but stay in the file. Unmatched targets are reported and stay live.'),
375
563
  keywords: zod_1.z.array(zod_1.z.string()).optional().describe('Facts only. 2-5 words a future question may use that the text does not contain: synonyms, the other language, the generic name of the product named. Indexed with the fact, never shown. The same text again with new keywords merges them.'),
376
564
  keywords_replace: zod_1.z.boolean().optional().describe('When the exact fact text already exists, replace its stored keywords with `keywords` instead of merging (default false). Teammates in a shared space only ever receive the union.'),
@@ -378,7 +566,22 @@ function createServer() {
378
566
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
379
567
  }, async (args) => {
380
568
  try {
381
- const result = await cortex.learn(args.topic, args.type, args.content, {
569
+ // One of the two has to name a neuron. A blank topic counts as missing:
570
+ // " " is truthy, and left alone it creates a neuron called nothing.
571
+ let topic = (args.topic ?? '').trim();
572
+ if (!topic) {
573
+ if (!args.neuron_id) {
574
+ return textResult('Nothing to store this on. Pass `topic` (the topic name — the neuron is created if none matches) ' +
575
+ 'or `neuron_id` (an exact id from crbro_recall). Got neither.', true);
576
+ }
577
+ const target = await resolveNeuron(args.neuron_id);
578
+ if (!target) {
579
+ return textResult(`No neuron with id "${args.neuron_id}". Find the right id with crbro_recall, ` +
580
+ 'or pass `topic` and the neuron will be created.', true);
581
+ }
582
+ topic = target.name;
583
+ }
584
+ const result = await cortex.learn(topic, args.type, args.content, {
382
585
  confidence: args.confidence,
383
586
  domain: args.domain,
384
587
  rationale: args.rationale,
@@ -402,7 +605,7 @@ function createServer() {
402
605
  // `neuron` is only null when the caller asked not to create one,
403
606
  // which the MCP path never does. Guard anyway so the types stay honest.
404
607
  if (!result.neuron) {
405
- return textResult(`No neuron matched "${args.topic}" and none was created.`);
608
+ return textResult(`No neuron matched "${topic}" and none was created.`);
406
609
  }
407
610
  return jsonResult({
408
611
  neuron_id: result.neuron.id,
@@ -461,7 +664,7 @@ function createServer() {
461
664
  query: zod_1.z.string().describe('What to look for, e.g. "Firebase authentication setup". Fewer, distinctive terms beat full sentences.'),
462
665
  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.'),
463
666
  domain: zod_1.z.string().optional().describe('Only neurons in this domain (exact match, e.g. "proyectos-web").'),
464
- limit: zod_1.z.number().optional().describe('Max neurons returned (default 10).'),
667
+ 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).'),
465
668
  },
466
669
  outputSchema: {
467
670
  query: zod_1.z.string(),
@@ -473,28 +676,65 @@ function createServer() {
473
676
  heat: zod_1.z.number(), has_map: zod_1.z.boolean().optional(),
474
677
  matched_terms: zod_1.z.number().optional(), query_terms: zod_1.z.number().optional(),
475
678
  confidence: zod_1.z.enum(['strong', 'weak']).optional(),
476
- 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(),
679
+ entry_id: zod_1.z.string().optional(),
680
+ content_truncated: zod_1.z.boolean().optional(), content_chars: zod_1.z.number().optional(),
681
+ 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(),
477
682
  }).loose()),
683
+ returned: zod_1.z.number().optional(),
684
+ matched_neurons: zod_1.z.number().optional().describe('Neurons with any hit before limit; total_results is what came back'),
685
+ has_more: zod_1.z.boolean().optional(),
478
686
  hint: zod_1.z.string(),
687
+ truncated: zod_1.z.object({}).loose().optional(),
479
688
  },
480
689
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
481
690
  }, async (args) => {
482
691
  try {
483
- const results = await searchEngine.searchMany([args.query, ...(args.queries || [])], {
484
- domain: args.domain,
485
- limit: args.limit,
692
+ // Five by default, down from ten: results are ranked and each one
693
+ // carries its entry, so ten cost ~12,000 tokens on a dense brain for
694
+ // answers that live in the top three (recall@3 is the metric).
695
+ const { results, matched_neurons } = await searchEngine.searchManyWithStats([args.query, ...(args.queries || [])], { domain: args.domain, limit: args.limit ?? 5 });
696
+ // A hit carries its entry, but not without limit: one 6,000-character
697
+ // fact must not cost more than the five results around it. Past the
698
+ // cap the opening comes back, declared, and entry_id reads the rest.
699
+ const CONTENT_CAP = 1_200;
700
+ let cortados = 0;
701
+ const rows = results.map(r => {
702
+ const mc = r.matching_content;
703
+ const largo = mc.length > CONTENT_CAP;
704
+ if (largo)
705
+ cortados++;
706
+ return {
707
+ ...r,
708
+ matched_added: dia(r.matched_added),
709
+ ...(largo ? { matching_content: `${mc.slice(0, CONTENT_CAP).trimEnd()}…`, content_truncated: true, content_chars: mc.length } : {}),
710
+ ...(r.also_matched ? { also_matched: r.also_matched.map(a => ({ ...a, added: dia(a.added) })) } : {}),
711
+ };
486
712
  });
713
+ const sobran = matched_neurons - results.length;
487
714
  const payload = {
488
715
  query: args.query,
716
+ // total_results is what came back (capped by limit); matched_neurons is
717
+ // how many neurons had a hit at all, so five never reads as "only five".
489
718
  total_results: results.length,
490
- results,
719
+ returned: results.length,
720
+ matched_neurons,
721
+ has_more: sobran > 0,
722
+ results: rows,
491
723
  hint: results.length === 0
492
724
  ? 'Nothing matched. Try fewer, more distinctive words - names, ids, filenames - rather than a full sentence.'
493
- : '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.',
725
+ : 'weak: verify. Newer wins on conflict. entry_id crbro_inspect view=neuron entries=[id]. has_map crbro_map first.'
726
+ + (sobran > 0 ? ` ${sobran} more neuron${sobran === 1 ? '' : 's'} matched: raise limit or narrow the query.` : '')
727
+ + (cortados > 0 ? ' content_truncated → read the entry by entry_id.' : ''),
494
728
  };
729
+ // Recall is the one read whose size the caller sets, with limit: ten
730
+ // results already cost ~12,000 tokens on a dense brain because each
731
+ // one carries its chunk twice, as text and as structuredContent.
732
+ const fitted = (0, budget_js_1.fitToBudget)(payload, {
733
+ howToGetMore: 'Ask again with a smaller limit, or narrow the query — the matches are still there.',
734
+ });
495
735
  return {
496
- content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
497
- structuredContent: payload,
736
+ content: [{ type: 'text', text: JSON.stringify(fitted) }],
737
+ structuredContent: fitted,
498
738
  };
499
739
  }
500
740
  catch (err) {
@@ -902,13 +1142,24 @@ function createServer() {
902
1142
  title: 'Consolidate the session',
903
1143
  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.',
904
1144
  inputSchema: {
905
- summary: zod_1.z.string().describe('What was accomplished: concrete work, decisions, outcomes. Stored (after credential redaction) as the session log later sessions read.'),
1145
+ 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 after credential redaction; beyond 3,000 characters it is cut and the response says so.'),
906
1146
  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.'),
907
1147
  },
908
1148
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
909
1149
  }, async (args) => {
910
1150
  try {
911
- const result = await maintenance.consolidate(args.summary, { topicsTouched: args.topics_touched });
1151
+ // A summary is re-read at every boot of every later session. The card
1152
+ // asks for one line; the field was taking whole reports — median 8,233
1153
+ // characters on one brain — so the ceiling is enforced here and declared,
1154
+ // never applied in silence. What a session learned goes through
1155
+ // crbro_learn, where it is found by content instead of re-read whole.
1156
+ // Redact BEFORE cutting: a credential that straddles the cut would
1157
+ // otherwise leave its first half on disk, unrecognisable to the filter.
1158
+ const SUMMARY_MAX = 3_000;
1159
+ const limpio = (0, secrets_js_1.redact)(args.summary).text;
1160
+ const enviado = limpio.length;
1161
+ const summary = enviado > SUMMARY_MAX ? `${limpio.slice(0, SUMMARY_MAX).trimEnd()}…` : limpio;
1162
+ const result = await maintenance.consolidate(summary, { topicsTouched: args.topics_touched });
912
1163
  // Flush any index writes still sitting in the debounce window, so a
913
1164
  // session that ends right after a learn does not lose it.
914
1165
  await searchEngine.flush();
@@ -920,6 +1171,10 @@ function createServer() {
920
1171
  ? compartidos.map(c => ({ space: c.space, state: c.state, pushed: c.pushed }))
921
1172
  : undefined,
922
1173
  message: 'Session consolidated. Brain state persisted.',
1174
+ ...(enviado > SUMMARY_MAX ? {
1175
+ summary_truncated: { kept_from_this_call: summary.length, sent: enviado },
1176
+ note: `The summary was cut at ${SUMMARY_MAX} characters: it is re-read at every boot. Keep it to a headline paragraph and store the facts with crbro_learn, where recall finds them.`,
1177
+ } : {}),
923
1178
  });
924
1179
  }
925
1180
  catch (err) {
@@ -1005,7 +1260,7 @@ function createServer() {
1005
1260
  note: 'Values are never shown here, by design.',
1006
1261
  };
1007
1262
  return {
1008
- content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
1263
+ content: [{ type: 'text', text: JSON.stringify(payload) }],
1009
1264
  structuredContent: payload,
1010
1265
  };
1011
1266
  }