crbro-memory 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +42 -3
  2. package/bin/crbro.mjs +234 -0
  3. package/dist/daemon/daemon.d.ts +25 -0
  4. package/dist/daemon/daemon.d.ts.map +1 -0
  5. package/dist/daemon/daemon.js +387 -0
  6. package/dist/daemon/daemon.js.map +1 -0
  7. package/dist/daemon/endpoint.d.ts +65 -0
  8. package/dist/daemon/endpoint.d.ts.map +1 -0
  9. package/dist/daemon/endpoint.js +308 -0
  10. package/dist/daemon/endpoint.js.map +1 -0
  11. package/dist/daemon/lines.d.ts +55 -0
  12. package/dist/daemon/lines.d.ts.map +1 -0
  13. package/dist/daemon/lines.js +142 -0
  14. package/dist/daemon/lines.js.map +1 -0
  15. package/dist/daemon/proxy.d.ts +55 -0
  16. package/dist/daemon/proxy.d.ts.map +1 -0
  17. package/dist/daemon/proxy.js +412 -0
  18. package/dist/daemon/proxy.js.map +1 -0
  19. package/dist/engine/backup.d.ts +68 -0
  20. package/dist/engine/backup.d.ts.map +1 -0
  21. package/dist/engine/backup.js +224 -0
  22. package/dist/engine/backup.js.map +1 -0
  23. package/dist/engine/brain.d.ts.map +1 -1
  24. package/dist/engine/brain.js +6 -2
  25. package/dist/engine/brain.js.map +1 -1
  26. package/dist/engine/cortex.d.ts +76 -2
  27. package/dist/engine/cortex.d.ts.map +1 -1
  28. package/dist/engine/cortex.js +169 -9
  29. package/dist/engine/cortex.js.map +1 -1
  30. package/dist/engine/dates.d.ts +28 -0
  31. package/dist/engine/dates.d.ts.map +1 -0
  32. package/dist/engine/dates.js +121 -0
  33. package/dist/engine/dates.js.map +1 -0
  34. package/dist/engine/maintenance.d.ts +85 -0
  35. package/dist/engine/maintenance.d.ts.map +1 -1
  36. package/dist/engine/maintenance.js +340 -0
  37. package/dist/engine/maintenance.js.map +1 -1
  38. package/dist/engine/triggers.d.ts +46 -0
  39. package/dist/engine/triggers.d.ts.map +1 -0
  40. package/dist/engine/triggers.js +198 -0
  41. package/dist/engine/triggers.js.map +1 -0
  42. package/dist/index.js +33 -5
  43. package/dist/index.js.map +1 -1
  44. package/dist/search/index.d.ts +56 -3
  45. package/dist/search/index.d.ts.map +1 -1
  46. package/dist/search/index.js +244 -12
  47. package/dist/search/index.js.map +1 -1
  48. package/dist/server.d.ts +32 -1
  49. package/dist/server.d.ts.map +1 -1
  50. package/dist/server.js +151 -19
  51. package/dist/server.js.map +1 -1
  52. package/hooks/crbro-guard.mjs +155 -0
  53. package/package.json +1 -1
package/dist/server.d.ts CHANGED
@@ -1,4 +1,18 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { Brain } from './engine/brain.js';
3
+ import { Cortex } from './engine/cortex.js';
4
+ import { Synapses } from './engine/synapses.js';
5
+ import { HeatEngine } from './engine/heat.js';
6
+ import { Hippocampus } from './engine/hippocampus.js';
7
+ import { Prefrontal } from './engine/prefrontal.js';
8
+ import { SearchEngine } from './search/index.js';
9
+ /**
10
+ * recall's `since`: a day ("2026-09-01") or a span back from today ("30d",
11
+ * "2w", "6m"). Returns the day, or null when it is neither — the caller is
12
+ * told, never silently given an unfiltered answer.
13
+ */
14
+ export declare function resolveSince(raw: string, nowMs?: number): string | null;
15
+ import { Maintenance } from './engine/maintenance.js';
2
16
  /**
3
17
  * Old tool name → how to do the same thing on the 2.0 surface. Served by
4
18
  * crbro_boot as `retired_tools` for the whole 2.x line, and asserted by the
@@ -6,5 +20,22 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
20
  * string the model receives.
7
21
  */
