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/README.md +9 -6
- package/dist/engine/cortex.d.ts +18 -0
- package/dist/engine/cortex.d.ts.map +1 -1
- package/dist/engine/cortex.js +14 -0
- package/dist/engine/cortex.js.map +1 -1
- package/dist/search/index.d.ts +19 -1
- package/dist/search/index.d.ts.map +1 -1
- package/dist/search/index.js +55 -6
- package/dist/search/index.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +307 -52
- package/dist/server.js.map +1 -1
- package/dist/types/index.d.ts +10 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/budget.d.ts +44 -0
- package/dist/utils/budget.d.ts.map +1 -0
- package/dist/utils/budget.js +124 -0
- package/dist/utils/budget.js.map +1 -0
- package/package.json +3 -3
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
|
|
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
|
-
|
|
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
|
-
'
|
|
190
|
-
'
|
|
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.
|
|
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
|
-
|
|
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
|
|
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:
|
|
221
|
-
offset: zod_1.z.number().int().min(0).optional().describe('Items to skip. view=neuron:
|
|
222
|
-
include_superseded: zod_1.z.boolean().optional().describe('view=neuron: also
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
|
|
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
|
-
...
|
|
315
|
-
|
|
475
|
+
...header,
|
|
476
|
+
counts: { ...counts, connections: connections.length, retired: rows.filter(r => isRetired(r.status)).length },
|
|
316
477
|
connections,
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
344
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 "${
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
: '
|
|
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(
|
|
497
|
-
structuredContent:
|
|
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('
|
|
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
|
-
|
|
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
|
|
1263
|
+
content: [{ type: 'text', text: JSON.stringify(payload) }],
|
|
1009
1264
|
structuredContent: payload,
|
|
1010
1265
|
};
|
|
1011
1266
|
}
|