crbro-memory 1.16.0 → 2.0.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.
Files changed (45) hide show
  1. package/README.md +57 -30
  2. package/dist/engine/brain.js +1 -1
  3. package/dist/engine/brain.js.map +1 -1
  4. package/dist/engine/cortex.d.ts +189 -1
  5. package/dist/engine/cortex.d.ts.map +1 -1
  6. package/dist/engine/cortex.js +639 -17
  7. package/dist/engine/cortex.js.map +1 -1
  8. package/dist/engine/hippocampus.d.ts +27 -1
  9. package/dist/engine/hippocampus.d.ts.map +1 -1
  10. package/dist/engine/hippocampus.js +51 -2
  11. package/dist/engine/hippocampus.js.map +1 -1
  12. package/dist/engine/maintenance.d.ts +49 -2
  13. package/dist/engine/maintenance.d.ts.map +1 -1
  14. package/dist/engine/maintenance.js +301 -28
  15. package/dist/engine/maintenance.js.map +1 -1
  16. package/dist/engine/prefrontal.d.ts +25 -7
  17. package/dist/engine/prefrontal.d.ts.map +1 -1
  18. package/dist/engine/prefrontal.js +55 -27
  19. package/dist/engine/prefrontal.js.map +1 -1
  20. package/dist/engine/synapses.d.ts +38 -0
  21. package/dist/engine/synapses.d.ts.map +1 -1
  22. package/dist/engine/synapses.js +116 -11
  23. package/dist/engine/synapses.js.map +1 -1
  24. package/dist/search/index.d.ts +14 -2
  25. package/dist/search/index.d.ts.map +1 -1
  26. package/dist/search/index.js +40 -6
  27. package/dist/search/index.js.map +1 -1
  28. package/dist/server.d.ts +7 -0
  29. package/dist/server.d.ts.map +1 -1
  30. package/dist/server.js +760 -830
  31. package/dist/server.js.map +1 -1
  32. package/dist/sync/materialize.d.ts +1 -1
  33. package/dist/sync/materialize.d.ts.map +1 -1
  34. package/dist/sync/materialize.js +40 -3
  35. package/dist/sync/materialize.js.map +1 -1
  36. package/dist/sync/ops.d.ts +9 -4
  37. package/dist/sync/ops.d.ts.map +1 -1
  38. package/dist/sync/ops.js.map +1 -1
  39. package/dist/sync/space.d.ts +27 -0
  40. package/dist/sync/space.d.ts.map +1 -1
  41. package/dist/sync/space.js +113 -1
  42. package/dist/sync/space.js.map +1 -1
  43. package/dist/types/index.d.ts +29 -0
  44. package/dist/types/index.d.ts.map +1 -1
  45. package/package.json +1 -1
package/dist/server.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  // ─── CRBRO MCP Server ────────────────────────────────────────────
3
- // Main server with all 23 tools registered.
3
+ // Main server with the 15 tools of the 2.0 surface registered.
4
4
  //
5
5
  // Every tool goes through registerTool with a title, MCP annotations
6
6
  // (readOnlyHint / destructiveHint / idempotentHint / openWorldHint) and, for
@@ -9,8 +9,15 @@
9
9
  // it over its siblings, its side effects and what it returns — and nothing
10
10
  // else: the discipline of using the memory well is said once, at boot, in
11
11
  // memory_discipline, because these definitions are paid on every request in
12
- // clients that load all tools (measured: ~5.4k tokens for the 23).
12
+ // clients that load all tools.
13
+ //
14
+ // 2.0 folded the nine read/duplicate tools of 1.x (status, neuron, neurons,
15
+ // hot_topics, connections, sessions, global_map, session_log, sync) into
16
+ // crbro_inspect, crbro_space action=sync and crbro_consolidate. RETIRED_TOOLS
17
+ // below is the only place the old names survive: boot serves it so a client
18
+ // with an old habit finds the replacement without a round trip.
13
19
  Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.RETIRED_TOOLS = void 0;
14
21
  exports.createServer = createServer;
15
22
  const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
16
23
  const zod_1 = require("zod");
@@ -27,6 +34,30 @@ const semantic_js_1 = require("./search/semantic.js");
27
34
  const maintenance_js_1 = require("./engine/maintenance.js");
28
35
  const space_js_1 = require("./sync/space.js");
29
36
  const keychain_js_1 = require("./engine/keychain.js");
37
+ const fs_js_1 = require("./utils/fs.js");
38
+ const hash_js_1 = require("./utils/hash.js");
39
+ /**
40
+ * Old tool name → how to do the same thing on the 2.0 surface. Served by
41
+ * crbro_boot as `retired_tools` for the whole 2.x line, and asserted by the
42
+ * definitions test. Keys are the ONLY place a retired name may appear in a
43
+ * string the model receives.
44
+ */
45
+ exports.RETIRED_TOOLS = {
46
+ crbro_status: 'crbro_inspect view=status',
47
+ crbro_neuron: 'crbro_inspect view=neuron neuron=<id or name>',
48
+ crbro_neurons: 'crbro_inspect view=neurons [domain|type|min_heat|limit|offset]',
49
+ crbro_hot_topics: 'crbro_inspect view=neurons (rows) and crbro_inspect view=status (hot_topics_recalculated)',
50
+ crbro_connections: 'crbro_inspect view=neuron neuron=<id> [min_strength]',
51
+ crbro_sessions: 'crbro_inspect view=sessions [limit]',
52
+ crbro_global_map: 'crbro_inspect view=global_map',
53
+ crbro_session_log: 'crbro_consolidate summary=... [topics_touched=[...] for neuron ids you only read] (logs the session) plus crbro_context set_topics=[...] if you need to replace the active topics',
54
+ crbro_sync: 'crbro_space action=sync [name]',
55
+ };
56
+ /** The one sentence about the lifecycle, shared by three descriptions and boot. */
57
+ const THREE_STAGES = 'A new truth that REPLACES an old one → crbro_learn with supersedes (one call does both). ' +
58
+ 'Something stopped being true, or was never true, and nothing replaces it → crbro_revise ' +
59
+ '(kept in the file, gone from recall, reversible with status active). Something must not exist ' +
60
+ 'on disk at all — a credential, personal data, a whole neuron → crbro_forget (quarantine copy first).';
30
61
  /**
31
62
  * The version of CRBRO that is actually running. The manifest carries its own
32
63
  * version, but that one stamps the brain FORMAT and has not moved since 1.0.0
@@ -42,6 +73,17 @@ function runningVersion() {
42
73
  return 'unknown';
43
74
  }
44
75
  }
76
+ function textResult(text, isError = false) {
77
+ return { content: [{ type: 'text', text }], ...(isError ? { isError: true } : {}) };
78
+ }
79
+ function jsonResult(payload) {
80
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
81
+ }
82
+ function errorResult(where, err) {
83
+ return textResult(`CRBRO ${where} error: ${err instanceof Error ? err.message : String(err)}`, true);
84
+ }
85
+ const NEURON_TYPES = ['project', 'tech', 'lang', 'person', 'domain', 'process', 'protocol'];
86
+ const INSPECT_VIEWS = ['status', 'neuron', 'neurons', 'sessions', 'global_map'];
45
87
  function createServer() {
46
88
  const server = new mcp_js_1.McpServer({
47
89
  name: 'crbro-memory',
@@ -61,6 +103,10 @@ function createServer() {
61
103
  // the miner path, which used to write straight to disk. Result on the
62
104
  // reference brain: only 106 of 1,183 neurons were searchable.
63
105
  cortex.setIndexer(neuron => searchEngine.indexNeuron(neuron));
106
+ // The mirror of the indexer: when a neuron leaves the cortex (forget entire,
107
+ // merge_into) its chunks leave the index too. The index is derived data, so
108
+ // a failure here is swallowed by the cortex like a failed reindex.
109
+ cortex.setRemover(id => searchEngine.removeNeuron(id));
64
110
  // When a neuron belongs to a shared space, every write also appends a note
65
111
  // to this machine's own log. Nobody ever writes to anyone else's file, so
66
112
  // two people working at once have nothing to collide over.
@@ -68,12 +114,14 @@ function createServer() {
68
114
  // v1.4.0: CRBRO is fully free — no license, no network calls. The former
69
115
  // license engine (Firestore-backed freemium) lives in git history before
70
116
  // that version if it is ever needed again.
117
+ /** id first, then name — the resolution every neuron-taking tool shares. */
118
+ const resolveNeuron = async (ref) => (await cortex.peek(ref)) || (await cortex.findByName(ref));
71
119
  // ═══════════════════════════════════════════════════════════════
72
120
  // TOOL 1: crbro_boot — Boot sequence
73
121
  // ═══════════════════════════════════════════════════════════════