8
22
  export declare const RETIRED_TOOLS: Readonly<Record<string, string>>;
9
- export declare function createServer(): McpServer;
23
+ /** Everything that owns the brain in a process. One set per process, however many servers speak for it. */
24
+ export interface Engines {
25
+ brain: Brain;
26
+ cortex: Cortex;
27
+ synapses: Synapses;
28
+ heatEngine: HeatEngine;
29
+ hippocampus: Hippocampus;
30
+ prefrontal: Prefrontal;
31
+ searchEngine: SearchEngine;
32
+ maintenance: Maintenance;
33
+ }
34
+ /**
35
+ * Build the engines and wire them to each other. Separate from createServer
36
+ * since 2.5: the daemon builds them once and gives every connected client its
37
+ * own McpServer over the same index, the same model and the same writer.
38
+ */
39
+ export declare function createEngines(): Engines;
40
+ export declare function createServer(shared?: Engines): McpServer;
10
41
  //# sourceMappingURL=server.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAkBA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AA+BpE;;;;;GAKG;AACH,eAAO,MAAM,aAAa,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAU1D,CAAC;AAuCF,wBAAgB,YAAY,IAAI,SAAS,CAyjDxC"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAkBA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAKpE,OAAO,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AAC1C,OAAO,EAAE,MAAM,EAAgB,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAChD,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAC9C,OAAO,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AACtD,OAAO,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AACpD,OAAO,EAAE,YAAY,EAAgB,MAAM,mBAAmB,CAAC;AAa/D;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,GAAE,MAAmB,GAAG,MAAM,GAAG,IAAI,CAUnF;AACD,OAAO,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAatD;;;;;GAKG;AACH,eAAO,MAAM,aAAa,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAU1D,CAAC;AAuCF,2GAA2G;AAC3G,MAAM,WAAW,OAAO;IACtB,KAAK,EAAE,KAAK,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,QAAQ,CAAC;IACnB,UAAU,EAAE,UAAU,CAAC;IACvB,WAAW,EAAE,WAAW,CAAC;IACzB,UAAU,EAAE,UAAU,CAAC;IACvB,YAAY,EAAE,YAAY,CAAC;IAC3B,WAAW,EAAE,WAAW,CAAC;CAC1B;AAED;;;;GAIG;AACH,wBAAgB,aAAa,IAAI,OAAO,CA2BvC;AAED,wBAAgB,YAAY,CAAC,MAAM,CAAC,EAAE,OAAO,GAAG,SAAS,CAuoDxD"}
package/dist/server.js CHANGED
@@ -18,6 +18,8 @@
18
18
  // with an old habit finds the replacement without a round trip.
19
19
  Object.defineProperty(exports, "__esModule", { value: true });
20
20
  exports.RETIRED_TOOLS = void 0;
21
+ exports.resolveSince = resolveSince;
22
+ exports.createEngines = createEngines;
21
23
  exports.createServer = createServer;
22
24
  const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
23
25
  const zod_1 = require("zod");
@@ -33,8 +35,29 @@ const index_js_1 = require("./search/index.js");
33
35
  const semantic_js_1 = require("./search/semantic.js");
34
36
  const budget_js_1 = require("./utils/budget.js");
35
37
  const secrets_js_1 = require("./engine/secrets.js");
38
+ const backup_js_1 = require("./engine/backup.js");
39
+ const triggers_js_1 = require("./engine/triggers.js");
40
+ /** A neuron this size with no summary is worth two lines from whoever is closing the session. */
41
+ const SUMMARY_NUDGE_MIN_ENTRIES = 25;
36
42
  /** Listings carry the day, not the millisecond: "2026-09-07" says what "2026-09-07T14:02:11.483Z" says, in a third of the tokens. Full stamps stay on single-entry reads. */
37
43
  const dia = (iso) => (typeof iso === 'string' && iso.length >= 10 ? iso.slice(0, 10) : iso);
44
+ /**
45
+ * recall's `since`: a day ("2026-09-01") or a span back from today ("30d",
46
+ * "2w", "6m"). Returns the day, or null when it is neither — the caller is
47
+ * told, never silently given an unfiltered answer.
48
+ */
49
+ function resolveSince(raw, nowMs = Date.now()) {
50
+ const v = raw.trim().toLowerCase();
51
+ const span = /^(\d{1,4})\s*([dwm])$/.exec(v);
52
+ if (span) {
53
+ const n = Number(span[1]);
54
+ const days = span[2] === 'd' ? n : span[2] === 'w' ? n * 7 : n * 30;
55
+ return new Date(nowMs - days * 86_400_000).toISOString().slice(0, 10);
56
+ }
57
+ if (/^\d{4}-\d{2}-\d{2}/.test(v) && Number.isFinite(Date.parse(v.slice(0, 10))))
58
+ return v.slice(0, 10);
59
+ return null;
60
+ }
38
61
  const maintenance_js_1 = require("./engine/maintenance.js");
39
62
  const space_js_1 = require("./sync/space.js");
40
63
  const keychain_js_1 = require("./engine/keychain.js");
@@ -89,21 +112,12 @@ function errorResult(where, err) {
89
112
  }
90
113
  const NEURON_TYPES = ['project', 'tech', 'lang', 'person', 'domain', 'process', 'protocol'];
91
114
  const INSPECT_VIEWS = ['status', 'neuron', 'neurons', 'sessions', 'global_map'];