74
122
  server.registerTool('crbro_boot', {
75
123
  title: 'Boot the brain',
76
- description: 'Boot the CRBRO brain — call it FIRST in every conversation, before any other work. Loads persistent memory from earlier sessions: hot topics, active context with open_items and recently_closed (never report recently_closed as pending; verify open_items before repeating them), recent session history, counts, any active protocols as a protocol_enforcement block you must follow, and memory_discipline the rules for using this memory well. Initializes the brain on first use, readies the search index and syncs shared team spaces (offline is a normal outcome, not an error). Skipping it means losing all accumulated context.',
124
+ description: 'Read the brain at session start — call it FIRST in every conversation, before any other work; writes only the boot stamp (and the brain itself on first use). Loads memory from earlier sessions: hot topics, active context with open_items and recently_closed (never report recently_closed as pending; verify open_items before repeating them), recent_sessions, counts, active protocols as a protocol_enforcement block you must follow, memory_discipline (the rules for using this memory well) and retired_tools (old tool names their replacement). Readies the search index and syncs shared team spaces (offline is normal). Skipping it loses all context; close the session with crbro_consolidate.',
77
125
  inputSchema: {},
78
126
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
79
127
  }, async () => {
@@ -86,6 +134,12 @@ function createServer() {
86
134
  // its own and anything pending goes out on the next sync.
87
135
  const equipos = await (0, space_js_1.syncAll)(brain, cortex, 5_000);
88
136
  const response = { ...result };
137
+ // The last three real session logs, and last_session taken from them:
138
+ // the manifest field brain.boot reads was never written by anything,
139
+ // so it came back null in every session.
140
+ response.recent_sessions = await hippocampus.listSessions(3);
141
+ response.last_session = response.recent_sessions[0]?.session_id ?? result.last_session ?? null;
142
+ response.retired_tools = exports.RETIRED_TOOLS;
89
143
  // Inject protocol enforcement.
90
144
  // The instruction text goes in ONE place. It used to be emitted twice --
91
145
  // inside active_protocols[].instructions and again here -- which on a
@@ -119,8 +173,8 @@ function createServer() {
119
173
  }
120
174
  // How to use this memory well — said once here, at boot, instead of
121
175
  // repeated inside every tool description. Measured with a real
122
- // tools/list: the 23 definitions cost ~6.3k tokens on every request
123
- // in clients that load all tools; this paragraph costs ~200, once.
176
+ // tools/list in 1.13: the definitions cost thousands of tokens on every
177
+ // request in clients that load all tools; this paragraph costs ~250, once.
124
178
  // Semantic recall is installed by init since 1.16; say so once when
125
179
  // it is missing, so the agent can offer the one command that fixes it.
126
180
  const sem = (0, semantic_js_1.semanticStatus)();
@@ -132,6 +186,8 @@ function createServer() {
132
186
  response.memory_discipline =
133
187
  'Before crbro_learn, crbro_recall: what you are about to save may already exist — then pass ' +
134
188
  '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. ' +
135
191
  'Structure — paths, what serves what, traps — goes in crbro_map, not in facts; anything derivable ' +
136
192
  'from the repo or git history is not worth storing. Write facts dense and self-contained: they are ' +
137
193
  'recalled without this conversation, and add keywords: the words a future question may use that ' +
@@ -140,70 +196,165 @@ function createServer() {
140
196
  'deliberate deferral with its ceiling and revisit trigger. Credentials never go in the brain: ' +
141
197
  'crbro_secret, then record only the NAME. Recall results carry confidence — "weak" means the match ' +
142
198
  'covers little of the question, verify before relying on it — and when two facts disagree, prefer ' +
143
- 'the more recent. Call crbro_consolidate before the conversation ends; it logs the session too.';
144
- return {
145
- content: [{
146
- type: 'text',
147
- text: JSON.stringify(response, null, 2),
148
- }],
149
- };
199
+ 'the more recent. ' + THREE_STAGES + ' ' +
200
+ 'Call crbro_consolidate before the conversation ends; it logs the session too.';
201
+ return jsonResult(response);
150
202
  }
151
203
  catch (err) {
152
- return {
153
- content: [{
154
- type: 'text',
155
- text: `CRBRO boot error: ${err instanceof Error ? err.message : String(err)}`,
156
- }],
157
- isError: true,
158
- };
204
+ return errorResult('boot', err);
159
205
  }
160
206
  });
161
207
  // ═══════════════════════════════════════════════════════════════
162
- // TOOL 2: crbro_statusBrain status
208
+ // TOOL 2: crbro_inspectRead-only views of the brain
163
209
  // ═══════════════════════════════════════════════════════════════
164
- server.registerTool('crbro_status', {
165
- title: 'Brain status',
166
- description: 'Read-only snapshot: CRBRO version, brain format, neuron/synapse/session totals, brain path, last boot and last consolidation. Loads no memory that is crbro_boot.',
167
- inputSchema: {},
210
+ server.registerTool('crbro_inspect', {
211
+ 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.',
213
+ inputSchema: {
214
+ 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
+ neuron: zod_1.z.string().optional().describe('view=neuron only, required there: neuron id (e.g. "project_octochat") or name (e.g. "OctoChat").'),
216
+ domain: zod_1.z.string().optional().describe('view=neurons: exact domain match, e.g. "proyectos-web".'),
217
+ type: zod_1.z.enum(NEURON_TYPES).optional().describe('view=neurons: only this neuron type.'),
218
+ 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
+ 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.'),
223
+ },
168
224
  outputSchema: {
169
- crbro_version: zod_1.z.string(),
170
- brain_format: zod_1.z.string().optional(),
171
- total_neurons: zod_1.z.number(),
172
- total_synapses: zod_1.z.number(),
173
- total_sessions: zod_1.z.number(),
174
- brain_path: zod_1.z.string().optional(),
175
- last_boot: zod_1.z.string().nullable().optional(),
176
- last_consolidation: zod_1.z.string().nullable().optional(),
177
- 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(),
225
+ view: zod_1.z.enum(INSPECT_VIEWS),
226
+ status: zod_1.z.object({
227
+ crbro_version: zod_1.z.string(),
228
+ brain_format: zod_1.z.string().optional(),
229
+ total_neurons: zod_1.z.number(),
230
+ total_synapses: zod_1.z.number(),
231
+ total_sessions: zod_1.z.number(),
232
+ brain_path: zod_1.z.string().optional(),
233
+ last_boot: zod_1.z.string().nullable().optional(),
234
+ last_consolidation: zod_1.z.string().nullable().optional(),
235
+ 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
+ hot_topics_recalculated: zod_1.z.string().nullable(),
237
+ }).optional(),
238
+ neuron: zod_1.z.object({}).loose().optional(),
239
+ neurons: zod_1.z.object({
240
+ total: zod_1.z.number(),
241
+ offset: zod_1.z.number(),
242
+ neurons: zod_1.z.array(zod_1.z.object({
243
+ id: zod_1.z.string(), name: zod_1.z.string(), domain: zod_1.z.string(), type: zod_1.z.string(),
244
+ heat: zod_1.z.number(), last_accessed: zod_1.z.string(), facts_count: zod_1.z.number(),
245
+ }).loose()),
246
+ }).optional(),
247
+ sessions: zod_1.z.object({
248
+ total: zod_1.z.number(),
249
+ sessions: zod_1.z.array(zod_1.z.object({
250
+ session_id: zod_1.z.string().optional(), date: zod_1.z.string().optional(), summary: zod_1.z.string().optional(),
251
+ topics_touched: zod_1.z.array(zod_1.z.string()).optional(),
252
+ key_facts_added: zod_1.z.number().optional(), decisions_made: zod_1.z.number().optional(),
253
+ }).loose()),
254
+ }).optional(),
255
+ global_map: zod_1.z.object({
256
+ total_clusters: zod_1.z.number(),
257
+ total_bridges: zod_1.z.number(),
258
+ computed_at: zod_1.z.string(),
259
+ clusters: zod_1.z.array(zod_1.z.object({}).loose()),
260
+ bridges: zod_1.z.array(zod_1.z.object({}).loose()),
261
+ }).optional(),
178
262
  },
179
263
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
180
- }, async () => {
264
+ }, async (args) => {
181
265
  try {
182
- const manifest = await brain.getManifest();
183
- const payload = {
184
- crbro_version: runningVersion(),
185
- brain_format: manifest.version,
186
- total_neurons: manifest.total_neurons,
187
- total_synapses: manifest.total_synapses,
188
- total_sessions: manifest.total_sessions,
189
- brain_path: manifest.brain_path,
190
- last_boot: manifest.last_boot,
191
- last_consolidation: manifest.last_consolidation,
192
- semantic: (0, semantic_js_1.semanticStatus)(),
193
- };
194
- return {
266
+ const done = (payload) => ({
195
267
  content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
196
- structuredContent: payload,
197
- };
268
+ structuredContent: { view: args.view, [args.view]: payload },
269
+ });
270
+ if (args.view === 'status') {
271
+ const manifest = await brain.getManifest();
272
+ const hot = await (0, fs_js_1.readJSON)(brain.paths.hotTopics());
273
+ return done({
274
+ crbro_version: runningVersion(),
275
+ brain_format: manifest.version,
276
+ total_neurons: manifest.total_neurons,
277
+ total_synapses: manifest.total_synapses,
278
+ total_sessions: manifest.total_sessions,
279
+ brain_path: manifest.brain_path,
280
+ last_boot: manifest.last_boot,
281
+ last_consolidation: manifest.last_consolidation,
282
+ semantic: (0, semantic_js_1.semanticStatus)(),
283
+ hot_topics_recalculated: hot?.last_recalculated ?? null,
284
+ });
285
+ }
286
+ if (args.view === 'neuron') {
287
+ if (!args.neuron) {
288
+ return textResult('view=neuron needs `neuron`: a neuron id or name.', true);
289
+ }
290
+ // peek, not get: a tool that declares readOnlyHint must not write.
291
+ // get() stamps last_accessed and access_count, which made this view
292
+ // contradict its own annotation. Heat keeps its frequency signal from
293
+ // the write paths (learn, map), and crbro_recall — the read an agent
294
+ // actually makes — never bumped either.
295
+ let neuron = await cortex.peek(args.neuron);
296
+ if (!neuron) {
297
+ const found = await cortex.findByName(args.neuron);
298
+ if (found)
299
+ neuron = await cortex.peek(found.id);
300
+ }
301
+ if (!neuron) {
302
+ return textResult(`Neuron not found: "${args.neuron}". Find the id with crbro_recall or crbro_inspect view=neurons.`, true);
303
+ }
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);
308
+ 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
+ const connections = await synapses.getConnections(neuron.id, args.min_strength);
313
+ return done({
314
+ ...neuron,
315
+ connection_ids: neuron.connections || [],
316
+ connections,
317
+ total_connections: connections.length,
318
+ facts: page,
319
+ facts_pagination: {
320
+ total: ordered.length,
321
+ returned: page.length,
322
+ offset,
323
+ has_more: offset + page.length < ordered.length,
324
+ order: 'newest first',
325
+ hidden_superseded: (neuron.facts || []).length - visible.length,
326
+ },
327
+ });
328
+ }
329
+ if (args.view === 'neurons') {
330
+ const limit = Math.min(Math.max(args.limit ?? 50, 1), 500);
331
+ const offset = Math.max(args.offset ?? 0, 0);
332
+ const rows = await cortex.list({
333
+ domain: args.domain,
334
+ type: args.type,
335
+ min_heat: args.min_heat,
336
+ limit,
337
+ offset,
338
+ });
339
+ return done({ total: rows.length, offset, neurons: rows });
340
+ }
341
+ if (args.view === 'sessions') {
342
+ 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 });
345
+ }
346
+ // view === 'global_map': computed live, never cached, nothing written.
347
+ const globalMap = await prefrontal.getGlobalMap();
348
+ return done({
349
+ total_clusters: globalMap.clusters.length,
350
+ total_bridges: globalMap.bridges.length,
351
+ computed_at: globalMap.last_rebuilt,
352
+ clusters: globalMap.clusters,
353
+ bridges: globalMap.bridges,
354
+ });
198
355
  }
199
356
  catch (err) {
200
- return {
201
- content: [{
202
- type: 'text',
203
- text: `CRBRO status error: ${err instanceof Error ? err.message : String(err)}`,
204
- }],
205
- isError: true,
206
- };
357
+ return errorResult('inspect', err);
207
358
  }
208
359
  });
209
360
  // ═══════════════════════════════════════════════════════════════
@@ -211,17 +362,18 @@ function createServer() {
211
362
  // ═══════════════════════════════════════════════════════════════
212
363
  server.registerTool('crbro_learn', {
213
364
  title: 'Learn something',
214
- description: 'Store a fact, decision, pattern, preference, error or debt on a topic. The neuron is created if it does not exist; pass neuron_id (from crbro_recall) to target an exact one and skip name matching. Recall first: to replace an outdated fact pass its id in supersedes rather than adding a sibling. A fact stored verbatim before is skipped silently; decisions always append; preferences never leave this machine. Credential-like values are replaced with a marker before touching disk and listed in redacted store the value with crbro_secret and record only its name. Add keywords a future question may use that the text lacks (synonyms, the other language, the generic name of the product). Returns neuron_id, action (created|updated), superseded count, near_duplicates (stored anyway; retire the old telling), supersedes_unmatched (those targets are still live — retire them with crbro_revise) and running totals.',
365
+ 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.',
215
366
  inputSchema: {
216
367
  topic: zod_1.z.string().describe('Topic name, e.g. "OctoChat", "Firebase", "SEO Strategy".'),
217
368
  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."'),
218
369
  content: zod_1.z.string().describe('The knowledge itself. Dense and self-contained: it is recalled without this conversation as context.'),
219
- confidence: zod_1.z.number().min(0).max(1).optional().describe('0.0-1.0, default 1.0. Facts only.'),
220
- 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".'),
370
+ 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
+ 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).'),
221
372
  rationale: zod_1.z.string().optional().describe('Why the decision was taken. Stored and indexed with it; ignored for other types.'),
222
373
  neuron_id: zod_1.z.string().optional().describe('Exact neuron id from crbro_recall, e.g. "project_octochat". Skips name matching entirely.'),
223
374
  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.'),
224
375
  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
+ 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.'),
225
377
  },
226
378
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
227
379
  }, async (args) => {
@@ -233,187 +385,78 @@ function createServer() {
233
385
  neuronId: args.neuron_id,
234
386
  supersedes: args.supersedes,
235
387
  keys: args.keywords,
388
+ keysReplace: args.keywords_replace,
236
389
  });
237
390
  // Indexing happens inside cortex.learn, through the indexer hook.
391
+ if (result.action === 'skipped_retired' && result.skipped_retired) {
392
+ const r = result.skipped_retired;
393
+ return jsonResult({
394
+ neuron_id: result.neuron?.id ?? null,
395
+ action: 'skipped_retired',
396
+ skipped_retired: r,
397
+ message: `Not stored: this text matches a ${r.status} entry (${r.id}${r.revised ? `, revised ${r.revised}` : ''}). ` +
398
+ (r.note ? `Note: ${r.note}. ` : '') +
399
+ 'If it is true again, reactivate it with crbro_revise status active; if you meant a different fact, reword it.',
400
+ });
401
+ }
238
402
  // `neuron` is only null when the caller asked not to create one,
239
403
  // which the MCP path never does. Guard anyway so the types stay honest.
240
404
  if (!result.neuron) {
241
- return {
242
- content: [{
243
- type: 'text',
244
- text: `No neuron matched "${args.topic}" and none was created.`,
245
- }],
246
- };
247
- }
248
- return {
249
- content: [{
250
- type: 'text',
251
- text: JSON.stringify({
252
- neuron_id: result.neuron.id,
253
- action: result.action,
254
- superseded_facts: result.superseded,
255
- near_duplicates: result.near_duplicates.length > 0
256
- ? result.near_duplicates
257
- : undefined,
258
- near_duplicates_warning: result.near_duplicates.length > 0
259
- ? 'Stored, but this closely resembles the fact(s) listed above. If this is ' +
260
- 'a newer telling of the same thing, retire the old one: pass its id in ' +
261
- '`supersedes` next time, or crbro_revise it now. Two versions of one fact ' +
262
- 'keep competing on recall as equals.'
263
- : undefined,
264
- supersedes_unmatched: result.supersedes_unmatched.length > 0
265
- ? result.supersedes_unmatched
266
- : undefined,
267
- supersedes_warning: result.supersedes_unmatched.length > 0
268
- ? 'These supersedes targets matched NO active fact — the old version is ' +
269
- 'still live and will keep appearing on recall. Find its id with ' +
270
- 'crbro_recall and retire it with crbro_revise.'
271
- : undefined,
272
- redacted: result.redacted.length > 0 ? result.redacted : undefined,
273
- redaction_note: result.redacted.length > 0
274
- ? `Stored, but ${result.redacted.length} credential(s) were replaced with a marker: ` +
275
- `${result.redacted.join(', ')}. The sentence around them was kept. ` +
276
- 'Do not try to store the value again. Offer the user crbro_secret instead: ' +
277
- 'it puts the credential in the OS keychain, and then you record only its name here.'
278
- : undefined,
279
- total_facts: result.neuron.facts.length,
280
- total_decisions: result.neuron.decisions.length,
281
- total_patterns: result.neuron.patterns.length,
282
- message: result.action === 'created'
283
- ? `New neuron "${result.neuron.name}" created in ${result.neuron.domain}`
284
- : `Updated neuron "${result.neuron.name}" — ${args.type} added` +
285
- (result.superseded > 0 ? `, ${result.superseded} older fact(s) superseded` : ''),
286
- }, null, 2),
287
- }],
288
- };
289
- }
290
- catch (err) {
291
- return {
292
- content: [{
293
- type: 'text',
294
- text: `CRBRO learn error: ${err instanceof Error ? err.message : String(err)}`,
295
- }],
296
- isError: true,
297
- };
298
- }
299
- });
300
- // ═══════════════════════════════════════════════════════════════
301
- // TOOL 4: crbro_neuron — Read a specific neuron
302
- // ═══════════════════════════════════════════════════════════════
303
- server.registerTool('crbro_neuron', {
304
- title: 'Read a neuron',
305
- description: 'Read one neuron by id or name: facts newest first (superseded and retracted hidden unless include_superseded), decisions, patterns, preferences, errors, debts, entry dates, connections, heat and system map. Reading bumps its access stats. Big neurons are paged — use offset rather than pulling everything at once. To find the right neuron first, use crbro_recall.',
306
- inputSchema: {
307
- id: zod_1.z.string().describe('Neuron id (e.g. "project_octochat") or name (e.g. "OctoChat").'),
308
- limit: zod_1.z.number().optional().describe('Facts to return: default 40, max 200.'),
309
- offset: zod_1.z.number().optional().describe('Facts to skip. Facts come newest first.'),
310
- include_superseded: zod_1.z.boolean().optional().describe('Also return superseded and retracted facts (default false).'),
311
- },
312
- annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
313
- }, async (args) => {
314
- try { // Try by ID first, then by name
315
- let neuron = await cortex.get(args.id);
316
- if (!neuron) {
317
- neuron = await cortex.findByName(args.id);
318
- if (neuron) {
319
- // Touch the found neuron
320
- neuron = await cortex.get(neuron.id);
321
- }
405
+ return textResult(`No neuron matched "${args.topic}" and none was created.`);
322
406
  }
323
- if (!neuron) {
324
- return {
325
- content: [{
326
- type: 'text',
327
- text: `Neuron not found: "${args.id}". Use crbro_neurons to list available neurons.`,
328
- }],
329
- };
330
- }
331
- // A whole neuron can be enormous - the biggest on the reference brain
332
- // serialises to 528,836 characters, more than most models can hold - so
333
- // facts are paged instead of dumped.
334
- const limit = Math.min(Math.max(args.limit ?? 40, 1), 200);
335
- const offset = Math.max(args.offset ?? 0, 0);
336
- const visible = (neuron.facts || []).filter(f => args.include_superseded ? true : (f.status !== 'superseded' && f.status !== 'retracted'));
337
- const ordered = [...visible].sort((a, b) => String(b.added || '').localeCompare(String(a.added || '')));
338
- const page = ordered.slice(offset, offset + limit);
339
- return {
340
- content: [{
341
- type: 'text',
342
- text: JSON.stringify({
343
- ...neuron,
344
- facts: page,
345
- facts_pagination: {
346
- total: ordered.length,
347
- returned: page.length,
348
- offset,
349
- has_more: offset + page.length < ordered.length,
350
- order: 'newest first',
351
- hidden_superseded: (neuron.facts || []).length - visible.length,
352
- },
353
- }, null, 2),
354
- }],
355
- };
356
- }
357
- catch (err) {
358
- return {
359
- content: [{
360
- type: 'text',
361
- text: `CRBRO neuron error: ${err instanceof Error ? err.message : String(err)}`,
362
- }],
363
- isError: true,
364
- };
365
- }
366
- });
367
- // ═══════════════════════════════════════════════════════════════
368
- // TOOL 5: crbro_neurons — List neurons
369
- // ═══════════════════════════════════════════════════════════════
370
- server.registerTool('crbro_neurons', {
371
- title: 'List neurons',
372
- description: 'List neurons, hottest first, filtered by domain, type or min_heat. Read-only. Each row: id, name, domain, type, heat, last_accessed, facts_count. To search content rather than list topics, use crbro_recall.',
373
- inputSchema: {
374
- domain: zod_1.z.string().optional().describe('Only this domain, e.g. "proyectos-web".'),
375
- type: zod_1.z.enum(['project', 'tech', 'lang', 'person', 'domain', 'process', 'protocol']).optional().describe('Only this neuron type.'),
376
- min_heat: zod_1.z.number().optional().describe('Minimum heat, 0.0-1.0. Heat blends access frequency, recency and connectivity.'),
377
- limit: zod_1.z.number().optional().describe('Max rows (default 50).'),
378
- },
379
- outputSchema: {
380
- total: zod_1.z.number(),
381
- neurons: zod_1.z.array(zod_1.z.object({
382
- id: zod_1.z.string(), name: zod_1.z.string(), domain: zod_1.z.string(), type: zod_1.z.string(),
383
- heat: zod_1.z.number(), last_accessed: zod_1.z.string(), facts_count: zod_1.z.number(),
384
- }).loose()),
385
- },
386
- annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
387
- }, async (args) => {
388
- try {
389
- const neurons = await cortex.list({
390
- domain: args.domain,
391
- type: args.type,
392
- min_heat: args.min_heat,
393
- limit: args.limit,
407
+ return jsonResult({
408
+ neuron_id: result.neuron.id,
409
+ action: result.action,
410
+ duplicate: result.duplicate || undefined,
411
+ updated_in_place: result.updated_in_place || undefined,
412
+ superseded_facts: result.superseded,
413
+ near_duplicates: result.near_duplicates.length > 0
414
+ ? result.near_duplicates
415
+ : undefined,
416
+ near_duplicates_warning: result.near_duplicates.length > 0
417
+ ? 'Stored, but this closely resembles the fact(s) listed above. If this is ' +
418
+ 'a newer telling of the same thing, retire the old one: pass its id in ' +
419
+ '`supersedes` next time, or crbro_revise it now. Two versions of one fact ' +
420
+ 'keep competing on recall as equals.'
421
+ : undefined,
422
+ supersedes_unmatched: result.supersedes_unmatched.length > 0
423
+ ? result.supersedes_unmatched
424
+ : undefined,
425
+ supersedes_warning: result.supersedes_unmatched.length > 0
426
+ ? 'These supersedes targets matched NO active fact — the old version is ' +
427
+ 'still live and will keep appearing on recall. Find its id with ' +
428
+ 'crbro_recall and retire it with crbro_revise.'
429
+ : undefined,
430
+ redacted: result.redacted.length > 0 ? result.redacted : undefined,
431
+ redaction_note: result.redacted.length > 0
432
+ ? `Stored, but ${result.redacted.length} credential(s) were replaced with a marker: ` +
433
+ `${result.redacted.join(', ')}. The sentence around them was kept. ` +
434
+ 'Do not try to store the value again. Offer the user crbro_secret instead: ' +
435
+ 'it puts the credential in the OS keychain, and then you record only its name here.'
436
+ : undefined,
437
+ total_facts: result.neuron.facts.length,
438
+ total_decisions: result.neuron.decisions.length,
439
+ total_patterns: result.neuron.patterns.length,
440
+ message: result.action === 'created'
441
+ ? `New neuron "${result.neuron.name}" created in ${result.neuron.domain}`
442
+ : result.action === 'skipped'
443
+ ? (result.updated_in_place
444
+ ? `"${result.neuron.name}" already held this ${args.type}; its confidence/keywords were updated in place.`
445
+ : `"${result.neuron.name}" already held this ${args.type} verbatim; nothing added.`)
446
+ : `Updated neuron "${result.neuron.name}" — ${args.type} added` +
447
+ (result.superseded > 0 ? `, ${result.superseded} older fact(s) superseded` : ''),
394
448
  });
395
- const payload = { total: neurons.length, neurons };
396
- return {
397
- content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
398
- structuredContent: payload,
399
- };
400
449
  }
401
450
  catch (err) {
402
- return {
403
- content: [{
404
- type: 'text',
405
- text: `CRBRO neurons error: ${err instanceof Error ? err.message : String(err)}`,
406
- }],
407
- isError: true,
408
- };
451
+ return errorResult('learn', err);
409
452
  }
410
453
  });
411
454
  // ═══════════════════════════════════════════════════════════════
412
- // TOOL 6: crbro_recall — Search the brain
455
+ // TOOL 4: crbro_recall — Search the brain
413
456
  // ═══════════════════════════════════════════════════════════════
414
457
  server.registerTool('crbro_recall', {
415
458
  title: 'Recall',
416
- description: 'Search everything saved in earlier sessions — the full text of facts, decisions, patterns, preferences, errors, debts and system maps, not just topic names. Read-only. One result per neuron: its best matching chunk (matching_content, matched_kind, matched_added), a confidence label (weak = the match covers little of the question; verify before relying on it) and, for the top results, also_matched the neuron\'s next best lines. Superseded and retracted facts never surface. Call it before asking the user something they may already have told you, and before crbro_learn. If nothing matches or all is weak, pass 2-4 alternative phrasings in queries (fused by rank), or retry with fewer, more distinctive words (names, ids, filenames). has_map:true means the neuron keeps a system map — read it with crbro_map before touching that system.',
459
+ 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.',
417
460
  inputSchema: {
418
461
  query: zod_1.z.string().describe('What to look for, e.g. "Firebase authentication setup". Fewer, distinctive terms beat full sentences.'),
419
462
  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.'),
@@ -447,7 +490,7 @@ function createServer() {
447
490
  results,
448
491
  hint: results.length === 0
449
492
  ? 'Nothing matched. Try fewer, more distinctive words - names, ids, filenames - rather than a full sentence.'
450
- : '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.',
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.',
451
494
  };
452
495
  return {
453
496
  content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
@@ -455,506 +498,471 @@ function createServer() {
455
498
  };
456
499
  }
457
500
  catch (err) {
458
- return {
459
- content: [{
460
- type: 'text',
461
- text: `CRBRO recall error: ${err instanceof Error ? err.message : String(err)}`,
462
- }],
463
- isError: true,
464
- };
501
+ return errorResult('recall', err);
465
502
  }
466
503
  });
467
504
  // ═══════════════════════════════════════════════════════════════
468
- // TOOL 7: crbro_connectCreate/strengthen a synapse
505
+ // TOOL 5: crbro_reviseRetire, reactivate, edit metadata
469
506
  // ═══════════════════════════════════════════════════════════════
470
- server.registerTool('crbro_connect', {
471
- title: 'Connect two neurons',
472
- description: 'Create or strengthen the undirected synapse between two neurons: created at strength 0.5, +0.1 per repeat call (cap 1.0). Idle synapses decay and crbro_maintenance prunes the weak. Returns synapse_id, action (created|strengthened) and strength. Neurons written in the same session are linked automatically by crbro_consolidate; use this for relationships that are not just co-occurrence.',
473
- inputSchema: {
474
- from: zod_1.z.string().describe('Source neuron id, e.g. "project_octochat". Not validated: use an exact id from crbro_recall or crbro_neurons, or the synapse points at nothing.'),
475
- to: zod_1.z.string().describe('Target neuron id. Order does not matter (a,b) and (b,a) are the same synapse.'),
476
- type: zod_1.z.enum(['dependency', 'causal', 'temporal', 'conceptual', 'hierarchy', 'alternative']).describe('Relationship kind. Used only on creation; a strengthening call keeps the existing type.'),
477
- context: zod_1.z.string().optional().describe('One line on the relationship. On strengthen it replaces the stored text; omit to keep it.'),
478
- },
479
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
507
+ const reviseSchema = zod_1.z.object({
508
+ neuron: zod_1.z.string().describe('Neuron id or name holding what to revise, e.g. "project_octochat".'),
509
+ facts: zod_1.z.array(zod_1.z.string()).optional().describe('Facts to move to `status`: their ids (from crbro_recall) or exact text (trimmed, case-insensitive). For superseded/retracted only active facts match; for active only retired ones do.'),
510
+ entries: zod_1.z.array(zod_1.z.string()).optional().describe('Exact texts of decisions, patterns, errors or debts to move to `status`. Retired entries stay in the file (entry_status) but leave recall like a superseded fact.'),
511
+ status: zod_1.z.enum(['superseded', 'retracted', 'active']).optional().describe('superseded = a newer truth exists (default); retracted = it was never true; active = reactivate a retired fact or entry. Reactivation is local: on a shared neuron the next sync re-applies the retirement (the response carries shared_warning).'),
512
+ note: zod_1.z.string().optional().describe('Why. Stored as revision_note on facts and entry_status.note on entries. The next reader will wonder.'),
513
+ summary: zod_1.z.string().optional().describe('Replace the neuron summary. Credentials are redacted and listed in redacted.'),
514
+ domain: zod_1.z.string().optional().describe('Replace the neuron domain unconditionally, e.g. "proyectos-web".'),
515
+ tags: zod_1.z.array(zod_1.z.string()).optional().describe('Replace the WHOLE tag list (trimmed, deduplicated). On protocol neurons re-send the priority: and source: tags or they are gone.'),
516
+ name: zod_1.z.string().optional().describe('Rename the neuron. Its id, file, synapses and shared state stay the same.'),
517
+ }).superRefine((v, ctx) => {
518
+ const any = [v.facts, v.entries, v.summary, v.domain, v.tags, v.name].some(x => x !== undefined);
519
+ if (!any) {
520
+ ctx.addIssue({
521
+ code: 'custom',
522
+ message: 'Nothing to revise: pass facts, entries, or a metadata field (summary, domain, tags, name).',
523
+ });
524
+ }
525
+ });
526
+ server.registerTool('crbro_revise', {
527
+ title: 'Revise a neuron',
528
+ description: 'Write: change what a neuron says without deleting anything. Stage 2 of the lifecycle: something stopped being true, or was never true, and nothing replaces it → crbro_revise (kept in the file, gone from recall, reversible with status active). If a replacement exists, crbro_learn with supersedes does both; for what must not exist on disk use crbro_forget. facts retires facts by id or exact text; entries retires decisions, patterns, errors and debts by exact text; status active reactivates either (local only on a shared neuron: the next sync re-applies the retirement, shared_warning says so). summary, domain, tags and name edit metadata in the same call (tags replaces the whole list; the id never changes). Anything in unmatched is STILL LIVE — fix and re-run.',
529
+ inputSchema: reviseSchema,
530
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
480
531
  }, async (args) => {
481
532
  try {
482
- const result = await synapses.connect(args.from, args.to, args.type, args.context);
483
- return {
484
- content: [{
485
- type: 'text',
486
- text: JSON.stringify({
487
- synapse_id: result.synapse.id,
488
- action: result.action,
489
- strength: result.synapse.strength,
490
- message: result.action === 'created'
491
- ? `New synapse: ${args.from} ↔ ${args.to} (${args.type})`
492
- : `Synapse strengthened: ${args.from} ${args.to} strength ${result.synapse.strength}`,
493
- }, null, 2),
494
- }],
495
- };
533
+ const target = await resolveNeuron(args.neuron);
534
+ if (!target) {
535
+ return textResult(`Neuron not found: "${args.neuron}". Use crbro_recall to find the right neuron id first.`);
536
+ }
537
+ const status = args.status || 'superseded';
538
+ let revisedFacts = 0;
539
+ let revisedEntries = 0;
540
+ const unmatched = [];
541
+ let changed = [];
542
+ let redacted = [];
543
+ if (args.facts && args.facts.length > 0) {
544
+ const r = await cortex.revise(target.id, args.facts, { status, note: args.note });
545
+ revisedFacts = r.revised;
546
+ unmatched.push(...r.unmatched);
547
+ }
548
+ if (args.entries && args.entries.length > 0) {
549
+ const r = await cortex.retireEntries(target.id, args.entries, { status, note: args.note });
550
+ revisedEntries = r.revised;
551
+ unmatched.push(...r.unmatched);
552
+ }
553
+ const meta = { summary: args.summary, domain: args.domain, tags: args.tags, name: args.name };
554
+ if (Object.values(meta).some(v => v !== undefined)) {
555
+ const r = await cortex.setMeta(target.id, meta);
556
+ changed = r.changed;
557
+ redacted = r.redacted;
558
+ }
559
+ const shared = (await (0, space_js_1.sharedMap)(brain))[target.id];
560
+ const sharedWarning = status === 'active' && shared
561
+ ? `"${target.id}" is shared in space "${shared}": reactivation is local only — the retirement comes back from the shared log on the next sync.`
562
+ : undefined;
563
+ const parts = [];
564
+ if (revisedFacts > 0)
565
+ parts.push(`${revisedFacts} fact(s) ${status === 'active' ? 'reactivated' : 'retired'}`);
566
+ if (revisedEntries > 0)
567
+ parts.push(`${revisedEntries} entr(y/ies) ${status === 'active' ? 'reactivated' : 'retired'}`);
568
+ if (changed.length > 0)
569
+ parts.push(`${changed.join(', ')} updated`);
570
+ const touchedTargets = (args.facts?.length ?? 0) + (args.entries?.length ?? 0);
571
+ return jsonResult({
572
+ neuron_id: target.id,
573
+ revised_facts: revisedFacts,
574
+ revised_entries: revisedEntries,
575
+ unmatched: unmatched.length > 0 ? unmatched : undefined,
576
+ changed,
577
+ status,
578
+ shared_warning: sharedWarning,
579
+ redacted: redacted.length > 0 ? redacted : undefined,
580
+ message: parts.length > 0
581
+ ? `${parts.join('; ')} in "${target.name}".` +
582
+ (status !== 'active' && (revisedFacts + revisedEntries) > 0 ? ' They no longer appear in recall.' : '') +
583
+ (unmatched.length > 0 ? ` WARNING: ${unmatched.length} target(s) matched nothing and are unchanged.` : '')
584
+ : touchedTargets > 0
585
+ ? 'Nothing matched. Pass the fact id from crbro_recall, or the exact text; for status active only retired items match.'
586
+ : 'Nothing changed: the metadata already had those values.',
587
+ });
496
588
  }
497
589
  catch (err) {
498
- return {
499
- content: [{
500
- type: 'text',
501
- text: `CRBRO connect error: ${err instanceof Error ? err.message : String(err)}`,
502
- }],
503
- isError: true,
504
- };
590
+ return errorResult('revise', err);
505
591
  }
506
592
  });
507
593
  // ═══════════════════════════════════════════════════════════════
508
- // TOOL 8: crbro_connectionsGet neuron connections
594
+ // TOOL 6: crbro_forgetRemove from disk, five modes
509
595
  // ═══════════════════════════════════════════════════════════════
510
- server.registerTool('crbro_connections', {
511
- title: 'Neuron connections',
512
- description: 'List every synapse touching one neuron, strongest first target_id, target_name, type, strength and context per entry. Read-only; an unknown or unconnected id returns an empty list, not an error.',
513
- inputSchema: {
514
- neuron_id: zod_1.z.string().describe('Exact neuron id, e.g. "project_octochat". Names are not resolved here get the id from crbro_recall or crbro_neurons.'),
515
- min_strength: zod_1.z.number().optional().describe('Drop connections weaker than this (0.0-1.0). Omit for all; 0 is no filter.'),
516
- },
517
- outputSchema: {
518
- neuron_id: zod_1.z.string(),
519
- total_connections: zod_1.z.number(),
520
- connections: zod_1.z.array(zod_1.z.object({
521
- target_id: zod_1.z.string(), target_name: zod_1.z.string(), type: zod_1.z.string(),
522
- strength: zod_1.z.number(), context: zod_1.z.string(),
523
- }).loose()),
524
- },
525
- annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
526
- }, async (args) => {
527
- try {
528
- const connections = await synapses.getConnections(args.neuron_id, args.min_strength);
529
- const payload = { neuron_id: args.neuron_id, total_connections: connections.length, connections };
530
- return {
531
- content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
532
- structuredContent: payload,
533
- };
596
+ const forgetSchema = zod_1.z.object({
597
+ neuron: zod_1.z.string().optional().describe('Neuron id or name the mode acts on. Required for every mode except session. restore needs the exact neuron id.'),
598
+ facts: zod_1.z.array(zod_1.z.string()).optional().describe('Mode facts: fact ids, or the exact text of a fact, decision, pattern, preference, error or debt; the exact full text of the map removes the map. Deleted for good after a quarantine copy; decision/pattern removals travel to shared spaces like errors and debts.'),
599
+ entire: zod_1.z.boolean().optional().describe('Mode entire: delete the whole neuron and its synapses. Without confirm_token it is a dry run — { neuron_id, dry_run:true, counts, shared_in, confirm_token }. Refused (no token) while the neuron is shared: crbro_share unshare first.'),
600
+ confirm_token: zod_1.z.string().optional().describe('Only with entire:true — the token from the dry run. Derived from the neuron\'s counts, so it goes stale (and is refused) when the neuron changed in between.'),
601
+ restore: zod_1.z.boolean().optional().describe('Mode restore: bring back the newest quarantine copy of `neuron` (exact id). If the neuron exists again, the copy is merged into it (merged_into_existing:true, moved counts). The quarantine file stays, so restore is repeatable.'),
602
+ merge_into: zod_1.z.string().optional().describe('Mode merge_into: target neuron id or name. Everything of `neuron` is unioned into it, synapses rewired, then `neuron` is deleted (quarantined first). Refused while `neuron` is shared.'),
603
+ session: zod_1.z.string().optional().describe('Mode session: session id ("session_2026-09-03" or "2026-09-03") whose log is deleted after a quarantine copy. `neuron` is not needed.'),
604
+ }).superRefine((v, ctx) => {
605
+ const modes = [
606
+ v.facts !== undefined ? 'facts' : null,
607
+ v.entire ? 'entire' : null,
608
+ v.restore ? 'restore' : null,
609
+ v.merge_into !== undefined ? 'merge_into' : null,
610
+ v.session !== undefined ? 'session' : null,
611
+ ].filter((m) => m !== null);
612
+ if (modes.length !== 1) {
613
+ ctx.addIssue({
614
+ code: 'custom',
615
+ message: `Pass exactly ONE mode: facts (entries of a neuron), entire (whole neuron, two steps), restore (from quarantine), merge_into (another neuron) or session (a day log). Got ${modes.length === 0 ? 'none' : modes.join(' + ')}. confirm_token is only valid with entire.`,
616
+ });
534
617
  }
535
- catch (err) {
536
- return {
537
- content: [{
538
- type: 'text',
539
- text: `CRBRO connections error: ${err instanceof Error ? err.message : String(err)}`,
540
- }],
541
- isError: true,
542
- };
618
+ if (v.confirm_token !== undefined && !v.entire) {
619
+ ctx.addIssue({ code: 'custom', path: ['confirm_token'], message: 'confirm_token is only valid with entire:true.' });
620
+ }
621
+ if (v.session === undefined && !v.neuron) {
622
+ ctx.addIssue({ code: 'custom', path: ['neuron'], message: 'neuron (id or name) is required for every mode except session.' });
543
623
  }
544
624
  });
545
- // ═══════════════════════════════════════════════════════════════
546
- // TOOL 9: crbro_session_log — Log a session
547
- // ═══════════════════════════════════════════════════════════════
548
- server.registerTool('crbro_session_log', {
549
- title: 'Log a session',
550
- description: 'Log a session summary to the hippocampus — one entry per calendar day; a same-day call appends to it. Also replaces the active-topics list with topics_touched. Normally unnecessary: crbro_consolidate logs the session itself.',
551
- inputSchema: {
552
- summary: zod_1.z.string().describe('What happened in this session. Appended if today already has an entry.'),
553
- topics_touched: zod_1.z.array(zod_1.z.string()).describe('Relevant neuron ids. Merged (deduplicated) into the day entry; becomes the active-topics list.'),
554
- key_facts_added: zod_1.z.number().optional().describe('New facts stored. Summed into the day total on same-day calls.'),
555
- decisions_made: zod_1.z.number().optional().describe('Decisions recorded. Summed into the day total on same-day calls.'),
556
- },
557
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
625
+ const forgetToken = (n) => (0, hash_js_1.contentHash)(`forget:${n.id}:${(n.facts || []).length}:${(n.decisions || []).length}:${(n.patterns || []).length}:${(n.connections || []).length}`, 8);
626
+ server.registerTool('crbro_forget', {
627
+ title: 'Forget for good',
628
+ description: 'Write, destructive: remove from disk after a quarantine copy (backup returned). Stage 3 of the lifecycle: something must not exist on disk at all — a credential, personal data, a whole neuron → crbro_forget; for knowledge that merely stopped being true use crbro_revise, which keeps the history. One mode per call. facts: delete entries of a neuron (facts, decisions, patterns, preferences, errors, debts, the map) by id or exact text. entire: delete the whole neuron and its synapses — call it without confirm_token first: the dry run reports what would happen and returns confirm_token. Show the user, get agreement, call again with the token; a stale token is refused. restore: bring back the newest quarantine copy. merge_into: union a neuron into another, rewire synapses, delete the source. session: delete one day\'s log. entire and merge_into refuse a shared neuron — crbro_share unshare first. A removed credential must still be rotated.',
629
+ inputSchema: forgetSchema,
630
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
558
631
  }, async (args) => {
559
632
  try {
560
- const session = await hippocampus.logSession({
561
- summary: args.summary,
562
- topics_touched: args.topics_touched,
563
- key_facts_added: args.key_facts_added,
564
- decisions_made: args.decisions_made,
565
- });
566
- // Update active context with last session
567
- await prefrontal.updateContext({
568
- set_topics: args.topics_touched,
569
- });
570
- return {
571
- content: [{
572
- type: 'text',
573
- text: JSON.stringify({
574
- session_id: session.session_id,
575
- date: session.date,
576
- message: 'Session logged to hippocampus.',
577
- }, null, 2),
578
- }],
633
+ // ── session ──
634
+ if (args.session !== undefined) {
635
+ const r = await hippocampus.forgetSession(args.session);
636
+ return jsonResult({
637
+ ...r,
638
+ message: r.removed
639
+ ? `Session log ${r.session_id} deleted. A copy is in ${r.backup}.`
640
+ : `No session log called ${r.session_id}. List them with crbro_inspect view=sessions.`,
641
+ });
642
+ }
643
+ const ref = args.neuron;
644
+ // ── facts ──
645
+ if (args.facts !== undefined) {
646
+ const r = await cortex.forget(ref, args.facts);
647
+ if (!r.neuron_id) {
648
+ return textResult(`Neuron not found: "${ref}". Use crbro_recall or crbro_audit to find the right one.`);
649
+ }
650
+ return jsonResult({
651
+ neuron_id: r.neuron_id,
652
+ removed: r.removed,
653
+ backup: r.backup,
654
+ message: r.removed > 0
655
+ ? `${r.removed} entr(y/ies) removed from "${r.neuron_id}". A copy of the neuron as it was is in ${r.backup}. ` +
656
+ 'If any of them was a credential, rotate it: it existed on disk and in the index.'
657
+ : 'Nothing matched. Pass the fact id from crbro_recall, or its exact text.',
658
+ });
659
+ }
660
+ // ── restore ──
661
+ if (args.restore) {
662
+ const r = await cortex.restoreNeuron(ref);
663
+ if (!r.neuron) {
664
+ return textResult(`No quarantine copy found for neuron id "${ref}". restore needs the exact id the neuron had; names are not resolved here.`);
665
+ }
666
+ return jsonResult({
667
+ neuron_id: r.neuron.id,
668
+ restored_from: r.restored_from,
669
+ merged_into_existing: r.merged_into_existing,
670
+ moved: r.moved,
671
+ message: r.merged_into_existing
672
+ ? `"${r.neuron.name}" existed again, so the quarantine copy was merged into it (see moved). Synapses were not restored: reconnect with crbro_connect if needed.`
673
+ : `"${r.neuron.name}" restored from ${r.restored_from}. Synapses were not restored: reconnect with crbro_connect if needed.`,
674
+ });
675
+ }
676
+ // ── merge_into ──
677
+ if (args.merge_into !== undefined) {
678
+ const from = await resolveNeuron(ref);
679
+ if (!from)
680
+ return textResult(`Neuron not found: "${ref}".`);
681
+ const into = await resolveNeuron(args.merge_into);
682
+ if (!into)
683
+ return textResult(`Target neuron not found: "${args.merge_into}".`);
684
+ if (from.id === into.id) {
685
+ return textResult(`"${ref}" and "${args.merge_into}" are the same neuron (${from.id}); nothing to merge.`);
686
+ }
687
+ const shared = (await (0, space_js_1.sharedMap)(brain))[from.id];
688
+ if (shared) {
689
+ return textResult(`Refused: "${from.id}" is shared in space "${shared}" and would be re-created by the next sync. ` +
690
+ 'Take it out first with crbro_share unshare:true, then merge.', true);
691
+ }
692
+ const r = await cortex.mergeNeurons(from.id, into.id);
693
+ if (!r.from || !r.into || !r.moved) {
694
+ return textResult(`Merge did not run: ${!r.from ? `source "${ref}"` : `target "${args.merge_into}"`} could not be resolved.`, true);
695
+ }
696
+ const rewired = await synapses.rewire(from.id, into.id);
697
+ return jsonResult({
698
+ from: r.from,
699
+ into: r.into,
700
+ backup: r.backup,
701
+ moved: r.moved,
702
+ synapses: rewired,
703
+ message: `"${from.name}" merged into "${into.name}" and deleted; a copy is in ${r.backup}. ` +
704
+ `Synapses: ${rewired.moved} moved, ${rewired.merged} merged, ${rewired.dropped} dropped. ` +
705
+ `Undo with crbro_forget restore:true neuron="${from.id}".`,
706
+ });
707
+ }
708
+ // ── entire (two steps) ──
709
+ const target = await resolveNeuron(ref);
710
+ if (!target)
711
+ return textResult(`Neuron not found: "${ref}".`);
712
+ const shared = (await (0, space_js_1.sharedMap)(brain))[target.id];
713
+ const counts = {
714
+ facts: (target.facts || []).length,
715
+ decisions: (target.decisions || []).length,
716
+ patterns: (target.patterns || []).length,
717
+ preferences: (target.preferences || []).length,
718
+ errors: (target.errors || []).length,
719
+ debts: (target.debts || []).length,
720
+ connections: (target.connections || []).length,
579
721
  };
722
+ const token = forgetToken(target);
723
+ if (!args.confirm_token) {
724
+ return jsonResult({
725
+ neuron_id: target.id,
726
+ name: target.name,
727
+ dry_run: true,
728
+ counts,
729
+ shared_in: shared ?? null,
730
+ confirm_token: shared ? undefined : token,
731
+ message: shared
732
+ ? `Refused: "${target.id}" is shared in space "${shared}" and would be re-created by the next sync. ` +
733
+ 'Take it out first with crbro_share unshare:true, then call again.'
734
+ : `Dry run: deleting "${target.name}" removes ${counts.facts} fact(s), ${counts.decisions} decision(s), ` +
735
+ `${counts.patterns} pattern(s), ${counts.preferences} preference(s), ${counts.errors} error(s), ${counts.debts} debt(s) ` +
736
+ `and ${counts.connections} synapse(s). A quarantine copy is kept (restore:true brings it back). ` +
737
+ 'Show this to the user; with their agreement call again with confirm_token.',
738
+ });
739
+ }
740
+ if (shared) {
741
+ return textResult(`Refused: "${target.id}" is shared in space "${shared}". Take it out first with crbro_share unshare:true.`, true);
742
+ }
743
+ if (args.confirm_token !== token) {
744
+ return textResult(`Stale confirm_token: "${target.id}" changed since the dry run. Call again without the token to get a fresh one.`, true);
745
+ }
746
+ const r = await cortex.forgetNeuron(target.id);
747
+ if (!r.neuron_id)
748
+ return textResult(`Neuron not found: "${ref}".`);
749
+ const synapsesRemoved = await synapses.removeAllFor(target.id);
750
+ return jsonResult({
751
+ neuron_id: r.neuron_id,
752
+ removed: 'neuron',
753
+ backup: r.backup,
754
+ counts: r.counts,
755
+ synapses_removed: synapsesRemoved,
756
+ message: `"${target.name}" deleted with ${synapsesRemoved} synapse(s). A copy is in ${r.backup}; ` +
757
+ `crbro_forget restore:true neuron="${target.id}" brings it back. If it held a credential, rotate it.`,
758
+ });
580
759
  }
581
760
  catch (err) {
582
- return {
583
- content: [{
584
- type: 'text',
585
- text: `CRBRO session_log error: ${err instanceof Error ? err.message : String(err)}`,
586
- }],
587
- isError: true,
588
- };
761
+ return errorResult('forget', err);
589
762
  }
590
763
  });
591
764
  // ═══════════════════════════════════════════════════════════════
592
- // TOOL 10: crbro_sessionsList recent sessions
765
+ // TOOL 7: crbro_connectCreate, strengthen or delete a synapse
593
766
  // ═══════════════════════════════════════════════════════════════
594
- server.registerTool('crbro_sessions', {
595
- title: 'Recent sessions',
596
- description: 'List recent session logs, newest first, one per day: date, merged summary, topics_touched neuron ids, fact/decision counters. Read-only. Read them before asking the user what was already done; crbro_boot already returns the last one.',
767
+ server.registerTool('crbro_connect', {
768
+ title: 'Connect two neurons',
769
+ description: 'Write: create, strengthen or delete the undirected synapse between two neurons; both ids are validated. action=connect (default) creates at strength 0.5 and adds +0.1 per repeat call (cap 1.0), or sets the absolute strength you pass; action=disconnect deletes the synapse and unlinks both neurons — the destructive side. Idle synapses decay and crbro_maintenance prunes the weak; crbro_consolidate links neurons written in the same session by itself, so use this for relationships beyond co-occurrence. To read connections use crbro_inspect view=neuron. Returns synapse_id, action (created|strengthened|disconnected|absent) and strength.',
597
770
  inputSchema: {
598
- limit: zod_1.z.number().optional().describe('Day logs to return, newest first (default 10).'),
599
- },
600
- outputSchema: {
601
- total: zod_1.z.number(),
602
- sessions: zod_1.z.array(zod_1.z.object({
603
- session_id: zod_1.z.string().optional(), date: zod_1.z.string().optional(), summary: zod_1.z.string().optional(),
604
- topics_touched: zod_1.z.array(zod_1.z.string()).optional(),
605
- key_facts_added: zod_1.z.number().optional(), decisions_made: zod_1.z.number().optional(),
606
- }).loose()),
771
+ action: zod_1.z.enum(['connect', 'disconnect']).optional().describe('connect = create or strengthen (default); disconnect = delete the synapse and unlink both neurons. An absent synapse returns action:absent, removed:false, not an error.'),
772
+ from: zod_1.z.string().describe('Exact neuron id, e.g. "project_octochat". Validated: an unknown id is an error.'),
773
+ to: zod_1.z.string().describe('Exact neuron id. Order does not matter — (a,b) and (b,a) are the same synapse.'),
774
+ type: zod_1.z.enum(['dependency', 'causal', 'temporal', 'conceptual', 'hierarchy', 'alternative']).optional().describe('Relationship kind, used only when the synapse is created (default conceptual). Ignored on strengthen and on disconnect.'),
775
+ strength: zod_1.z.number().min(0).max(1).optional().describe('Absolute strength 0.0-1.0 to set, on create or on an existing synapse, instead of the 0.5 / +0.1 rule.'),
776
+ context: zod_1.z.string().optional().describe('One line on the relationship. On strengthen it replaces the stored text; omit to keep it.'),
607
777
  },
608
- annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
778
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
609
779
  }, async (args) => {
610
780
  try {
611
- const sessions = await hippocampus.listSessions(args.limit);
612
- const payload = { total: sessions.length, sessions };
613
- return {
614
- content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
615
- structuredContent: payload,
616
- };
781
+ for (const id of [args.from, args.to]) {
782
+ if (!(await cortex.peek(id))) {
783
+ return textResult(`Unknown neuron: ${id}. Pass exact ids — find them with crbro_recall or crbro_inspect view=neurons.`, true);
784
+ }
785
+ }
786
+ if (args.action === 'disconnect') {
787
+ const r = await synapses.disconnect(args.from, args.to);
788
+ return jsonResult({
789
+ synapse_id: r.synapse_id,
790
+ action: r.removed ? 'disconnected' : 'absent',
791
+ removed: r.removed,
792
+ message: r.removed
793
+ ? `Synapse removed: ${args.from} ↔ ${args.to}. Both neurons no longer list each other.`
794
+ : `No synapse between ${args.from} and ${args.to}; nothing to remove.`,
795
+ });
796
+ }
797
+ const type = args.type ?? 'conceptual';
798
+ const result = await synapses.connect(args.from, args.to, type, args.context, { strength: args.strength });
799
+ return jsonResult({
800
+ synapse_id: result.synapse.id,
801
+ action: result.action,
802
+ strength: result.synapse.strength,
803
+ message: result.action === 'created'
804
+ ? `New synapse: ${args.from} ↔ ${args.to} (${type}) at strength ${result.synapse.strength}`
805
+ : `Synapse strengthened: ${args.from} ↔ ${args.to} → strength ${result.synapse.strength}`,
806
+ });
617
807
  }
618
808
  catch (err) {
619
- return {
620
- content: [{
621
- type: 'text',
622
- text: `CRBRO sessions error: ${err instanceof Error ? err.message : String(err)}`,
623
- }],
624
- isError: true,
625
- };
809
+ return errorResult('connect', err);
626
810
  }
627
811
  });
628
812
  // ═══════════════════════════════════════════════════════════════
629
- // TOOL 11: crbro_context — Active context
813
+ // TOOL 8: crbro_context — Active context
630
814
  // ═══════════════════════════════════════════════════════════════
631
815
  server.registerTool('crbro_context', {
632
816
  title: 'Working context',
633
- description: 'Read or update the working context: active topics, open items, last session. Call with no arguments to read; every call returns the full state plus resolved, the items it closed. Close items as soon as they are done — an item left open is repeated back to the user in later sessions long after it was finished.',
817
+ description: 'Read or write the working context: active topics, open items, recently closed, last session. Called with no arguments it only reads (written:false, nothing touched); any argument writes and returns the full state plus resolved and discarded. Close items as soon as they are done — resolve_pending records them in recently_closed, discard_pending drops one without recording it, clear empties everything — because an item left open is repeated back to the user in later sessions long after it was finished. Sessions are logged by crbro_consolidate, not here.',
634
818
  inputSchema: {
635
- set_topics: zod_1.z.array(zod_1.z.string()).optional().describe('Replace the whole active-topics list with these neuron ids (no merge). crbro_session_log also overwrites it.'),
819
+ set_topics: zod_1.z.array(zod_1.z.string()).optional().describe('Replace the whole active-topics list with these neuron ids (no merge). crbro_consolidate also rewrites it from the session.'),
636
820
  add_pending: zod_1.z.string().optional().describe('Add an open item, written so it can be checked later. Identical text is deduplicated, so re-adding is a safe no-op.'),
637
821
  resolve_pending: zod_1.z.string().optional().describe('Close an open item by id (e.g. "p_ab12cd") or by 8+ characters of its text (case-insensitive substring; several items can close at once). Matches move to recently_closed, newest first, capped at 15. An empty resolved in the reply means nothing matched.'),
822
+ discard_pending: zod_1.z.string().optional().describe('Drop an open item by id or 8+ characters of its text WITHOUT recording it as done (it never appears in recently_closed). Same matcher as resolve_pending; matches come back in discarded.'),
823
+ clear: zod_1.z.boolean().optional().describe('Empty active_topics, pending_tasks and recently_closed. Runs before the other updates in the same call.'),
638
824
  },
639
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
825
+ // clear empties the whole working context and discard_pending drops an
826
+ // item without recording it: destructive, whatever the common path does.
827
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
640
828
  }, async (args) => {
641
829
  try {
642
830
  const ctx = await prefrontal.updateContext({
643
831
  set_topics: args.set_topics,
644
832
  add_pending: args.add_pending,
645
833
  resolve_pending: args.resolve_pending,
834
+ discard_pending: args.discard_pending,
835
+ clear: args.clear,
646
836
  });
647
- return {
648
- content: [{
649
- type: 'text',
650
- text: JSON.stringify(ctx, null, 2),
651
- }],
652
- };
837
+ return jsonResult(ctx);
653
838
  }
654
839
  catch (err) {
655
- return {
656
- content: [{
657
- type: 'text',
658
- text: `CRBRO context error: ${err instanceof Error ? err.message : String(err)}`,
659
- }],
660
- isError: true,
661
- };
840
+ return errorResult('context', err);
662
841
  }
663
842
  });
664
843
  // ═══════════════════════════════════════════════════════════════
665
- // TOOL 12: crbro_hot_topicsHot topics
844
+ // TOOL 9: crbro_mapThe living map of a system
666
845
  // ═══════════════════════════════════════════════════════════════
667
- server.registerTool('crbro_hot_topics', {
668
- title: 'Hot topics',
669
- description: 'The hottest neurons by heat (access frequency, recency, connectivity). Read-only, served from a cache rebuilt at consolidate and maintenance last_recalculated says when. crbro_boot already returns this list.',
670
- inputSchema: {
671
- limit: zod_1.z.number().optional().describe('Topics to return (default 15; the cache never holds more than 20).'),
672
- },
673
- outputSchema: {
674
- topics: zod_1.z.array(zod_1.z.object({
675
- id: zod_1.z.string(), name: zod_1.z.string(), heat: zod_1.z.number(), last_access: zod_1.z.string(), domain: zod_1.z.string(),
676
- }).loose()),
677
- last_recalculated: zod_1.z.string().optional(),
678
- },
679
- annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
680
- }, async (args) => {
681
- try {
682
- const hotTopics = await prefrontal.getHotTopics(args.limit);
683
- const payload = { ...hotTopics };
684
- return {
685
- content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
686
- structuredContent: payload,
687
- };
688
- }
689
- catch (err) {
690
- return {
691
- content: [{
692
- type: 'text',
693
- text: `CRBRO hot_topics error: ${err instanceof Error ? err.message : String(err)}`,
694
- }],
695
- isError: true,
696
- };
697
- }
698
- });
699
- // ═══════════════════════════════════════════════════════════════
700
- // TOOL 13: crbro_global_map — Global neural map
701
- // ═══════════════════════════════════════════════════════════════
702
- server.registerTool('crbro_global_map', {
703
- title: 'Global map',
704
- description: 'The global map: one cluster per domain (node ids, top neurons, heat) and bridges where synapses cross domains, served from a cache stamped last_rebuilt. Read-only unless rebuild:true, which rescans every neuron and rewrites that cache (derived data, no knowledge is touched). For one system\'s internals use crbro_map instead.',
705
- inputSchema: {
706
- rebuild: zod_1.z.boolean().optional().describe('true = rescan every neuron and rewrite the cached map (slower on big brains). Default: serve the cache, building it only if missing — it can lag recent learning; crbro_maintenance also rebuilds it.'),
707
- },
708
- annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
709
- }, async (args) => {
710
- try {
711
- const globalMap = await prefrontal.getGlobalMap(args.rebuild);
712
- return {
713
- content: [{
714
- type: 'text',
715
- text: JSON.stringify({
716
- total_clusters: globalMap.clusters.length,
717
- total_bridges: globalMap.bridges.length,
718
- last_rebuilt: globalMap.last_rebuilt,
719
- clusters: globalMap.clusters,
720
- bridges: globalMap.bridges,
721
- }, null, 2),
722
- }],
723
- };
724
- }
725
- catch (err) {
726
- return {
727
- content: [{
728
- type: 'text',
729
- text: `CRBRO global_map error: ${err instanceof Error ? err.message : String(err)}`,
730
- }],
731
- isError: true,
732
- };
733
- }
734
- });
735
- // ═══════════════════════════════════════════════════════════════
736
- // TOOL 14: crbro_maintenance — Run maintenance
737
- // ═══════════════════════════════════════════════════════════════
738
- server.registerTool('crbro_maintenance', {
739
- title: 'Brain maintenance',
740
- description: 'Run brain maintenance: recalculate heat, prune weak synapses, check integrity and rebuild the search index. Returns a report (counts, integrity_issues, notes) and flags debts that never named a revisit trigger. dry_run:true reports without writing anything. Archiving cold neurons and purging miner boilerplate are OFF unless asked — on a mature brain most neurons look cold, and archived ones stop being searchable. For session close use crbro_consolidate, not this.',
846
+ server.registerTool('crbro_map', {
847
+ title: 'System map',
848
+ description: 'Read or replace a neuron\'s system map: ONE living document — where the system lives, what serves what, which pieces talk to each other, the traps that cost hours. crbro_inspect view=neuron already returns the map; use crbro_map to read it alone, or to rewrite it. Omit content to read (map:null if none); content replaces the previous version entirely (append-only maps rot); an empty string clears it. Read it before working on a system touched in past sessions; after changing the system rewrite the whole map. Reading never creates a neuron, writing does. Credentials are redacted on write. Atomic facts belong in crbro_learn — the map is the prose around them.',
741
849
  inputSchema: {
742
- dry_run: zod_1.z.boolean().optional().describe('true = report only: no heat recalc, archiving, purge, lock sweep, pruning or index rebuild. Counts, debts and integrity checks still run.'),
743
- archive: zod_1.z.boolean().optional().describe('Also move cold neurons (heat < 0.05, untouched 90+ days) out of the cortex. Off by default; run dry_run first and read archivable_neurons. Restore by moving the file from archives/ back into cortex/.'),
744
- purge_boilerplate: zod_1.z.boolean().optional().describe('Also delete contentless facts left by early miner versions ("Referenced in: file.md"). Off by default; every run reports how many there are. Neurons left empty are kept.'),
850
+ neuron: zod_1.z.string().describe('Neuron id or name, e.g. "project_octochat" or "OctoChat".'),
851
+ content: zod_1.z.string().optional().describe('The new map, replacing the old one whole; omit to read. Write the reference you will need next time: paths, ids, what-serves-what, gotchas. An empty string clears the map.'),
852
+ domain: zod_1.z.string().optional().describe('Domain if the neuron has to be created, e.g. "proyectos-web". Ignored when it exists.'),
745
853
  },
746
854
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
747
855
  }, async (args) => {
748
856
  try {
749
- const report = await maintenance.run(args.dry_run, { archive: args.archive, purgeBoilerplate: args.purge_boilerplate });
750
- return {
751
- content: [{
752
- type: 'text',
753
- text: JSON.stringify({
754
- mode: args.dry_run ? 'DRY RUN' : 'EXECUTED',
755
- ...report,
756
- }, null, 2),
757
- }],
758
- };
857
+ if (args.content === undefined) {
858
+ const neuron = await resolveNeuron(args.neuron);
859
+ if (!neuron) {
860
+ return textResult(`Neuron not found: "${args.neuron}". Use crbro_recall to find the right neuron id first.`);
861
+ }
862
+ if (!neuron.map || !neuron.map.text) {
863
+ return jsonResult({
864
+ neuron_id: neuron.id,
865
+ map: null,
866
+ message: `"${neuron.name}" has no system map yet. After working on this system, write one with crbro_map + content: where it lives, what serves what, the traps.`,
867
+ });
868
+ }
869
+ return jsonResult({
870
+ neuron_id: neuron.id,
871
+ updated: neuron.map.updated,
872
+ by: neuron.map.by,
873
+ map: neuron.map.text,
874
+ hint: 'If anything here proved wrong or the system changed, rewrite the map before closing the task.',
875
+ });
876
+ }
877
+ const result = await cortex.setMap(args.neuron, args.content, {
878
+ domain: args.domain,
879
+ });
880
+ if (!result.neuron) {
881
+ return textResult(`Could not store the map for "${args.neuron}".`);
882
+ }
883
+ return jsonResult({
884
+ neuron_id: result.neuron.id,
885
+ action: result.action,
886
+ updated: result.neuron.map?.updated,
887
+ length: args.content.length,
888
+ redacted: result.redacted.length > 0 ? result.redacted : undefined,
889
+ message: args.content.trim() === ''
890
+ ? `System map of "${result.neuron.name}" cleared.`
891
+ : `System map of "${result.neuron.name}" replaced. The previous version is gone - this one is now the reference.`,
892
+ });
759
893
  }
760
894
  catch (err) {
761
- return {
762
- content: [{
763
- type: 'text',
764
- text: `CRBRO maintenance error: ${err instanceof Error ? err.message : String(err)}`,
765
- }],
766
- isError: true,
767
- };
895
+ return errorResult('map', err);
768
896
  }
769
897
  });
770
898
  // ═══════════════════════════════════════════════════════════════
771
- // TOOL 15: crbro_consolidate — End-of-session consolidation
899
+ // TOOL 10: crbro_consolidate — End-of-session consolidation
772
900
  // ═══════════════════════════════════════════════════════════════
773
901
  server.registerTool('crbro_consolidate', {
774
902
  title: 'Consolidate the session',
775
- description: 'Call before the conversation ends whenever significant work was done. Persists pending knowledge and index writes, logs the session from summary (no separate crbro_session_log needed), 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; notes go out next time). Returns the session\'s real write counts — facts_saved, decisions_saved, topics_touched and per-space sync state. Not consolidating loses the session\'s knowledge.',
903
+ 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.',
776
904
  inputSchema: {
777
- summary: zod_1.z.string().describe('What was accomplished: concrete work, decisions, outcomes. Stored verbatim as the session log later sessions read.'),
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.'),
906
+ 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.'),
778
907
  },
779
908
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
780
909
  }, async (args) => {
781
910
  try {
782
- const result = await maintenance.consolidate(args.summary);
911
+ const result = await maintenance.consolidate(args.summary, { topicsTouched: args.topics_touched });
783
912
  // Flush any index writes still sitting in the debounce window, so a
784
913
  // session that ends right after a learn does not lose it.
785
914
  await searchEngine.flush();
786
915
  // Send the session's notes to the team before the lights go out.
787
916
  const compartidos = await (0, space_js_1.syncAll)(brain, cortex, 10_000);
788
- return {
789
- content: [{
790
- type: 'text',
791
- text: JSON.stringify({
792
- ...result,
793
- shared_spaces: compartidos.length > 0
794
- ? compartidos.map(c => ({ space: c.space, state: c.state, pushed: c.pushed }))
795
- : undefined,
796
- message: 'Session consolidated. Brain state persisted.',
797
- }, null, 2),
798
- }],
799
- };
917
+ return jsonResult({
918
+ ...result,
919
+ shared_spaces: compartidos.length > 0
920
+ ? compartidos.map(c => ({ space: c.space, state: c.state, pushed: c.pushed }))
921
+ : undefined,
922
+ message: 'Session consolidated. Brain state persisted.',
923
+ });
800
924
  }
801
925
  catch (err) {
802
- return {
803
- content: [{
804
- type: 'text',
805
- text: `CRBRO consolidate error: ${err instanceof Error ? err.message : String(err)}`,
806
- }],
807
- isError: true,
808
- };
926
+ return errorResult('consolidate', err);
809
927
  }
810
928
  });
811
929
  // ═══════════════════════════════════════════════════════════════
812
- // TOOL: crbro_mapThe living map of a system
930
+ // TOOL 11: crbro_maintenanceRun maintenance
813
931
  // ═══════════════════════════════════════════════════════════════
814
- server.registerTool('crbro_map', {
815
- title: 'System map',
816
- description: 'Read or replace a neuron\'s system map: ONE living document where the system lives, what serves what, which pieces talk to each other, the traps that cost hours. Read it before working on a system touched in past sessions; after changing the system, rewrite the whole map content replaces the previous version entirely (append-only maps rot). Omit content to read (map:null if none yet); an empty string clears it. Reading never creates a neuron, writing does. Credentials are redacted on write and listed in redacted. Atomic facts belong in crbro_learn the map is the prose reference around them.',
932
+ server.registerTool('crbro_maintenance', {
933
+ title: 'Brain maintenance',
934
+ description: 'Write: brain housekeepingrecalculate heat, prune weak synapses, check integrity, rebuild the search index. Returns a report (counts, integrity_issues, repairable, notes) and flags debts without a revisit trigger. dry_run:true writes nothing at all (the global map is computed live, never cached). Extras are OFF unless asked: archive cold neurons (on a mature brain most look cold, and archived ones stop being searchable), unarchive them back, purge_boilerplate left by early miners, repair what the integrity check found. For session close use crbro_consolidate; to only read the brain use crbro_inspect.',
817
935
  inputSchema: {
818
- neuron: zod_1.z.string().describe('Neuron id or name, e.g. "project_octochat" or "OctoChat".'),
819
- content: zod_1.z.string().optional().describe('The new map, replacing the old one whole; omit to read. Write the reference you will need next time: paths, ids, what-serves-what, gotchas. An empty string clears the map.'),
820
- domain: zod_1.z.string().optional().describe('Domain if the neuron has to be created, e.g. "proyectos-web". Ignored when it exists.'),
936
+ dry_run: zod_1.z.boolean().optional().describe('true = report only: no heat recalc, archiving, unarchiving, purge, repair, lock sweep, pruning or index rebuild, and no file written. Counts, debts and integrity checks still run.'),
937
+ archive: zod_1.z.boolean().optional().describe('Also move cold neurons (heat < 0.05, untouched 90+ days) out of the cortex into archives/. Off by default; run dry_run first and read archivable_neurons. Undo with unarchive.'),
938
+ unarchive: zod_1.z.union([zod_1.z.array(zod_1.z.string()), zod_1.z.literal('all')]).optional().describe('Move these neuron ids (or "all") from archives/ back into the cortex and reindex them. Off in dry_run; the report says archives_count and unarchived_neurons; unknown ids are listed in notes.'),
939
+ purge_boilerplate: zod_1.z.boolean().optional().describe('Also delete contentless facts left by early miner versions ("Referenced in: file.md"). Off by default; every run reports how many there are. Neurons left empty are kept.'),
940
+ repair: zod_1.z.boolean().optional().describe('Fix what the integrity check found: dangling connection ids, synapse files pointing at missing neurons, entry_dates/entry_status keys with no live entry, manifest counters. Off in dry_run; the report lists repairs[] one line each.'),
821
941
  },
822
942
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
823
943
  }, async (args) => {
824
944
  try {
825
- if (args.content === undefined) {
826
- const neuron = (await cortex.peek(args.neuron)) || (await cortex.findByName(args.neuron));
827
- if (!neuron) {
828
- return {
829
- content: [{
830
- type: 'text',
831
- text: `Neuron not found: "${args.neuron}". Use crbro_recall to find the right neuron_id first.`,
832
- }],
833
- };
834
- }
835
- if (!neuron.map || !neuron.map.text) {
836
- return {
837
- content: [{
838
- type: 'text',
839
- text: JSON.stringify({
840
- neuron_id: neuron.id,
841
- map: null,
842
- message: `"${neuron.name}" has no system map yet. After working on this system, write one with crbro_map + content: where it lives, what serves what, the traps.`,
843
- }, null, 2),
844
- }],
845
- };
846
- }
847
- return {
848
- content: [{
849
- type: 'text',
850
- text: JSON.stringify({
851
- neuron_id: neuron.id,
852
- updated: neuron.map.updated,
853
- by: neuron.map.by,
854
- map: neuron.map.text,
855
- hint: 'If anything here proved wrong or the system changed, rewrite the map before closing the task.',
856
- }, null, 2),
857
- }],
858
- };
859
- }
860
- const result = await cortex.setMap(args.neuron, args.content, {
861
- domain: args.domain,
945
+ const report = await maintenance.run(args.dry_run, {
946
+ archive: args.archive,
947
+ purgeBoilerplate: args.purge_boilerplate,
948
+ repair: args.repair,
949
+ unarchive: args.unarchive,
862
950
  });
863
- if (!result.neuron) {
864
- return {
865
- content: [{
866
- type: 'text',
867
- text: `Could not store the map for "${args.neuron}".`,
868
- }],
869
- };
870
- }
871
- return {
872
- content: [{
873
- type: 'text',
874
- text: JSON.stringify({
875
- neuron_id: result.neuron.id,
876
- action: result.action,
877
- updated: result.neuron.map?.updated,
878
- length: args.content.length,
879
- redacted: result.redacted.length > 0 ? result.redacted : undefined,
880
- message: args.content.trim() === ''
881
- ? `System map of "${result.neuron.name}" cleared.`
882
- : `System map of "${result.neuron.name}" replaced. The previous version is gone - this one is now the reference.`,
883
- }, null, 2),
884
- }],
885
- };
886
- }
887
- catch (err) {
888
- return {
889
- content: [{
890
- type: 'text',
891
- text: `CRBRO map error: ${err instanceof Error ? err.message : String(err)}`,
892
- }],
893
- isError: true,
894
- };
895
- }
896
- });
897
- // ═══════════════════════════════════════════════════════════════
898
- // TOOL 16: crbro_revise — Retire knowledge that stopped being true
899
- // ═══════════════════════════════════════════════════════════════
900
- server.registerTool('crbro_revise', {
901
- title: 'Revise facts',
902
- description: 'Mark stored facts as no longer current — the moment you find something saved is out of date or was wrong, since a memory that only appends keeps serving the old version with equal confidence. Superseded facts leave crbro_recall but stay in the neuron file, so the correction is auditable. Matches by fact id or exact text; anything reported in unmatched is STILL LIVE — fix and re-run. If a replacement fact exists, crbro_learn with supersedes does both in one call. To delete outright, use crbro_forget.',
903
- inputSchema: {
904
- neuron: zod_1.z.string().describe('Neuron id or name holding the facts, e.g. "project_octochat".'),
905
- facts: zod_1.z.array(zod_1.z.string()).describe('Facts to retire: their ids (from crbro_recall) or exact text (trimmed, case-insensitive). Already-retired facts never match.'),
906
- status: zod_1.z.enum(['superseded', 'retracted']).optional().describe('superseded = there is a newer truth (default); retracted = it was never true.'),
907
- note: zod_1.z.string().optional().describe('Why it stopped being true. The next reader will wonder.'),
908
- },
909
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
910
- }, async (args) => {
911
- try {
912
- const result = await cortex.revise(args.neuron, args.facts, {
913
- status: args.status,
914
- note: args.note,
951
+ return jsonResult({
952
+ mode: args.dry_run ? 'DRY RUN' : 'EXECUTED',
953
+ ...report,
915
954
  });
916
- if (!result.neuron) {
917
- return {
918
- content: [{
919
- type: 'text',
920
- text: `Neuron not found: "${args.neuron}". Use crbro_recall to find the right neuron_id first.`,
921
- }],
922
- };
923
- }
924
- return {
925
- content: [{
926
- type: 'text',
927
- text: JSON.stringify({
928
- neuron_id: result.neuron.id,
929
- revised: result.revised,
930
- status: args.status || 'superseded',
931
- unmatched: result.unmatched.length > 0 ? result.unmatched : undefined,
932
- message: result.revised > 0
933
- ? `${result.revised} fact(s) retired in "${result.neuron.name}". They no longer appear in recall.` +
934
- (result.unmatched.length > 0
935
- ? ` WARNING: ${result.unmatched.length} target(s) matched nothing and are still live.`
936
- : '')
937
- : 'Nothing matched. Pass the fact id from crbro_recall, or its exact text.',
938
- }, null, 2),
939
- }],
940
- };
941
955
  }
942
956
  catch (err) {
943
- return {
944
- content: [{
945
- type: 'text',
946
- text: `CRBRO revise error: ${err instanceof Error ? err.message : String(err)}`,
947
- }],
948
- isError: true,
949
- };
957
+ return errorResult('maintenance', err);
950
958
  }
951
959
  });
952
960
  // ═══════════════════════════════════════════════════════════════
953
- // TOOL 17: crbro_audit — What should not be in the brain
961
+ // TOOL 12: crbro_audit — What should not be in the brain
954
962
  // ═══════════════════════════════════════════════════════════════
955
963
  server.registerTool('crbro_audit', {
956
964
  title: 'Audit for credentials',
957
- description: 'Read-only scan of every field of every neuron (facts, decisions, patterns, preferences, errors, debts, system map) for credentials stored before the filter caught them — API keys, tokens, passwords. Reports where they sit and what kind, never the values. Findings are in the search index too, so recall can return them: remove with crbro_forget, then rotate the credential. Run it after upgrading and whenever a secret may have been pasted into a conversation.',
965
+ description: 'Read-only scan of every field of every neuron (facts, decisions, patterns, preferences, errors, debts, system map) and of every session log for credentials stored before the filter caught them — API keys, tokens, passwords. Reports where they sit and what kind, never the values. Findings are in the search index too, so recall can return them: remove with crbro_forget (facts for entries, session for a day log), then rotate the credential. Run it after upgrading and whenever a secret may have been pasted into a conversation. crbro_inspect shows content; this only judges it.',
958
966
  inputSchema: {},
959
967
  outputSchema: {
960
968
  neurons_affected: zod_1.z.number(),
@@ -964,6 +972,10 @@ function createServer() {
964
972
  facts: zod_1.z.number(), decisions: zod_1.z.number(), patterns: zod_1.z.number(), preferences: zod_1.z.number(),
965
973
  errors: zod_1.z.number(), debts: zod_1.z.number(), map: zod_1.z.number(),
966
974
  }).loose()),
975
+ sessions_affected: zod_1.z.number().optional(),
976
+ session_findings: zod_1.z.array(zod_1.z.object({
977
+ session_id: zod_1.z.string(), date: zod_1.z.string(), kinds: zod_1.z.array(zod_1.z.string()),
978
+ }).loose()).optional(),
967
979
  message: zod_1.z.string(),
968
980
  note: zod_1.z.string().optional(),
969
981
  },
@@ -971,16 +983,25 @@ function createServer() {
971
983
  }, async () => {
972
984
  try {
973
985
  const hallazgos = await cortex.auditSecrets();
986
+ const sesiones = await hippocampus.auditSecrets();
974
987
  const total = hallazgos.reduce((n, h) => n + h.facts + h.decisions + h.patterns + h.preferences + h.errors + h.debts + h.map, 0);
988
+ const clean = hallazgos.length === 0 && sesiones.length === 0;
975
989
  const payload = {
976
990
  neurons_affected: hallazgos.length,
977
991
  facts_affected: total,
978
992
  findings: hallazgos,
979
- message: hallazgos.length === 0
980
- ? 'No credentials found in the brain.'
981
- : `${total} entr(y/ies) across ${hallazgos.length} neuron(s) contain something that looks like a credential. ` +
982
- 'They are also inside the search index, so recall can return them. ' +
983
- 'Remove them with crbro_forget, then rotate the credentials — assume they are compromised.',
993
+ sessions_affected: sesiones.length,
994
+ session_findings: sesiones,
995
+ message: clean
996
+ ? 'No credentials found in the brain or in the session logs.'
997
+ : (hallazgos.length > 0
998
+ ? `${total} entr(y/ies) across ${hallazgos.length} neuron(s) contain something that looks like a credential. ` +
999
+ 'They are also inside the search index, so recall can return them. Remove them with crbro_forget facts. '
1000
+ : '') +
1001
+ (sesiones.length > 0
1002
+ ? `${sesiones.length} session log(s) contain something that looks like a credential; remove each with crbro_forget session. `
1003
+ : '') +
1004
+ 'Then rotate the credentials — assume they are compromised.',
984
1005
  note: 'Values are never shown here, by design.',
985
1006
  };
986
1007
  return {
@@ -989,21 +1010,15 @@ function createServer() {
989
1010
  };
990
1011
  }
991
1012
  catch (err) {
992
- return {
993
- content: [{
994
- type: 'text',
995
- text: `CRBRO audit error: ${err instanceof Error ? err.message : String(err)}`,
996
- }],
997
- isError: true,
998
- };
1013
+ return errorResult('audit', err);
999
1014
  }
1000
1015
  });
1001
1016
  // ═══════════════════════════════════════════════════════════════
1002
- // TOOL 22: crbro_secret — Credentials, brokered to the OS keychain
1017
+ // TOOL 13: crbro_secret — Credentials, brokered to the OS keychain
1003
1018
  // ═══════════════════════════════════════════════════════════════
1004
1019
  server.registerTool('crbro_secret', {
1005
1020
  title: 'Keychain secret',
1006
- description: 'Credentials, brokered to the operating system\'s own keychain (macOS Keychain, Linux Secret Service, Windows DPAPI): CRBRO keeps no copy and invents no crypto, and no sync or team space can reach the store. The moment the user hands you a credential: set it here, then record only the NAME with crbro_learn. get returns the value for the task at hand — an environment variable of the same name wins, a missing secret returns found:false, not an error — and it must not be printed back unless the user asked for that secret. list returns names only; remove deletes one; status says which store this machine has. Names are SCREAMING_SNAKE_CASE; set updates an existing name in place and rejects empty values. A machine with no store is a normal status answer, not a failure — environment variables still work.',
1021
+ description: 'Read or write credentials in the operating system\'s keychain (macOS Keychain, Linux Secret Service, Windows DPAPI): CRBRO keeps no copy, invents no crypto, and no sync or team space can reach the store. When the user hands you a credential, set it here, then record only the NAME with crbro_learn. get returns the value for the task at hand — an environment variable of the same name wins; a missing secret returns found:false, not an error — never print it back unless the user asked. list returns names only; remove deletes one; status says which store this machine has (none is a normal answer; env vars still work). Names are SCREAMING_SNAKE_CASE; set updates in place and rejects empty values.',
1007
1022
  inputSchema: {
1008
1023
  action: zod_1.z.enum(['get', 'set', 'list', 'remove', 'status'])
1009
1024
  .describe('get = read one, set = store or update one, list = names only, remove = delete one, status = which keychain this machine offers, or why none.'),
@@ -1068,84 +1083,26 @@ function createServer() {
1068
1083
  note: 'Use it for the task at hand. Do not repeat it back to the user and do not write it into any file.',
1069
1084
  };
1070
1085
  }
1071
- return {
1072
- content: [{
1073
- type: 'text',
1074
- text: JSON.stringify(payload, null, 2),
1075
- }],
1076
- };
1086
+ return jsonResult(payload);
1077
1087
  }
1078
1088
  catch (err) {
1079
- return {
1080
- content: [{
1081
- type: 'text',
1082
- text: err instanceof keychain_js_1.KeychainUnavailable
1083
- ? `No credential store available: ${err.message}`
1084
- : `CRBRO secret error: ${err instanceof Error ? err.message : String(err)}`,
1085
- }],
1086
- isError: true,
1087
- };
1089
+ return textResult(err instanceof keychain_js_1.KeychainUnavailable
1090
+ ? `No credential store available: ${err.message}`
1091
+ : `CRBRO secret error: ${err instanceof Error ? err.message : String(err)}`, true);
1088
1092
  }
1089
1093
  });
1090
1094
  // ═══════════════════════════════════════════════════════════════
1091
- // TOOL 18: crbro_forgetRemove knowledge for good
1092
- // ═══════════════════════════════════════════════════════════════
1093
- server.registerTool('crbro_forget', {
1094
- title: 'Forget for good',
1095
- description: 'Permanently remove entries from a neuron — for what must not exist at all: a credential, personal data, something stored by mistake. Matches facts, decisions, patterns, preferences, errors and debts by id or exact text (trimmed, case-insensitive), and the system map by its exact full text. Destructive, with a net: the whole neuron is copied to .quarantine/ first (backup path returned) and the search index is updated; nothing matched returns removed:0, not an error. On shared neurons the removal travels. For knowledge that merely stopped being true use crbro_revise, which keeps the history. Tell the user what will be removed and get their agreement first; a removed credential must still be rotated — it existed on disk and in the index.',
1096
- inputSchema: {
1097
- neuron: zod_1.z.string().describe('Neuron id or name holding the entries.'),
1098
- facts: zod_1.z.array(zod_1.z.string()).describe('Fact ids, or the exact text of a fact, decision, pattern, preference, error or debt. The exact full text of the map removes the map.'),
1099
- },
1100
- annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
1101
- }, async (args) => {
1102
- try {
1103
- const r = await cortex.forget(args.neuron, args.facts);
1104
- if (!r.neuron_id) {
1105
- return {
1106
- content: [{
1107
- type: 'text',
1108
- text: `Neuron not found: "${args.neuron}". Use crbro_recall or crbro_audit to find the right one.`,
1109
- }],
1110
- };
1111
- }
1112
- return {
1113
- content: [{
1114
- type: 'text',
1115
- text: JSON.stringify({
1116
- neuron_id: r.neuron_id,
1117
- removed: r.removed,
1118
- backup: r.backup,
1119
- message: r.removed > 0
1120
- ? `${r.removed} fact(s) removed from "${r.neuron_id}". A copy of the neuron as it was is in ${r.backup}. ` +
1121
- 'If any of them was a credential, rotate it: it existed on disk and in the index.'
1122
- : 'Nothing matched. Pass the fact id from crbro_recall, or its exact text.',
1123
- }, null, 2),
1124
- }],
1125
- };
1126
- }
1127
- catch (err) {
1128
- return {
1129
- content: [{
1130
- type: 'text',
1131
- text: `CRBRO forget error: ${err instanceof Error ? err.message : String(err)}`,
1132
- }],
1133
- isError: true,
1134
- };
1135
- }
1136
- });
1137
- // ═══════════════════════════════════════════════════════════════
1138
- // TOOL 19: crbro_space — Join a team's shared memory
1095
+ // TOOL 14: crbro_spaceA team's shared memory
1139
1096
  // ═══════════════════════════════════════════════════════════════
1140
1097
  server.registerTool('crbro_space', {
1141
1098
  title: 'Team space',
1142
- description: 'Shared memory with teammates. A space is a private git repository holding notes about the projects you choose to share nothing else from your brain goes near it. One person runs create with the repository URL; everyone else runs join with the same URL; afterwards it syncs by itself at boot and consolidate. Joining shares nothing by itself: put each project in with crbro_share. status lists your identity and spaces. create and join need name, remote and author, and reply ok:false with the reason when git is missing or the push/clone fails.',
1099
+ description: 'Read or write team spaces — shared memory with teammates: a private git repository holding notes about the projects you choose to share; nothing else from your brain goes near it. create starts one (name, remote, author); join clones one a teammate created; status reads your identity and spaces; sync exchanges notes now — the manual form of what crbro_boot and crbro_consolidate do alone, useful right after crbro_share (offline is a normal answer, not a failure); leave pushes pending notes, deletes the local copy and stops following its neurons (neurons untouched). Joining shares nothing: put each project in with crbro_share. create and join reply ok:false with the reason on failure.',
1143
1100
  inputSchema: {
1144
- action: zod_1.z.enum(['create', 'join', 'status']).describe('create = start a new space and push it, join = clone one a teammate created, status = your identity and the spaces you are in.'),
1145
- name: zod_1.z.string().optional().describe('Short name, e.g. "equipo" — the same on everyone\'s machine. Required for create and join.'),
1101
+ action: zod_1.z.enum(['create', 'join', 'status', 'sync', 'leave']).describe('create = start a new space and push it; join = clone one a teammate created; status = your identity and spaces; sync = exchange notes now; leave = sync, then forget the space locally.'),
1102
+ name: zod_1.z.string().optional().describe('Short name, e.g. "equipo" — the same on everyone\'s machine. Required for create, join and leave; optional for sync (omit = every space); ignored for status.'),
1146
1103
  remote: zod_1.z.string().optional().describe('Git URL of a private repository — EMPTY for create, the same URL the creator used for join. E.g. git@github.com:acme/team-memory.git.'),
1147
1104
  author: zod_1.z.string().optional().describe('How your notes are signed, e.g. "ana". Lowercase, no spaces. Required for create and join.'),
1148
- branch: zod_1.z.string().optional().describe('Branch to use (default "main").'),
1105
+ branch: zod_1.z.string().optional().describe('Branch to use (default "main"). create and join only.'),
1149
1106
  },
1150
1107
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1151
1108
  }, async (args) => {
@@ -1159,151 +1116,124 @@ function createServer() {
1159
1116
  if (cfg)
1160
1117
  detalle.push({ name: cfg.name, created_by: cfg.created_by, branch: cfg.branch });
1161
1118
  }
1162
- return {
1163
- content: [{
1164
- type: 'text',
1165
- text: JSON.stringify({
1166
- you_are: id.author,
1167
- device: id.device,
1168
- spaces: detalle,
1169
- message: detalle.length === 0
1170
- ? 'You are not in any shared space. Use action "create" to start one, or "join" if a teammate already did.'
1171
- : `In ${detalle.length} space(s). They sync automatically at boot and on consolidate.`,
1172
- }, null, 2),
1173
- }],
1174
- };
1119
+ return jsonResult({
1120
+ you_are: id.author,
1121
+ device: id.device,
1122
+ spaces: detalle,
1123
+ message: detalle.length === 0
1124
+ ? 'You are not in any shared space. Use action "create" to start one, or "join" if a teammate already did.'
1125
+ : `In ${detalle.length} space(s). They sync automatically at boot and on consolidate; action "sync" does it now.`,
1126
+ });
1127
+ }
1128
+ if (args.action === 'sync') {
1129
+ const informes = args.name
1130
+ ? [await (0, space_js_1.syncSpaceNow)(brain, cortex, args.name, 30_000)]
1131
+ : await (0, space_js_1.syncAll)(brain, cortex, 30_000);
1132
+ if (informes.length === 0) {
1133
+ return textResult('You are not in any shared space yet. Use action "create" or "join" first.');
1134
+ }
1135
+ return jsonResult({
1136
+ spaces: informes.map(i => ({
1137
+ space: i.space,
1138
+ state: i.state,
1139
+ neurons_updated: i.neurons_touched,
1140
+ new_facts: i.merged.reduce((n, m) => n + m.facts_added, 0),
1141
+ retracted: i.merged.reduce((n, m) => n + m.facts_retracted, 0),
1142
+ teammates_seen: [...new Set(i.merged.flatMap(m => m.authors))],
1143
+ divergence: i.merged.flatMap(m => m.divergence),
1144
+ pushed: i.pushed,
1145
+ message: i.message,
1146
+ })),
1147
+ });
1148
+ }
1149
+ if (args.action === 'leave') {
1150
+ if (!args.name) {
1151
+ return textResult('name is required for leave: which space to leave.');
1152
+ }
1153
+ const r = await (0, space_js_1.leaveSpace)(brain, cortex, args.name);
1154
+ return jsonResult({
1155
+ ...r,
1156
+ next: r.ok
1157
+ ? 'Your neurons are intact and no longer followed; the remote repository was not touched. Re-join later with action "join" and the same URL.'
1158
+ : undefined,
1159
+ });
1175
1160
  }
1176
1161
  if (!args.name || !args.remote) {
1177
- return {
1178
- content: [{ type: 'text', text: 'Both name and remote are required for create and join.' }],
1179
- };
1162
+ return textResult('Both name and remote are required for create and join.');
1180
1163
  }
1181
1164
  if (!args.author) {
1182
- return {
1183
- content: [{ type: 'text', text: 'Pass author so your notes carry your name, e.g. author: "ana".' }],
1184
- };
1165
+ return textResult('Pass author so your notes carry your name, e.g. author: "ana".');
1185
1166
  }
1186
1167
  const r = args.action === 'create'
1187
1168
  ? await (0, space_js_1.createSpace)(brain, args.name, args.remote, args.author, args.branch || 'main')
1188
1169
  : await (0, space_js_1.joinSpace)(brain, args.name, args.remote, args.author, args.branch || 'main');
1189
- return {
1190
- content: [{
1191
- type: 'text',
1192
- text: JSON.stringify({
1193
- ok: r.ok,
1194
- message: r.message,
1195
- detail: r.detail,
1196
- next: r.ok && args.action === 'create'
1197
- ? 'Now share a project into it with crbro_share, and invite your teammates to the repository.'
1198
- : r.ok
1199
- ? 'Run crbro_sync to pull in what the others already know.'
1200
- : undefined,
1201
- }, null, 2),
1202
- }],
1203
- };
1170
+ return jsonResult({
1171
+ ok: r.ok,
1172
+ message: r.message,
1173
+ detail: r.detail,
1174
+ next: r.ok && args.action === 'create'
1175
+ ? 'Now share a project into it with crbro_share, and invite your teammates to the repository.'
1176
+ : r.ok
1177
+ ? 'Run crbro_space action=sync to pull in what the others already know.'
1178
+ : undefined,
1179
+ });
1204
1180
  }
1205
1181
  catch (err) {
1206
- return {
1207
- content: [{ type: 'text', text: `CRBRO space error: ${err instanceof Error ? err.message : String(err)}` }],
1208
- isError: true,
1209
- };
1182
+ return errorResult('space', err);
1210
1183
  }
1211
1184
  });
1212
1185
  // ═══════════════════════════════════════════════════════════════
1213
- // TOOL 20: crbro_share — Put one project into a space
1186
+ // TOOL 15: crbro_share — Put one project into a space, or take it out
1214
1187
  // ═══════════════════════════════════════════════════════════════
1188
+ const shareSchema = zod_1.z.object({
1189
+ neuron: zod_1.z.string().describe('Neuron id or name to share or unshare.'),
1190
+ space: zod_1.z.string().optional().describe('Space name, as created or joined with crbro_space. Required unless unshare:true.'),
1191
+ confirm: zod_1.z.string().optional().describe('The confirm_token from the dry run — returned only when no credential was found. Omit the first time. Ignored with unshare.'),
1192
+ unshare: zod_1.z.boolean().optional().describe('Stop following `neuron` in its space: no more notes go out, the next sync ignores it, and the neuron can then be forgotten. Already-sent notes stay in the remote and in teammates\' brains. space and confirm are ignored in this mode.'),
1193
+ }).superRefine((v, ctx) => {
1194
+ if (!v.unshare && !v.space) {
1195
+ ctx.addIssue({ code: 'custom', path: ['space'], message: 'space is required unless unshare:true.' });
1196
+ }
1197
+ });
1215
1198
  server.registerTool('crbro_share', {
1216
1199
  title: 'Share a project',
1217
- description: 'Share one neuron with a team space. Always call it without confirm first: the dry run reports exactly what would be sent — ops_to_emit, skipped_preferences (preferences never leave this machine) — and refuses outright if it finds a credential (it will not redact and send anyway: crbro_forget it and rotate it). Show the user that report and get their agreement, then call again with the confirm_token; a stale token is refused. Entries go out on the next crbro_sync or consolidate, and from then on everything learned about that project flows to the team. Sharing cannot be undone.',
1218
- inputSchema: {
1219
- neuron: zod_1.z.string().describe('Neuron id or name to share.'),
1220
- space: zod_1.z.string().describe('Space name, as created or joined with crbro_space.'),
1221
- confirm: zod_1.z.string().optional().describe('The confirm_token from the dry run — returned only when no credential was found. Omit the first time.'),
1222
- },
1200
+ description: 'Write: put one neuron into a team space, or take it out with unshare. Call it without confirm first: the dry run reports what would be sent — ops_to_emit, skipped_preferences (preferences never leave this machine) — and refuses outright if it finds a credential (crbro_forget it and rotate it). Show the user, get agreement, call again with the confirm token; a stale token is refused. Entries go out on the next crbro_space action=sync or consolidate, and from then on everything learned about that project flows to the team. unshare stops future notes; what was already sent stays in the remote and in teammates\' brains. Spaces are managed with crbro_space.',
1201
+ inputSchema: shareSchema,
1223
1202
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1224
1203
  }, async (args) => {
1225
1204
  try {
1205
+ if (args.unshare) {
1206
+ const r = await (0, space_js_1.unshareNeuron)(brain, cortex, args.neuron);
1207
+ return jsonResult({
1208
+ ...r,
1209
+ next: r.ok
1210
+ ? 'No more notes go out for this neuron. What was already sent stays in the remote and in teammates\' brains. It can now be forgotten or merged with crbro_forget.'
1211
+ : undefined,
1212
+ });
1213
+ }
1214
+ const space = args.space;
1226
1215
  if (!args.confirm) {
1227
- const prep = await (0, space_js_1.prepareShare)(brain, cortex, args.neuron, args.space);
1216
+ const prep = await (0, space_js_1.prepareShare)(brain, cortex, args.neuron, space);
1228
1217
  if ('error' in prep) {
1229
- return { content: [{ type: 'text', text: prep.error }] };
1218
+ return textResult(prep.error);
1230
1219
  }
1231
- return {
1232
- content: [{
1233
- type: 'text',
1234
- text: JSON.stringify({
1235
- ...prep,
1236
- message: prep.blocked.length > 0
1237
- ? `Refused: ${prep.blocked.length} credential(s) found in this neuron. Nothing was sent. ` +
1238
- 'Remove them with crbro_forget and rotate them, then try again.'
1239
- : `Ready to share ${prep.ops_to_emit} entries. ${prep.skipped_preferences} preference(s) will NOT be sent — ` +
1240
- 'preferences never leave this machine. Show the user what is about to be shared, then call again with the confirm token.',
1241
- }, null, 2),
1242
- }],
1243
- };
1244
- }
1245
- const r = await (0, space_js_1.commitShare)(brain, cortex, args.neuron, args.space, args.confirm);
1246
- return {
1247
- content: [{
1248
- type: 'text',
1249
- text: JSON.stringify({
1250
- ...r,
1251
- next: r.ok ? 'Run crbro_sync to send it now, or let it go out on the next consolidate.' : undefined,
1252
- }, null, 2),
1253
- }],
1254
- };
1255
- }
1256
- catch (err) {
1257
- return {
1258
- content: [{ type: 'text', text: `CRBRO share error: ${err instanceof Error ? err.message : String(err)}` }],
1259
- isError: true,
1260
- };
1261
- }
1262
- });
1263
- // ═══════════════════════════════════════════════════════════════
1264
- // TOOL 21: crbro_sync — Exchange notes with the team now
1265
- // ═══════════════════════════════════════════════════════════════
1266
- server.registerTool('crbro_sync', {
1267
- title: 'Sync with the team',
1268
- description: 'Exchange notes with the team right now instead of waiting for the next boot or consolidate, which sync on their own — use it mid-session, e.g. right after crbro_share. Pulls what everyone else recorded and pushes yours, reporting per space: state, neurons updated, new facts, teammates seen, pushed. Being offline is a normal answer, not a failure: local memory works either way and pending notes go out next time.',
1269
- inputSchema: {
1270
- space: zod_1.z.string().optional().describe('One space; state comes back "not_joined" if you are not in it. Omit to sync all of them.'),
1271
- },
1272
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
1273
- }, async (args) => {
1274
- try {
1275
- const informes = args.space
1276
- ? [await (0, space_js_1.syncSpaceNow)(brain, cortex, args.space, 30_000)]
1277
- : await (0, space_js_1.syncAll)(brain, cortex, 30_000);
1278
- if (informes.length === 0) {
1279
- return {
1280
- content: [{ type: 'text', text: 'You are not in any shared space yet. Use crbro_space to create or join one.' }],
1281
- };
1220
+ return jsonResult({
1221
+ ...prep,
1222
+ message: prep.blocked.length > 0
1223
+ ? `Refused: ${prep.blocked.length} credential(s) found in this neuron. Nothing was sent. ` +
1224
+ 'Remove them with crbro_forget and rotate them, then try again.'
1225
+ : `Ready to share ${prep.ops_to_emit} entries. ${prep.skipped_preferences} preference(s) will NOT be sent — ` +
1226
+ 'preferences never leave this machine. Show the user what is about to be shared, then call again with the confirm token.',
1227
+ });
1282
1228
  }
1283
- return {
1284
- content: [{
1285
- type: 'text',
1286
- text: JSON.stringify({
1287
- spaces: informes.map(i => ({
1288
- space: i.space,
1289
- state: i.state,
1290
- neurons_updated: i.neurons_touched,
1291
- new_facts: i.merged.reduce((n, m) => n + m.facts_added, 0),
1292
- retracted: i.merged.reduce((n, m) => n + m.facts_retracted, 0),
1293
- teammates_seen: [...new Set(i.merged.flatMap(m => m.authors))],
1294
- divergence: i.merged.flatMap(m => m.divergence),
1295
- pushed: i.pushed,
1296
- message: i.message,
1297
- })),
1298
- }, null, 2),
1299
- }],
1300
- };
1229
+ const r = await (0, space_js_1.commitShare)(brain, cortex, args.neuron, space, args.confirm);
1230
+ return jsonResult({
1231
+ ...r,
1232
+ next: r.ok ? 'Run crbro_space action=sync to send it now, or let it go out on the next consolidate. unshare:true stops future notes.' : undefined,
1233
+ });
1301
1234
  }
1302
1235
  catch (err) {
1303
- return {
1304
- content: [{ type: 'text', text: `CRBRO sync error: ${err instanceof Error ? err.message : String(err)}` }],
1305
- isError: true,
1306
- };
1236
+ return errorResult('share', err);
1307
1237
  }
1308
1238
  });
1309
1239
  return server;