92
- function createServer() {
93
- // Served at initialize for the clients that read it (Claude Desktop does
94
- // not, as of anthropics/claude-code#43749; there the tool descriptions
95
- // carry the same message). It is the one place to say how the memory is
96
- // meant to be used, before any tool is called.
97
- const server = new mcp_js_1.McpServer({
98
- name: 'crbro-memory',
99
- version: runningVersion(),
100
- }, {
101
- instructions: 'CRBRO is this user\'s persistent memory, kept on their own machine. Start every conversation with crbro_boot: it loads what earlier sessions left — protocols to follow, open items, hot topics. ' +
102
- 'Before answering anything about the user, their projects, preferences, decisions or past work, call crbro_recall: the answer is usually stored, and making them repeat it is the failure this memory exists to prevent. ' +
103
- 'Questions about CRBRO itself (version, counts, whether semantic recall is on) are crbro_inspect view=status. Read one entry, not a whole neuron: view=neuron gives an index, entries=[ids] the text. ' +
104
- 'Save with crbro_learn as you go, and close with crbro_consolidate before the conversation ends.',
105
- });
106
- // ─── Initialize engines ──────────────────────────────────────
115
+ /**
116
+ * Build the engines and wire them to each other. Separate from createServer
117
+ * since 2.5: the daemon builds them once and gives every connected client its
118
+ * own McpServer over the same index, the same model and the same writer.
119
+ */
120
+ function createEngines() {
107
121
  const brain = new brain_js_1.Brain();
108
122
  const cortex = new cortex_js_1.Cortex(brain);
109
123
  const synapses = new synapses_js_1.Synapses(brain);
@@ -125,6 +139,24 @@ function createServer() {
125
139
  // to this machine's own log. Nobody ever writes to anyone else's file, so
126
140
  // two people working at once have nothing to collide over.
127
141
  (0, space_js_1.attachSync)(brain, cortex);
142
+ return { brain, cortex, synapses, heatEngine, hippocampus, prefrontal, searchEngine, maintenance };
143
+ }
144
+ function createServer(shared) {
145
+ // Served at initialize for the clients that read it (Claude Desktop does
146
+ // not, as of anthropics/claude-code#43749; there the tool descriptions
147
+ // carry the same message). It is the one place to say how the memory is
148
+ // meant to be used, before any tool is called.
149
+ const server = new mcp_js_1.McpServer({
150
+ name: 'crbro-memory',
151
+ version: runningVersion(),
152
+ }, {
153
+ instructions: 'CRBRO is this user\'s persistent memory, kept on their own machine. Start every conversation with crbro_boot: it loads what earlier sessions left — protocols to follow, open items, hot topics. ' +
154
+ 'Before answering anything about the user, their projects, preferences, decisions or past work, call crbro_recall: the answer is usually stored, and making them repeat it is the failure this memory exists to prevent. ' +
155
+ 'Questions about CRBRO itself (version, counts, whether semantic recall is on) are crbro_inspect view=status. Read one entry, not a whole neuron: view=neuron gives an index, entries=[ids] the text. ' +
156
+ 'Save with crbro_learn as you go, and close with crbro_consolidate before the conversation ends.',
157
+ });
158
+ // ─── Engines: this server's own, or the ones the daemon shares ──
159
+ const { brain, cortex, synapses, heatEngine, hippocampus, prefrontal, searchEngine, maintenance } = shared ?? createEngines();
128
160
  // v1.4.0: CRBRO is fully free — no license, no network calls. The former
129
161
  // license engine (Firestore-backed freemium) lives in git history before
130
162
  // that version if it is ever needed again.
@@ -693,6 +725,8 @@ function createServer() {
693
725
  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.'),
694
726
  domain: zod_1.z.string().optional().describe('Only neurons in this domain (exact match, e.g. "proyectos-web"). Day logs have no domain: sessions_matched is listed regardless.'),
695
727
  limit: zod_1.z.number().int().positive().optional().describe('Max neurons returned (default 5, ranked; ask for more only when the top five did not answer).'),
728
+ since: zod_1.z.string().optional().describe('Only entries dated on or after this: a day ("2026-09-01") or a span back from today ("7d", "2w", "3m"). For "what changed lately" and to keep an old telling out. Undated entries cannot prove they are recent: they are left out and counted in undated_skipped.'),
729
+ kind: zod_1.z.array(zod_1.z.enum(index_js_1.RECALL_KINDS)).optional().describe('Only these entry kinds, e.g. ["error"] for past mistakes before repeating one, ["decision"] for what was agreed and why, ["debt"] for what was deferred. Day logs are left out when set.'),
696
730
  },
697
731
  outputSchema: {
698
732
  query: zod_1.z.string(),
@@ -713,16 +747,28 @@ function createServer() {
713
747
  has_more: zod_1.z.boolean().optional(),
714
748
  sessions_matched: zod_1.z.array(zod_1.z.object({}).loose()).optional(),
715
749
  sessions_total: zod_1.z.number().optional(),
750
+ filters: zod_1.z.object({ since: zod_1.z.string().optional(), kind: zod_1.z.array(zod_1.z.string()).optional() }).optional(),
751
+ undated_skipped: zod_1.z.number().optional().describe('With since: matching entries left out for having no date'),
716
752
  hint: zod_1.z.string(),
717
753
  truncated: zod_1.z.object({}).loose().optional(),
718
754
  },
719
755
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
720
756
  }, async (args) => {
721
757
  try {
758
+ let since;
759
+ if (args.since !== undefined && args.since.trim() !== '') {
760
+ const d = resolveSince(args.since);
761
+ if (!d)
762
+ throw new Error(`since "${args.since}" is neither a day (2026-09-01) nor a span (7d, 2w, 3m).`);
763
+ since = d;
764
+ }
765
+ const kinds = args.kind && args.kind.length > 0 ? [...new Set(args.kind)] : undefined;
722
766
  // Five by default, down from ten: results are ranked and each one
723
767
  // carries its entry, so ten cost ~12,000 tokens on a dense brain for
724
768
  // answers that live in the top three (recall@3 is the metric).
725
- const { results, matched_neurons, sessions, sessions_total } = await searchEngine.searchManyWithStats([args.query, ...(args.queries || [])], { domain: args.domain, limit: args.limit ?? 5 });
769
+ const { results, matched_neurons, sessions, sessions_total, undated_skipped } = await searchEngine.searchManyWithStats([args.query, ...(args.queries || [])], { domain: args.domain, limit: args.limit ?? 5, since, kinds });
770
+ const filtrado = !!since || !!kinds;
771
+ const sinFecha = undated_skipped ?? 0;
726
772
  // A hit carries its entry, but not without limit: one 6,000-character
727
773
  // fact must not cost more than the five results around it. Past the
728
774
  // cap the opening comes back, declared, and entry_id reads the rest.
@@ -749,6 +795,10 @@ function createServer() {
749
795
  returned: results.length,
750
796
  matched_neurons,
751
797
  has_more: sobran > 0,
798
+ // The filter as it was understood ("30d" becomes a day), so a narrowed
799
+ // answer never reads as the whole brain.
800
+ ...(filtrado ? { filters: { ...(since ? { since } : {}), ...(kinds ? { kind: kinds } : {}) } } : {}),
801
+ ...(since ? { undated_skipped: sinFecha } : {}),
752
802
  results: rows,
753
803
  // The diary, searched since 2.2: day logs whose summary mentions the
754
804
  // question, in a list of their own so narrative never outranks a fact.
@@ -758,11 +808,15 @@ function createServer() {
758
808
  ? (sessions.length
759
809
  // A day mentions it and no fact does: point at the day, not at rephrasing.
760
810
  ? `No stored fact matched; ${sessions_total} day log${sessions_total === 1 ? '' : 's'} mention it (sessions_matched): read one whole with crbro_inspect view=sessions session=<session_id>. If it is a fact worth keeping, save it with crbro_learn.`
761
- : 'Nothing matched. Try fewer, more distinctive words - names, ids, filenames - rather than a full sentence.')
811
+ : (filtrado
812
+ ? 'Nothing matched inside the filter. Drop since/kind and ask again before concluding it is not stored.'
813
+ : 'Nothing matched. Try fewer, more distinctive words - names, ids, filenames - rather than a full sentence.'))
762
814
  : 'weak: verify. Newer wins on conflict. entry_id → crbro_inspect view=neuron entries=[id]. has_map → crbro_map first.'
763
815
  + (sobran > 0 ? ` ${sobran} more neuron${sobran === 1 ? '' : 's'} matched: raise limit or narrow the query.` : '')
764
816
  + (cortados > 0 ? ' content_truncated → read the entry by entry_id.' : '')
765
817
  + (sessions.length ? ' sessions_matched: day logs that mention it; read one whole with crbro_inspect view=sessions session=<session_id>.' : ''))
818
+ + (filtrado && results.length > 0 ? ' Filtered: entries outside since/kind were not searched.' : '')
819
+ + (sinFecha > 0 ? ` ${sinFecha} matching entr${sinFecha === 1 ? 'y has' : 'ies have'} no date and were left out by since (crbro_maintenance backfill_dates dates the ones that state a day).` : '')
766
820
  + (sessions_total > sessions.length ? ` ${sessions_total - sessions.length} more day${sessions_total - sessions.length === 1 ? '' : 's'} mention it: narrow the query.` : ''),
767
821
  };
768
822
  // Recall is the one read whose size the caller sets, with limit: ten
@@ -793,7 +847,11 @@ function createServer() {
793
847
  domain: zod_1.z.string().optional().describe('Replace the neuron domain unconditionally, e.g. "proyectos-web".'),
794
848
  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.'),
795
849
  name: zod_1.z.string().optional().describe('Rename the neuron. Its id, file, synapses and shared state stay the same.'),
850
+ move_to: zod_1.z.string().optional().describe('Split: move the entries listed in facts/entries (ids from crbro_inspect, or exact text; any kind) to this neuron — id or name, created if missing with the same type and domain. They keep their dates, keys and retirement; the source is quarantined first and the two neurons are linked. status and note are ignored. Refused while `neuron` is shared.'),
796
851
  }).superRefine((v, ctx) => {
852
+ if (v.move_to !== undefined && !(v.facts?.length || v.entries?.length)) {
853
+ ctx.addIssue({ code: 'custom', message: 'move_to needs what to move: pass facts and/or entries (ids from crbro_inspect view=neuron, or exact text).' });
854
+ }
797
855
  const any = [v.facts, v.entries, v.summary, v.domain, v.tags, v.name].some(x => x !== undefined);
798
856
  if (!any) {
799
857
  ctx.addIssue({
@@ -804,7 +862,7 @@ function createServer() {
804
862
  });
805
863
  server.registerTool('crbro_revise', {
806
864
  title: 'Revise a neuron',
807
- 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.',
865
+ 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). move_to splits: the listed entries go to another neuron with their dates. Anything in unmatched is STILL LIVE — fix and re-run.',
808
866
  inputSchema: reviseSchema,
809
867
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
810
868
  }, async (args) => {
@@ -814,6 +872,35 @@ function createServer() {
814
872
  return textResult(`Neuron not found: "${args.neuron}". Use crbro_recall to find the right neuron id first.`);
815
873
  }
816
874
  const status = args.status || 'superseded';
875
+ // ── move_to: split, not retire ──
876
+ if (args.move_to !== undefined) {
877
+ const compartida = (await (0, space_js_1.sharedMap)(brain))[target.id];
878
+ if (compartida) {
879
+ return textResult(`"${target.id}" is shared in space "${compartida}": moving entries out would be undone by the next sync. crbro_share unshare first.`, true);
880
+ }
881
+ const r = await cortex.moveEntries(target.id, [...(args.facts || []), ...(args.entries || [])], args.move_to, { domain: args.domain });
882
+ if (r.into && r.into === r.from) {
883
+ return textResult(`"${args.move_to}" is the same neuron as "${target.id}"; nothing to move.`, true);
884
+ }
885
+ if (r.moved > 0 && r.into) {
886
+ try {
887
+ await synapses.connect(target.id, r.into, 'hierarchy', `split from ${target.name} (${dia(new Date().toISOString())})`, { initialStrength: 0.6, keepExistingContext: true });
888
+ }
889
+ catch { /* the link is a courtesy; the move already happened */ }
890
+ }
891
+ return jsonResult({
892
+ neuron_id: target.id,
893
+ moved_to: r.into,
894
+ created: r.created,
895
+ moved: r.moved,
896
+ unmatched: r.unmatched.length > 0 ? r.unmatched : undefined,
897
+ backup: r.backup,
898
+ message: r.moved > 0
899
+ ? `${r.moved} entr${r.moved === 1 ? 'y' : 'ies'} moved from "${target.name}" to "${r.into}"${r.created ? ' (created)' : ''}, dates kept. A copy of the source as it was is in quarantine.` +
900
+ (r.unmatched.length > 0 ? ` WARNING: ${r.unmatched.length} target(s) matched nothing and stayed where they were.` : '')
901
+ : 'Nothing matched, nothing moved. Pass entry ids from crbro_inspect view=neuron, or the exact text.',
902
+ });
903
+ }
817
904
  let revisedFacts = 0;
818
905
  let revisedEntries = 0;
819
906
  const unmatched = [];
@@ -1207,6 +1294,9 @@ function createServer() {
1207
1294
  const SUMMARY_LONG = 3_000;
1208
1295
  const summary = (0, secrets_js_1.redact)(args.summary).text;
1209
1296
  const result = await maintenance.consolidate(summary, { topicsTouched: args.topics_touched });
1297
+ // In daemon mode the counters belong to the connection. One that took over a
1298
+ // running conversation (the daemon it was on died) starts them at zero.
1299
+ const contadoresIncompletos = cortex_js_1.sessionScope.getStore()?.resumed === true;
1210
1300
  // The day just logged joins the search index at once (2.2): the diary
1211
1301
  // is searchable, and a recall tomorrow can point at today.
1212
1302
  const logHoy = await (0, fs_js_1.readJSON)(brain.paths.session(result.session_id));
@@ -1217,11 +1307,46 @@ function createServer() {
1217
1307
  await searchEngine.flush();
1218
1308
  // Send the session's notes to the team before the lights go out.
1219
1309
  const compartidos = await (0, space_js_1.syncAll)(brain, cortex, 10_000);
1310
+ // A backup nobody has to remember to make (2.5). The audited brain had
1311
+ // 1,200 neurons and 97 sessions on one disk and the only "backups"
1312
+ // folder held config files. Once a day, never throws, and says where it
1313
+ // went so the user can point CRBRO_BACKUP_DIR somewhere that is synced.
1314
+ const copia = await (0, backup_js_1.autoBackupIfDue)(brain.paths);
1315
+ // The command → lesson lookup the PreToolUse guard reads. Derived data,
1316
+ // rebuilt here because this is when the ledgers have just changed.
1317
+ await (0, triggers_js_1.writeTriggerIndex)(brain);
1318
+ // Large neurons nobody has described (2.5). `summary` exists since 1.0
1319
+ // and was empty in 1,199 of 1,200 neurons: the server has no model to
1320
+ // write one, but the assistant closing the session has the whole context
1321
+ // in front of it. Only neurons this session touched, only the big ones,
1322
+ // read with peek so asking does not warm them.
1323
+ const sinResumen = [];
1324
+ for (const id of result.topics_logged) {
1325
+ const n = await cortex.peek(id);
1326
+ if (!n || (n.summary || '').trim())
1327
+ continue;
1328
+ const entries = n.facts.length + n.decisions.length + n.patterns.length
1329
+ + n.preferences.length + (n.errors?.length || 0) + (n.debts?.length || 0);
1330
+ if (entries >= SUMMARY_NUDGE_MIN_ENTRIES)
1331
+ sinResumen.push({ neuron_id: id, entries });
1332
+ }
1333
+ sinResumen.sort((a, b) => b.entries - a.entries);
1220
1334
  return jsonResult({
1221
1335
  ...result,
1222
1336
  shared_spaces: compartidos.length > 0
1223
1337
  ? compartidos.map(c => ({ space: c.space, state: c.state, pushed: c.pushed }))
1224
1338
  : undefined,
1339
+ backup: copia.made
1340
+ ? { made: true, file: copia.file, dir: (0, backup_js_1.resolveBackupDir)(brain.paths.root) }
1341
+ : (copia.reason.startsWith('failed') ? { made: false, reason: copia.reason } : undefined),
1342
+ ...(contadoresIncompletos ? {
1343
+ tally_incomplete: true,
1344
+ tally_incomplete_hint: 'CRBRO restarted during this conversation, so facts_saved, decisions_saved and topics_touched only count what came after. Everything written before is on disk; if it matters that the log names those topics, call crbro_consolidate again with topics_touched=[their neuron ids].',
1345
+ } : {}),
1346
+ ...(sinResumen.length > 0 ? {
1347
+ missing_summaries: sinResumen.slice(0, 3),
1348
+ missing_summaries_hint: 'These neurons you touched are large and have no summary. Write two or three lines on what each one IS with crbro_revise neuron=<id> summary="…": it is what boot shows next to the name and what recall matches when the question is about the topic as a whole.',
1349
+ } : {}),
1225
1350
  message: 'Session consolidated. Brain state persisted.',
1226
1351
  summary_chars: summary.length,
1227
1352
  ...(summary.length > SUMMARY_LONG ? {
@@ -1238,13 +1363,15 @@ function createServer() {
1238
1363
  // ═══════════════════════════════════════════════════════════════
1239
1364
  server.registerTool('crbro_maintenance', {
1240
1365
  title: 'Brain maintenance',
1241
- description: 'Write: brain housekeeping — recalculate 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.',
1366
+ description: 'Write: brain housekeeping — recalculate 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, backfill_dates for entries older than 1.13, compact what a bulk import left. For session close use crbro_consolidate; to only read the brain use crbro_inspect.',
1242
1367
  inputSchema: {
1243
1368
  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.'),
1244
1369
  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.'),
1245
1370
  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.'),
1246
1371
  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.'),
1247
1372
  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.'),
1373
+ backfill_dates: zod_1.z.boolean().optional().describe('Date the patterns, preferences, errors and debts written before 1.13 (recall shows them with an empty matched_added), using only a date stated in the text of the entry itself, to the day; the rest stay undated — nothing is guessed. Every run reports undated_entries and datable_entries; this writes them (dates_backfilled). Off in dry_run.'),
1374
+ compact: zod_1.z.boolean().optional().describe('Fold the one-line neurons a bulk import left (compact_groups: 25+ born the same day in one domain, no tags, links or summary, 30+ days old) into one digest neuron per group. Identical lines are kept once, each source name becomes search keys of its line, every source is copied to quarantine first. Shared neurons are never touched. Off in dry_run — run that first and read the samples.'),
1248
1375
  },
1249
1376
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
1250
1377
  }, async (args) => {
@@ -1254,10 +1381,15 @@ function createServer() {
1254
1381
  purgeBoilerplate: args.purge_boilerplate,
1255
1382
  repair: args.repair,
1256
1383
  unarchive: args.unarchive,
1384
+ backfillDates: args.backfill_dates,
1385
+ compact: args.compact,
1386
+ sharedIds: new Set(Object.keys(await (0, space_js_1.sharedMap)(brain))),
1257
1387
  });
1388
+ const disparadores = args.dry_run ? null : await (0, triggers_js_1.writeTriggerIndex)(brain);
1258
1389
  return jsonResult({
1259
1390
  mode: args.dry_run ? 'DRY RUN' : 'EXECUTED',
1260
1391
  ...report,
1392
+ ...(disparadores && !('error' in disparadores) ? { trigger_index: disparadores } : {}),
1261
1393
  });
1262
1394
  }
1263
1395
  catch (err) {