crbro-memory 2.9.1 → 2.11.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 (197) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +256 -456
  3. package/SECURITY.md +135 -8
  4. package/bin/crbro.mjs +555 -299
  5. package/dist/build-id.json +10 -0
  6. package/dist/cli/common.js +211 -0
  7. package/dist/cli/export.js +522 -0
  8. package/dist/cli/import.js +683 -0
  9. package/dist/cli/markdown.js +313 -0
  10. package/dist/cli/mcpclient.js +107 -0
  11. package/dist/cli/serve.js +453 -0
  12. package/dist/cli/view.js +358 -0
  13. package/dist/cli/viewer-page.js +378 -0
  14. package/dist/daemon/daemon.js +119 -11
  15. package/dist/daemon/endpoint.js +222 -21
  16. package/dist/daemon/lines.js +102 -13
  17. package/dist/daemon/proxy.js +480 -125
  18. package/dist/daemon/surface.js +116 -0
  19. package/dist/daemon/trace.js +73 -0
  20. package/dist/engine/atrest.js +132 -0
  21. package/dist/engine/backup.js +143 -19
  22. package/dist/engine/brain.js +22 -6
  23. package/dist/engine/codexconfig.js +476 -0
  24. package/dist/engine/cortex.js +762 -311
  25. package/dist/engine/doctor.js +676 -0
  26. package/dist/engine/evidence.js +171 -0
  27. package/dist/engine/heat.js +81 -37
  28. package/dist/engine/hippocampus.js +50 -36
  29. package/dist/engine/hookrefresh.js +296 -0
  30. package/dist/engine/hookverify.js +15 -3
  31. package/dist/engine/installer.js +1022 -0
  32. package/dist/engine/installio.js +279 -0
  33. package/dist/engine/keychain.js +735 -144
  34. package/dist/engine/maintenance.js +355 -87
  35. package/dist/engine/modinstall.js +98 -45
  36. package/dist/engine/prefrontal.js +126 -69
  37. package/dist/engine/projects.js +80 -0
  38. package/dist/engine/quarantine.js +204 -0
  39. package/dist/engine/secrets.js +41 -5
  40. package/dist/engine/shelf.js +34 -11
  41. package/dist/engine/synapses.js +183 -80
  42. package/dist/engine/triggers.js +141 -11
  43. package/dist/index.js +37 -3
  44. package/dist/mcp-surface.json +1 -0
  45. package/dist/miner/extractor.js +64 -17
  46. package/dist/miner/scheduler.js +144 -68
  47. package/dist/search/index.js +810 -176
  48. package/dist/search/quality.js +31 -0
  49. package/dist/search/semantic.js +208 -18
  50. package/dist/search/tokenize.js +44 -0
  51. package/dist/server.js +649 -343
  52. package/dist/sync/git.js +203 -72
  53. package/dist/sync/materialize.js +33 -8
  54. package/dist/sync/space.js +286 -101
  55. package/dist/sync/validate.js +204 -0
  56. package/dist/utils/budget.js +15 -2
  57. package/dist/utils/fs.js +40 -6
  58. package/dist/utils/ids.js +128 -14
  59. package/dist/utils/lock.js +219 -52
  60. package/hooks/crbro-codex.mjs +284 -0
  61. package/hooks/crbro-guard.mjs +281 -173
  62. package/hooks/crbro-hooks.shipped.json +20 -0
  63. package/hooks/crbro-lifecycle.mjs +16 -4
  64. package/hooks/crbro-subagent.mjs +180 -180
  65. package/package.json +12 -8
  66. package/dist/daemon/daemon.d.ts +0 -25
  67. package/dist/daemon/daemon.d.ts.map +0 -1
  68. package/dist/daemon/daemon.js.map +0 -1
  69. package/dist/daemon/endpoint.d.ts +0 -66
  70. package/dist/daemon/endpoint.d.ts.map +0 -1
  71. package/dist/daemon/endpoint.js.map +0 -1
  72. package/dist/daemon/lines.d.ts +0 -55
  73. package/dist/daemon/lines.d.ts.map +0 -1
  74. package/dist/daemon/lines.js.map +0 -1
  75. package/dist/daemon/proxy.d.ts +0 -55
  76. package/dist/daemon/proxy.d.ts.map +0 -1
  77. package/dist/daemon/proxy.js.map +0 -1
  78. package/dist/engine/backup.d.ts +0 -68
  79. package/dist/engine/backup.d.ts.map +0 -1
  80. package/dist/engine/backup.js.map +0 -1
  81. package/dist/engine/brain.d.ts +0 -79
  82. package/dist/engine/brain.d.ts.map +0 -1
  83. package/dist/engine/brain.js.map +0 -1
  84. package/dist/engine/cortex.d.ts +0 -670
  85. package/dist/engine/cortex.d.ts.map +0 -1
  86. package/dist/engine/cortex.js.map +0 -1
  87. package/dist/engine/dates.d.ts +0 -28
  88. package/dist/engine/dates.d.ts.map +0 -1
  89. package/dist/engine/dates.js.map +0 -1
  90. package/dist/engine/heat.d.ts +0 -28
  91. package/dist/engine/heat.d.ts.map +0 -1
  92. package/dist/engine/heat.js.map +0 -1
  93. package/dist/engine/hippocampus.d.ts +0 -60
  94. package/dist/engine/hippocampus.d.ts.map +0 -1
  95. package/dist/engine/hippocampus.js.map +0 -1
  96. package/dist/engine/hookverify.d.ts +0 -42
  97. package/dist/engine/hookverify.d.ts.map +0 -1
  98. package/dist/engine/hookverify.js.map +0 -1
  99. package/dist/engine/keychain.d.ts +0 -32
  100. package/dist/engine/keychain.d.ts.map +0 -1
  101. package/dist/engine/keychain.js.map +0 -1
  102. package/dist/engine/maintenance.d.ts +0 -218
  103. package/dist/engine/maintenance.d.ts.map +0 -1
  104. package/dist/engine/maintenance.js.map +0 -1
  105. package/dist/engine/modinstall.d.ts +0 -215
  106. package/dist/engine/modinstall.d.ts.map +0 -1
  107. package/dist/engine/modinstall.js.map +0 -1
  108. package/dist/engine/postmortem.d.ts +0 -81
  109. package/dist/engine/postmortem.d.ts.map +0 -1
  110. package/dist/engine/postmortem.js.map +0 -1
  111. package/dist/engine/prefrontal.d.ts +0 -61
  112. package/dist/engine/prefrontal.d.ts.map +0 -1
  113. package/dist/engine/prefrontal.js.map +0 -1
  114. package/dist/engine/secrets.d.ts +0 -30
  115. package/dist/engine/secrets.d.ts.map +0 -1
  116. package/dist/engine/secrets.js.map +0 -1
  117. package/dist/engine/shelf.d.ts +0 -156
  118. package/dist/engine/shelf.d.ts.map +0 -1
  119. package/dist/engine/shelf.js.map +0 -1
  120. package/dist/engine/source.d.ts +0 -6
  121. package/dist/engine/source.d.ts.map +0 -1
  122. package/dist/engine/source.js.map +0 -1
  123. package/dist/engine/synapses.d.ts +0 -82
  124. package/dist/engine/synapses.d.ts.map +0 -1
  125. package/dist/engine/synapses.js.map +0 -1
  126. package/dist/engine/triggers.d.ts +0 -53
  127. package/dist/engine/triggers.d.ts.map +0 -1
  128. package/dist/engine/triggers.js.map +0 -1
  129. package/dist/engine/usage.d.ts +0 -61
  130. package/dist/engine/usage.d.ts.map +0 -1
  131. package/dist/engine/usage.js.map +0 -1
  132. package/dist/index.d.ts +0 -2
  133. package/dist/index.d.ts.map +0 -1
  134. package/dist/index.js.map +0 -1
  135. package/dist/miner/extractor.d.ts +0 -12
  136. package/dist/miner/extractor.d.ts.map +0 -1
  137. package/dist/miner/extractor.js.map +0 -1
  138. package/dist/miner/index.d.ts +0 -61
  139. package/dist/miner/index.d.ts.map +0 -1
  140. package/dist/miner/index.js.map +0 -1
  141. package/dist/miner/scheduler.d.ts +0 -30
  142. package/dist/miner/scheduler.d.ts.map +0 -1
  143. package/dist/miner/scheduler.js.map +0 -1
  144. package/dist/miner/types.d.ts +0 -11
  145. package/dist/miner/types.d.ts.map +0 -1
  146. package/dist/miner/types.js.map +0 -1
  147. package/dist/search/index.d.ts +0 -323
  148. package/dist/search/index.d.ts.map +0 -1
  149. package/dist/search/index.js.map +0 -1
  150. package/dist/search/semantic.d.ts +0 -80
  151. package/dist/search/semantic.d.ts.map +0 -1
  152. package/dist/search/semantic.js.map +0 -1
  153. package/dist/search/synonyms.d.ts +0 -2
  154. package/dist/search/synonyms.d.ts.map +0 -1
  155. package/dist/search/synonyms.js.map +0 -1
  156. package/dist/search/tokenize.d.ts +0 -39
  157. package/dist/search/tokenize.d.ts.map +0 -1
  158. package/dist/search/tokenize.js.map +0 -1
  159. package/dist/server.d.ts +0 -41
  160. package/dist/server.d.ts.map +0 -1
  161. package/dist/server.js.map +0 -1
  162. package/dist/sync/git.d.ts +0 -36
  163. package/dist/sync/git.d.ts.map +0 -1
  164. package/dist/sync/git.js.map +0 -1
  165. package/dist/sync/materialize.d.ts +0 -35
  166. package/dist/sync/materialize.d.ts.map +0 -1
  167. package/dist/sync/materialize.js.map +0 -1
  168. package/dist/sync/ops.d.ts +0 -142
  169. package/dist/sync/ops.d.ts.map +0 -1
  170. package/dist/sync/ops.js.map +0 -1
  171. package/dist/sync/space.d.ts +0 -116
  172. package/dist/sync/space.d.ts.map +0 -1
  173. package/dist/sync/space.js.map +0 -1
  174. package/dist/types/index.d.ts +0 -346
  175. package/dist/types/index.d.ts.map +0 -1
  176. package/dist/types/index.js.map +0 -1
  177. package/dist/utils/budget.d.ts +0 -44
  178. package/dist/utils/budget.d.ts.map +0 -1
  179. package/dist/utils/budget.js.map +0 -1
  180. package/dist/utils/fs.d.ts +0 -59
  181. package/dist/utils/fs.d.ts.map +0 -1
  182. package/dist/utils/fs.js.map +0 -1
  183. package/dist/utils/hash.d.ts +0 -20
  184. package/dist/utils/hash.d.ts.map +0 -1
  185. package/dist/utils/hash.js.map +0 -1
  186. package/dist/utils/ids.d.ts +0 -47
  187. package/dist/utils/ids.d.ts.map +0 -1
  188. package/dist/utils/ids.js.map +0 -1
  189. package/dist/utils/lock.d.ts +0 -11
  190. package/dist/utils/lock.d.ts.map +0 -1
  191. package/dist/utils/lock.js.map +0 -1
  192. package/dist/utils/transcripts.d.ts +0 -63
  193. package/dist/utils/transcripts.d.ts.map +0 -1
  194. package/dist/utils/transcripts.js.map +0 -1
  195. package/dist/version.d.ts +0 -26
  196. package/dist/version.d.ts.map +0 -1
  197. package/dist/version.js.map +0 -1
@@ -1,19 +1,30 @@
1
1
  "use strict";
2
2
  // ─── CRBRO Cortex Engine ─────────────────────────────────────────
3
3
  // Neuron CRUD — create, read, update, list neurons
4
+ var __importDefault = (this && this.__importDefault) || function (mod) {
5
+ return (mod && mod.__esModule) ? mod : { "default": mod };
6
+ };
4
7
  Object.defineProperty(exports, "__esModule", { value: true });
5
- exports.Cortex = exports.MAX_PROMOTION_CANDIDATES = exports.sessionScope = exports.newTally = void 0;
8
+ exports.Cortex = exports.MAX_PROMOTION_CANDIDATES = exports.sessionScope = exports.newTally = exports.UnreadableNeuronError = void 0;
6
9
  exports.nearDuplicatesAmong = nearDuplicatesAmong;
7
10
  exports.findPromotionCandidates = findPromotionCandidates;
8
11
  exports.unionNeuron = unionNeuron;
9
12
  exports.normalizeKeys = normalizeKeys;
10
13
  const node_async_hooks_1 = require("node:async_hooks");
14
+ const node_fs_1 = require("node:fs");
15
+ const node_path_1 = __importDefault(require("node:path"));
11
16
  const fs_js_1 = require("../utils/fs.js");
17
+ const lock_js_1 = require("../utils/lock.js");
12
18
  const ids_js_1 = require("../utils/ids.js");
19
+ const quarantine_js_1 = require("./quarantine.js");
20
+ const backup_js_1 = require("./backup.js");
21
+ const heat_js_1 = require("./heat.js");
13
22
  const hash_js_1 = require("../utils/hash.js");
14
23
  const ops_js_1 = require("../sync/ops.js");
24
+ const materialize_js_1 = require("../sync/materialize.js");
15
25
  const secrets_js_1 = require("./secrets.js");
16
26
  const shelf_js_1 = require("./shelf.js");
27
+ const evidence_js_1 = require("./evidence.js");
17
28
  const TYPE_PREFIX_RE = /^(project_|tech_|lang_|person_|domain_|process_|protocol_)/;
18
29
  /**
19
30
  * Similarity above which a new fact is flagged as a near-duplicate of one
@@ -23,8 +34,34 @@ const TYPE_PREFIX_RE = /^(project_|tech_|lang_|person_|domain_|process_|protocol
23
34
  */
24
35
  const NEAR_DUP_THRESHOLD = 0.8;
25
36
  const NEAR_DUP_MIN_LENGTH = 60;
26
- /** Minimum similarity before we accept a near-miss as "the same topic". */
27
- const NAME_MATCH_THRESHOLD = 0.85;
37
+ /**
38
+ * A neuron file that exists and cannot be parsed. Readers skip it and list it
39
+ * (Cortex.unreadable); writers refuse with this error, which names the file
40
+ * and where a good copy may be.
41
+ */
42
+ class UnreadableNeuronError extends Error {
43
+ neuronId;
44
+ path;
45
+ recovery;
46
+ constructor(neuronId, path, recovery, reason) {
47
+ super(`Neuron "${neuronId}" cannot be read (${reason}). Nothing was written. ${recovery}`);
48
+ this.neuronId = neuronId;
49
+ this.path = path;
50
+ this.recovery = recovery;
51
+ this.name = 'UnreadableNeuronError';
52
+ }
53
+ }
54
+ exports.UnreadableNeuronError = UnreadableNeuronError;
55
+ /**
56
+ * The same identifier written two ways: underscores dropped and each long
57
+ * word's plural folded ("widget_catalogs" ~ "widget catalog", "octo_chat" ~
58
+ * "octochat"). Nothing else. The bigram similarity this replaces merged
59
+ * "sprint_a" into "sprint_b" (0.857 ≥ 0.85) and any two long names one letter
60
+ * apart: a letter that differs is usually what tells two things apart.
61
+ */
62
+ function nameVariantKey(slug) {
63
+ return slug.split('_').filter(Boolean).map(t => (t.length > 4 && !/\d/.test(t) ? t.replace(/(es|s)$/, '') : t)).join('');
64
+ }
28
65
  const newTally = () => ({ facts: 0, decisions: 0, topics: new Set(), witnessed: new Set() });
29
66
  exports.newTally = newTally;
30
67
  /**
@@ -39,28 +76,26 @@ exports.sessionScope = new node_async_hooks_1.AsyncLocalStorage();
39
76
  * for "Widget Catalog" vs "widget_catalog", and — unlike substring containment —
40
77
  * it does not consider a 60-character walkthrough id a match for "SEO".
41
78
  */
42
- function similarity(a, b) {
43
- if (a === b)
44
- return 1;
45
- if (a.length < 2 || b.length < 2)
79
+ function bigramsOf(s) {
80
+ const out = new Map();
81
+ for (let i = 0; i < s.length - 1; i++) {
82
+ const g = s.slice(i, i + 2);
83
+ out.set(g, (out.get(g) || 0) + 1);
84
+ }
85
+ return out;
86
+ }
87
+ /** Dice of a precomputed bigram map (text of length aLen) against b. */
88
+ function diceFrom(A, aLen, b) {
89
+ if (aLen < 2 || b.length < 2)
46
90
  return 0;
47
- const bigrams = (s) => {
48
- const out = new Map();
49
- for (let i = 0; i < s.length - 1; i++) {
50
- const g = s.slice(i, i + 2);
51
- out.set(g, (out.get(g) || 0) + 1);
52
- }
53
- return out;
54
- };
55
- const A = bigrams(a);
56
- const B = bigrams(b);
91
+ const B = bigramsOf(b);
57
92
  let shared = 0;
58
93
  for (const [g, countA] of A) {
59
94
  const countB = B.get(g);
60
95
  if (countB)
61
96
  shared += Math.min(countA, countB);
62
97
  }
63
- return (2 * shared) / (a.length - 1 + b.length - 1);
98
+ return (2 * shared) / (aLen - 1 + b.length - 1);
64
99
  }
65
100
  /**
66
101
  * Do two slugs carry the same numbers, in the same order?
@@ -87,17 +122,20 @@ function nearDuplicatesAmong(content, candidates) {
87
122
  if (content.length < NEAR_DUP_MIN_LENGTH)
88
123
  return [];
89
124
  const nuevo = content.toLowerCase();
125
+ // The new text's bigrams once, not once per candidate (2.11): on a 700-fact
126
+ // neuron that was the same map rebuilt hundreds of times per learn.
127
+ const bigramasNuevo = bigramsOf(nuevo);
90
128
  const out = [];
91
129
  for (const c of candidates) {
92
130
  if (!c.text || c.text.length < NEAR_DUP_MIN_LENGTH)
93
131
  continue;
94
132
  // Dice over bigrams cannot reach the threshold when one side is much
95
133
  // longer: 2·min/(a+b) bounds it. A free filter before the real count.
96
- const otro = c.text.toLowerCase();
97
- const a = nuevo.length, b = otro.length;
134
+ const a = nuevo.length, b = c.text.length;
98
135
  if ((2 * Math.min(a, b)) / (a + b) < NEAR_DUP_THRESHOLD)
99
136
  continue;
100
- const score = similarity(nuevo, otro);
137
+ const otro = c.text.toLowerCase();
138
+ const score = nuevo === otro ? 1 : diceFrom(bigramasNuevo, nuevo.length, otro);
101
139
  if (score >= NEAR_DUP_THRESHOLD)
102
140
  out.push({ id: c.id, similarity: Math.round(score * 100) / 100, text: c.text });
103
141
  }
@@ -290,6 +328,10 @@ function earliestOf(a, b) {
290
328
  }
291
329
  function copyFact(f) {
292
330
  const out = { ...f };
331
+ if (f.reference)
332
+ out.reference = { ...f.reference };
333
+ if (f.verification)
334
+ out.verification = { ...f.verification, ...(f.verification.reference ? { reference: { ...f.verification.reference } } : {}) };
293
335
  if (f.keys)
294
336
  out.keys = [...f.keys];
295
337
  if (f.supersedes)
@@ -363,9 +405,7 @@ function unionNeuron(target, source) {
363
405
  delete hit.keys;
364
406
  // The later check is the newer evidence; the more volatile explicit class
365
407
  // wins, as in a team space (a needless warning is cheaper than a missing one).
366
- const verificado = (0, shelf_js_1.latestOf)(hit.verified, sf.verified);
367
- if (verificado)
368
- hit.verified = verificado;
408
+ (0, evidence_js_1.mergeFactEvidence)(hit, sf);
369
409
  const vida = (0, shelf_js_1.mostVolatile)(hit.shelf_life, sf.shelf_life);
370
410
  if (vida)
371
411
  hit.shelf_life = vida;
@@ -444,22 +484,10 @@ function unionNeuron(target, source) {
444
484
  neuron.entry_status = estadosVivos;
445
485
  else
446
486
  delete neuron.entry_status;
447
- const verificadas = { ...(source.entry_verified || {}) };
448
- for (const [k, v] of Object.entries(target.entry_verified || {})) {
449
- const l = (0, shelf_js_1.latestOf)(verificadas[k], v);
450
- if (l)
451
- verificadas[k] = l;
452
- else if (!verificadas[k])
453
- verificadas[k] = v;
454
- }
455
- const verificadasVivas = {};
456
- for (const k of Object.keys(verificadas).sort())
457
- if (vivos.has(k))
458
- verificadasVivas[k] = verificadas[k];
459
- if (Object.keys(verificadasVivas).length)
460
- neuron.entry_verified = verificadasVivas;
461
- else
462
- delete neuron.entry_verified;
487
+ // Usar ambos pares originales: una fecha nueva y una fuente vieja no se mezclan.
488
+ neuron.entry_verified = target.entry_verified;
489
+ neuron.entry_verification = target.entry_verification;
490
+ (0, evidence_js_1.mergeEntryEvidence)(neuron, source, vivos);
463
491
  const origenes = { ...(source.entry_source || {}), ...(target.entry_source || {}) };
464
492
  const origenesVivos = {};
465
493
  for (const k of Object.keys(origenes).sort())
@@ -556,20 +584,108 @@ class Cortex {
556
584
  /**
557
585
  * Copy a whole neuron to .quarantine/ with a timestamp, before anything
558
586
  * destructive. Same stamp format everywhere, so restoreNeuron can pick the
559
- * newest copy by name alone.
587
+ * newest copy by name alone. Sealed when encryption at rest is on; throws
588
+ * (and the caller removes nothing) when it is on and the key is missing.
560
589
  */
561
590
  async quarantine(neuron) {
562
- const sello = (0, fs_js_1.now)().replace(/[:.]/g, '-');
563
- const backup = `${this.brain.paths.quarantine}/${neuron.id}.${sello}.json`;
564
- await (0, fs_js_1.writeJSON)(backup, neuron);
565
- return backup;
591
+ return (0, quarantine_js_1.writeQuarantine)(this.brain.paths.quarantine, neuron.id, neuron, 'whole');
566
592
  }
567
- /** Remove the neuron file, drop its chunks, and keep the manifest honest. */
568
- async deleteNeuronFile(id) {
569
- await (0, fs_js_1.deleteJSON)(this.brain.paths.neuron(id));
570
- await this.unindex(id);
571
- const manifest = await this.brain.getManifest();
572
- await this.brain.updateManifest({ total_neurons: Math.max(0, manifest.total_neurons - 1) });
593
+ /** Neuron files that exist and could not be parsed, seen by this process (2.11). */
594
+ skipped = new Map();
595
+ /** Where a good copy of a neuron may be: the newest backup, or a quarantine copy. */
596
+ async recoveryHint(id) {
597
+ const partes = [];
598
+ try {
599
+ const copias = (await (0, quarantine_js_1.listQuarantine)(this.brain.paths.quarantine, id)).filter(c => c.kind === 'whole');
600
+ if (copias.length)
601
+ partes.push(`a whole copy is in quarantine (${node_path_1.default.join(this.brain.paths.quarantine, copias[0].file)}; crbro_forget restore:true brings it back once the bad file is moved aside)`);
602
+ }
603
+ catch { /* no quarantine */ }
604
+ try {
605
+ const dir = (0, backup_js_1.resolveBackupDir)(this.brain.paths.root);
606
+ const b = (await (0, backup_js_1.listBackups)(dir))[0];
607
+ if (b)
608
+ partes.push(`the newest backup is ${b.path} (npx crbro-memory backup restore "${b.file}" --into <empty folder>, then copy cortex/${id}.json back)`);
609
+ }
610
+ catch { /* no backups */ }
611
+ const donde = partes.length ? `Recovery: ${partes.join('; or ')}.` : 'No backup or quarantine copy was found.';
612
+ return `${donde} Or fix the JSON by hand (a UTF-8 byte-order mark is accepted since 2.11).`;
613
+ }
614
+ async noteUnreadable(id, err) {
615
+ const info = {
616
+ id,
617
+ path: this.brain.paths.neuron(id),
618
+ error: err instanceof fs_js_1.CorruptJSONError ? err.reason : err?.message || String(err),
619
+ recovery: await this.recoveryHint(id),
620
+ };
621
+ this.skipped.set(id, info);
622
+ return info;
623
+ }
624
+ /**
625
+ * Unreadable neuron files: the ones readers met and skipped since this
626
+ * process started (default), or every one on disk now (`scan: true`).
627
+ * Callers that list or search the cortex surface these instead of failing.
628
+ */
629
+ async unreadable(options) {
630
+ if (options?.scan) {
631
+ for (const id of await (0, fs_js_1.listJSONFiles)(this.brain.paths.cortex)) {
632
+ try {
633
+ await (0, fs_js_1.readJSON)(this.brain.paths.neuron(id));
634
+ this.skipped.delete(id);
635
+ }
636
+ catch (err) {
637
+ await this.noteUnreadable(id, err);
638
+ }
639
+ }
640
+ }
641
+ // A file fixed since it was seen is no longer reported.
642
+ const out = [];
643
+ for (const [id, info] of this.skipped) {
644
+ try {
645
+ await (0, fs_js_1.readJSON)(this.brain.paths.neuron(id));
646
+ this.skipped.delete(id);
647
+ }
648
+ catch {
649
+ out.push(info);
650
+ }
651
+ }
652
+ return out.sort((a, b) => (a.id < b.id ? -1 : 1));
653
+ }
654
+ /** A strict read for write paths: null when absent, UnreadableNeuronError when corrupt. */
655
+ async readStrict(id) {
656
+ try {
657
+ return await (0, fs_js_1.readJSON)(this.brain.paths.neuron(id));
658
+ }
659
+ catch (err) {
660
+ if (!(await (0, fs_js_1.fileExists)(this.brain.paths.neuron(id))))
661
+ return null;
662
+ const info = await this.noteUnreadable(id, err);
663
+ throw new UnreadableNeuronError(id, info.path, info.recovery, info.error);
664
+ }
665
+ }
666
+ /**
667
+ * updateJSON on a neuron, with a corrupt file turned into an error that
668
+ * names it and says where a good copy is.
669
+ */
670
+ async updateNeuron(id, mutate) {
671
+ try {
672
+ return await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(id), mutate);
673
+ }
674
+ catch (err) {
675
+ if (err instanceof fs_js_1.CorruptJSONError) {
676
+ const info = await this.noteUnreadable(id, err);
677
+ throw new UnreadableNeuronError(id, info.path, info.recovery, info.error);
678
+ }
679
+ throw err;
680
+ }
681
+ }
682
+ /** Resolution for write paths: exact id first, then the name; a corrupt match is an error, not a miss. */
683
+ async resolveForWrite(ref) {
684
+ return (await this.readStrict(ref)) || (await this.findByName(ref, { strict: true }));
685
+ }
686
+ /** The neuron was deleted between resolution and the lock: a write must not bring it back. */
687
+ deletedMeanwhile(id) {
688
+ return new Error(`Neuron "${id}" was deleted while this write was in progress. Nothing was written, so it was not brought back.`);
573
689
  }
574
690
  async emit(neuronId, change) {
575
691
  if (!this.emitter)
@@ -578,51 +694,73 @@ class Cortex {
578
694
  await this.emitter(neuronId, change);
579
695
  }
580
696
  catch {
581
- // Sharing must never break a local write. The note can be re-emitted
582
- // later; a lost fact cannot be recovered.
697
+ // El emisor solo falla si no logró registrar la entrega persistente.
698
+ // La nota local sigue guardada, pero no se puede confirmar éxito total.
699
+ throw new Error('Local change saved, but shared delivery could not be recorded. Check local storage before retrying.');
583
700
  }
584
701
  }
585
702
  /**
586
- * Overwrite a neuron with the result of merging the team's notes.
587
- * Only the sync layer calls this, and only with a neuron that already
588
- * contains everything local plus whatever arrived.
703
+ * Fusionar contra la versión leída dentro del mismo bloqueo de escritura.
704
+ * Un snapshot calculado antes del bloqueo puede borrar un learn o retiro.
589
705
  */
590
- async replaceFromSync(neuron) {
591
- const saved = await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(neuron.id), current => {
592
- // Local-only observations stay local: they describe this machine's use
593
- // of the memory, not the knowledge itself.
594
- if (current) {
595
- neuron.heat = current.heat;
596
- neuron.access_count = current.access_count;
597
- neuron.last_accessed = current.last_accessed;
598
- neuron.summary = current.summary || neuron.summary;
599
- neuron.preferences = current.preferences;
600
- neuron.connections = current.connections;
601
- neuron.created = current.created;
602
- }
706
+ async mergeFromSync(nid, ops) {
707
+ let report;
708
+ let created = false;
709
+ const saved = await this.updateNeuron(nid, current => {
710
+ created = current === null;
711
+ const merged = (0, materialize_js_1.applyOps)(current, ops, { id: nid });
712
+ report = merged.report;
713
+ const neuron = merged.neuron;
603
714
  return neuron;
604
715
  });
605
- const final = (saved || neuron);
606
- await this.reindex(final);
607
- return final;
716
+ if (saved)
717
+ await this.reindex(saved);
718
+ return { report, created };
608
719
  }
609
720
  async reindex(neuron) {
610
721
  if (!this.indexer)
611
722
  return;
612
723
  try {
613
- await this.indexer(neuron);
724
+ // Heat lives in the derived heat state since 2.11 and is no longer
725
+ // rewritten into every neuron file; the index gets the current value.
726
+ const h = (await this.heatMap()).get(neuron.id);
727
+ await this.indexer(h === undefined || h === neuron.heat ? neuron : { ...neuron, heat: h });
614
728
  }
615
729
  catch {
616
730
  // Indexing must never break a write. The index is derived data.
617
731
  }
618
732
  }
733
+ heatCache = null;
734
+ /**
735
+ * id → heat from the derived heat state (prefrontal/heat_state.json),
736
+ * cached on the file's mtime. Empty when it was never computed: readers
737
+ * then fall back to the heat stored in the neuron, as before 2.11.
738
+ */
739
+ async heatMap() {
740
+ const file = node_path_1.default.join(this.brain.paths.prefrontal, 'heat_state.json');
741
+ let mtime = 0;
742
+ try {
743
+ mtime = (await node_fs_1.promises.stat(file)).mtimeMs;
744
+ }
745
+ catch {
746
+ return new Map();
747
+ }
748
+ if (this.heatCache && this.heatCache.mtime === mtime)
749
+ return this.heatCache.map;
750
+ const state = await (0, heat_js_1.readHeatState)(this.brain).catch(() => null);
751
+ const map = new Map();
752
+ for (const [id, e] of Object.entries(state?.neurons || {}))
753
+ map.set(id, e.heat);
754
+ this.heatCache = { mtime, map };
755
+ return map;
756
+ }
619
757
  /**
620
758
  * Get a neuron by ID.
621
759
  */
622
760
  async get(id) {
623
761
  // Even a read touches the file (access time and count), so it goes through
624
762
  // the same locked read-modify-write as everything else.
625
- return (0, fs_js_1.updateJSON)(this.brain.paths.neuron(id), current => {
763
+ return this.updateNeuron(id, current => {
626
764
  if (!current)
627
765
  return null;
628
766
  current.last_accessed = (0, fs_js_1.now)();
@@ -632,9 +770,24 @@ class Cortex {
632
770
  }
633
771
  /**
634
772
  * Get a neuron by ID without updating access stats.
773
+ *
774
+ * Tolerant (2.11): a file that exists and cannot be parsed reads as null
775
+ * and is recorded in unreadable(), so one truncated neuron no longer takes
776
+ * down recall, inspect or consolidate. Write paths use a strict read.
635
777
  */
636
778
  async peek(id) {
637
- return (0, fs_js_1.readJSON)(this.brain.paths.neuron(id));
779
+ // A reference with a path separator is a name ("CI/CD") or an attempt to leave the brain, never an id.
780
+ if (typeof id !== 'string' || !id || /[\\/:\0]/.test(id))
781
+ return null;
782
+ try {
783
+ return await (0, fs_js_1.readJSON)(this.brain.paths.neuron(id));
784
+ }
785
+ catch (err) {
786
+ // An existing file that does not parse is recorded; anything else is a miss.
787
+ if (await (0, fs_js_1.fileExists)(this.brain.paths.neuron(id)))
788
+ await this.noteUnreadable(id, err);
789
+ return null;
790
+ }
638
791
  }
639
792
  /**
640
793
  * Find a neuron by name.
@@ -648,49 +801,76 @@ class Cortex {
648
801
  * wrong neuron, and no amount of index tuning repairs that. A wrong guess
649
802
  * is worse than a new neuron.
650
803
  */
651
- async findByName(name) {
652
- const ids = await (0, fs_js_1.listJSONFiles)(this.brain.paths.cortex);
653
- const slug = (0, ids_js_1.toSnakeCase)(name);
654
- if (!slug)
804
+ async findByName(name, options) {
805
+ const r = await this.resolveTopic(name, options);
806
+ if (!r)
655
807
  return null;
656
- // Neurons created before accents were folded live under a mangled name
657
- // ("bsqueda" for "búsqueda"). Try the correct slug first, then that one,
658
- // so upgrading does not orphan them.
659
- const legacy = (0, ids_js_1.legacySnakeCase)(name);
660
- const candidatos = legacy && legacy !== slug ? [slug, legacy] : [slug];
661
- // 1. Exact: the id itself, or the id minus its type prefix.
662
- for (const buscado of candidatos) {
663
- for (const id of ids) {
664
- if (id === buscado || id.replace(TYPE_PREFIX_RE, '') === buscado) {
665
- return this.peek(id);
666
- }
808
+ return options?.strict ? this.readStrict(r.id) : this.peek(r.id);
809
+ }
810
+ /**
811
+ * Which existing neuron a topic names, without reading any neuron unless
812
+ * two candidates tie. In order, for each slug the name may be filed under
813
+ * (current rule, the 2.10 rule, the pre-1.x accent-mangling rule):
814
+ *
815
+ * 1. `id` — the slug IS an existing id ("project_crbro");
816
+ * 2. `name` — an id minus its type prefix equals the slug;
817
+ * 3. `variant` — the same identifier written with or without underscores
818
+ * or a plural (see nameVariantKey). Never a letter or a number apart.
819
+ *
820
+ * Several neurons of different types on one name (project_react and
821
+ * tech_react): the requested type wins, then the type an id-shaped topic
822
+ * names, then the type the name implies; failing that the most used, and
823
+ * the others come back in `ambiguous` so the caller can say so.
824
+ */
825
+ async resolveTopic(name, options) {
826
+ const cands = (0, ids_js_1.slugCandidates)(name);
827
+ if (cands.length === 0)
828
+ return null;
829
+ const ids = await (0, fs_js_1.listJSONFiles)(this.brain.paths.cortex);
830
+ const idSet = new Set(ids);
831
+ const bareOf = (id) => id.replace(TYPE_PREFIX_RE, '');
832
+ const typeOf = (id) => (0, ids_js_1.typeOfIdShape)(id);
833
+ const prefer = [];
834
+ if (options?.type)
835
+ prefer.push(options.type);
836
+ const shape = (0, ids_js_1.typeOfIdShape)(cands[0]);
837
+ if (shape)
838
+ prefer.push(shape);
839
+ prefer.push((0, ids_js_1.inferNeuronType)(name));
840
+ const pick = async (matches, resolved_by) => {
841
+ if (matches.length === 1)
842
+ return { id: matches[0], resolved_by };
843
+ for (const t of prefer) {
844
+ const delTipo = matches.filter(id => typeOf(id) === t);
845
+ if (delTipo.length === 1)
846
+ return { id: delTipo[0], resolved_by, ambiguous: matches.filter(m => m !== delTipo[0]) };
667
847
  }
668
- }
669
- // 2. Near-miss, but only a real one. Ties go to the most used neuron.
670
- const candidates = [];
671
- for (const id of ids) {
672
- const bare = id.replace(TYPE_PREFIX_RE, '');
673
- const score = similarity(slug, bare);
674
- if (score >= NAME_MATCH_THRESHOLD && sameNumbers(slug, bare)) {
675
- candidates.push({ id, score });
848
+ let best = null;
849
+ for (const id of [...matches].sort()) {
850
+ const n = await this.peek(id);
851
+ const uses = n?.access_count || 0;
852
+ if (!best || uses > best.uses)
853
+ best = { id, uses };
676
854
  }
855
+ return { id: best.id, resolved_by, ambiguous: matches.filter(m => m !== best.id) };
856
+ };
857
+ for (const s of cands) {
858
+ if (idSet.has(s))
859
+ return { id: s, resolved_by: 'id' };
860
+ // An id-shaped slug also matches an id that carries its prefix twice
861
+ // ("project_x" → project_project_x, made by 2.10 from such a topic):
862
+ // that one's bare id is the slug itself.
863
+ const bare = ids.filter(id => bareOf(id) === s);
864
+ if (bare.length)
865
+ return pick(bare, 'name');
677
866
  }
678
- if (candidates.length === 0)
679
- return null;
680
- candidates.sort((a, b) => b.score - a.score);
681
- const top = candidates[0].score;
682
- const tied = candidates.filter(c => c.score >= top - 0.001);
683
- if (tied.length === 1)
684
- return this.peek(tied[0].id);
685
- let best = null;
686
- for (const c of tied) {
687
- const n = await this.peek(c.id);
688
- if (!n)
689
- continue;
690
- if (!best || (n.access_count || 0) > (best.access_count || 0))
691
- best = n;
867
+ const key = nameVariantKey(cands[0].replace(TYPE_PREFIX_RE, ''));
868
+ if (key.length >= 4) {
869
+ const variantes = ids.filter(id => nameVariantKey(bareOf(id)) === key);
870
+ if (variantes.length)
871
+ return pick(variantes, 'variant');
692
872
  }
693
- return best;
873
+ return null;
694
874
  }
695
875
  /**
696
876
  * Create a new neuron.
@@ -702,7 +882,7 @@ class Cortex {
702
882
  // Check-then-create has to happen inside the lock, or two clients starting
703
883
  // the same topic at the same moment both think they are the first.
704
884
  let creada = false;
705
- const neuron = await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(id), current => {
885
+ const neuron = await this.updateNeuron(id, current => {
706
886
  if (current)
707
887
  return null;
708
888
  creada = true;
@@ -739,46 +919,86 @@ class Cortex {
739
919
  * Creates the neuron if it does not exist.
740
920
  */
741
921
  async learn(topic, type, content, options) {
922
+ const reference = options?.reference === undefined ? undefined : (0, evidence_js_1.sanitizeReference)(options.reference);
923
+ const temporal = (0, evidence_js_1.sanitizeTemporal)(options?.temporal);
742
924
  // Credentials never make it to disk. The sentence around them survives, so
743
925
  // "the deploy token is [REDACTED: npm token]" still records that a token
744
926
  // exists and what kind — the knowledge without the liability.
745
927
  const limpio = (0, secrets_js_1.redact)(content);
746
928
  content = limpio.text;
747
- let neuron = null;
929
+ // The rationale and the aliases are stored, indexed and shared too (G8).
930
+ if (options?.rationale || options?.keys?.length) {
931
+ options = { ...options,
932
+ ...(options.rationale ? { rationale: (0, secrets_js_1.redact)(options.rationale).text } : {}),
933
+ ...(options.keys?.length ? { keys: options.keys.map(k => (0, secrets_js_1.redact)(k).text) } : {}) };
934
+ }
748
935
  let action = 'updated';
936
+ let targetId;
937
+ let resolvedBy;
938
+ let ambiguous;
939
+ // Resolution reads no neuron (2.11): only the id is needed here, and the
940
+ // lock below reads the file anyway. On a 1 MB neuron that was a whole
941
+ // extra parse per learn.
749
942
  if (options?.neuronId) {
750
- neuron = await this.peek(options.neuronId);
751
- }
752
- if (!neuron) {
753
- neuron = await this.findByName(topic);
943
+ // An explicit id is a promise about where the knowledge goes. A typo in
944
+ // it used to fall back to the topic and write somewhere else, silently.
945
+ const nid = options.neuronId.trim();
946
+ const existe = !!nid && !/[\\/]/.test(nid) && await (0, fs_js_1.fileExists)(this.brain.paths.neuron(nid));
947
+ // The one case an absent id is created: it is exactly the id the topic
948
+ // itself makes ("Tienda" + project_tienda). Anything else would be the
949
+ // knowledge landing somewhere the caller did not name.
950
+ const tipoDelId = existe ? null : (0, ids_js_1.typeOfIdShape)(nid);
951
+ const mismoId = !!tipoDelId && !!topic.trim() && (0, ids_js_1.neuronId)(topic, tipoDelId) === nid;
952
+ if (existe) {
953
+ targetId = nid;
954
+ resolvedBy = 'neuron_id';
955
+ }
956
+ else if (mismoId && options.createIfMissing !== false) {
957
+ targetId = (await this.create(topic, tipoDelId, options.domain || 'general')).id;
958
+ action = 'created';
959
+ resolvedBy = 'created';
960
+ }
961
+ else {
962
+ throw new Error(`No neuron with id "${options.neuronId}". Nothing was written. ` +
963
+ 'Find the right id with crbro_recall or crbro_inspect view=neurons, or pass only topic to resolve it by name.');
964
+ }
754
965
  }
755
- if (!neuron) {
756
- if (options?.createIfMissing === false) {
757
- return {
758
- neuron: null, action: 'skipped', superseded: 0,
759
- supersedes_unmatched: options?.supersedes || [], near_duplicates: [], redacted: limpio.found,
760
- duplicate: false, updated_in_place: false, skipped_retired: null,
761
- };
966
+ else {
967
+ const r = await this.resolveTopic(topic, { type: options?.neuronType });
968
+ if (r) {
969
+ targetId = r.id;
970
+ resolvedBy = r.resolved_by;
971
+ ambiguous = r.ambiguous;
972
+ }
973
+ else {
974
+ if (options?.createIfMissing === false) {
975
+ return {
976
+ neuron: null, action: 'skipped', superseded: 0,
977
+ supersedes_unmatched: options?.supersedes || [], near_duplicates: [], redacted: limpio.found,
978
+ duplicate: false, updated_in_place: false, skipped_retired: null,
979
+ };
980
+ }
981
+ const nType = options?.neuronType || (0, ids_js_1.inferNeuronType)(topic);
982
+ const domain = options?.domain || 'general';
983
+ targetId = (await this.create(topic, nType, domain)).id;
984
+ action = 'created';
985
+ resolvedBy = 'created';
762
986
  }
763
- const nType = options?.neuronType || (0, ids_js_1.inferNeuronType)(topic);
764
- const domain = options?.domain || 'general';
765
- neuron = await this.create(topic, nType, domain);
766
- action = 'created';
767
987
  }
768
988
  let superseded = 0;
769
- // Stays empty unless retire() actually runs: a duplicate fact skips the
770
- // whole block, and warning "still live" about targets nobody touched is
771
- // a false alarm on every idempotent retry.
989
+ // Only targets that matched nothing, live or already retired: a retry of
990
+ // a supersedes that already ran finds its target retired, and warning
991
+ // "still live" about it would be a false alarm.
772
992
  let supersedesUnmatched = [];
993
+ let retiradosAhora = [];
994
+ let gone = false;
773
995
  let nearDuplicates = [];
774
996
  let emitir = null;
775
997
  let duplicate = false;
776
998
  let updatedInPlace = false;
777
999
  let confirmations;
778
1000
  let shelf;
779
- let reconfirmed = false;
780
1001
  let shelfLengthened = false;
781
- let verificar = null;
782
1002
  const vidaPedida = (0, shelf_js_1.isShelfLife)(options?.shelfLife) ? options.shelfLife : undefined;
783
1003
  let skippedRetired = null;
784
1004
  // A retired decision, pattern, error or debt must not come back through
@@ -816,13 +1036,50 @@ class Cortex {
816
1036
  delete n.entry_source;
817
1037
  }
818
1038
  };
819
- const actualizada = await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(neuron.id), current => {
820
- const n = current || neuron;
1039
+ // One identity rule for every kind (2.11): the normalised text entryId
1040
+ // hashes — whitespace runs collapsed, NFC. Before, a pattern differing by
1041
+ // a line break was stored twice under one sidecar key, and a fact with a
1042
+ // trailing newline became a second fact.
1043
+ const clave = (0, ops_js_1.normalizeText)(content);
1044
+ const mismaEntrada = (t) => t === content || (0, ops_js_1.normalizeText)(t) === clave;
1045
+ const claveLower = clave.toLowerCase();
1046
+ const contentLower = content.toLowerCase();
1047
+ const mismoHecho = (t) => {
1048
+ if (t.length === content.length && t.toLowerCase() === contentLower)
1049
+ return true;
1050
+ // Normalising only shrinks a text, so a shorter one cannot match.
1051
+ if (t.length < clave.length || t.length > clave.length * 2 + 16)
1052
+ return false;
1053
+ return (0, ops_js_1.normalizeText)(t).toLowerCase() === claveLower;
1054
+ };
1055
+ const supersedeDesde = (n, propio) => {
1056
+ if (!options?.supersedes?.length)
1057
+ return;
1058
+ // A fact never supersedes itself, whatever the caller listed.
1059
+ const objetivos = options.supersedes.filter(t => t.trim().toLowerCase() !== propio.id.toLowerCase() && !mismoHecho(t.trim()));
1060
+ const r = this.retire(n, objetivos, propio.id, 'superseded');
1061
+ superseded = r.count;
1062
+ retiradosAhora = r.ids;
1063
+ // A target that is already retired was found: a retry of a supersedes
1064
+ // that already ran is not a miss.
1065
+ const yaRetirado = (t) => {
1066
+ const w = t.trim().toLowerCase();
1067
+ return n.facts.some(f => (f.status === 'superseded' || f.status === 'retracted')
1068
+ && ((f.id || (0, hash_js_1.factId)(f.text)).toLowerCase() === w || f.text.trim().toLowerCase() === w));
1069
+ };
1070
+ supersedesUnmatched = r.unmatched.filter(t => !yaRetirado(t));
1071
+ };
1072
+ const actualizada = await this.updateNeuron(targetId, current => {
1073
+ if (!current) {
1074
+ gone = true;
1075
+ return null;
1076
+ }
1077
+ const n = current;
821
1078
  switch (type) {
822
1079
  case 'fact': {
823
1080
  const id = (0, hash_js_1.factId)(content);
824
1081
  const keys = normalizeKeys(options?.keys);
825
- const existente = n.facts.find(f => f.text.toLowerCase() === content.toLowerCase());
1082
+ const existente = n.facts.find(f => mismoHecho(f.text));
826
1083
  if (existente && (existente.status === 'superseded' || existente.status === 'retracted')) {
827
1084
  // The same line was deliberately retired. Re-adding it would
828
1085
  // undo that revision by accident; report it and write nothing.
@@ -883,23 +1140,21 @@ class Cortex {
883
1140
  cambiado = true;
884
1141
  }
885
1142
  }
886
- // Saying the same line again, and nothing else, is a check
887
- // (shelf life): the clock restarts. Not when the call also
888
- // brings keywords, a confidence or a class — that is editing the
889
- // line, and the server itself asks for exactly that re-learn to
890
- // add keywords, so counting it would mark an unchecked line as
891
- // checked. Not for the miner either: re-reading an old
892
- // transcript is not looking at the source.
893
- if (options?.source !== 'miner' && !cambiado) {
894
- existente.verified = (0, fs_js_1.now)();
895
- reconfirmed = true;
896
- // One op per line per session: a retry or the re-save after a
897
- // compaction must not add a line to the team's log each time.
898
- const yaVerificadas = (this.tally.verifiedEmitted ??= new Set());
899
- if (!yaVerificadas.has(testigo)) {
900
- yaVerificadas.add(testigo);
901
- verificar = { eid: existente.id || (0, hash_js_1.factId)(existente.text), at: existente.verified };
902
- }
1143
+ const referenceChanged = reference && JSON.stringify(reference) !== JSON.stringify(existente.reference);
1144
+ const temporalChanged = temporal && temporal !== existente.temporal;
1145
+ if (referenceChanged || temporalChanged) {
1146
+ const previous = (0, evidence_js_1.mergeCheck)(existente, {});
1147
+ existente.verification_invalidated_at = new Date(Math.max(Date.now(), Date.parse(previous.verified || '') || 0, Date.parse(previous.verification_invalidated_at || '') || 0)).toISOString();
1148
+ delete existente.verified;
1149
+ delete existente.verification;
1150
+ }
1151
+ if (referenceChanged) {
1152
+ existente.reference = reference;
1153
+ cambiado = true;
1154
+ }
1155
+ if (temporalChanged) {
1156
+ existente.temporal = temporal;
1157
+ cambiado = true;
903
1158
  }
904
1159
  if (cambiado) {
905
1160
  // Note for shared neurons: materialize takes max(conf) and
@@ -907,19 +1162,26 @@ class Cortex {
907
1162
  // stays local. Documented, not fought — the log is append-only.
908
1163
  updatedInPlace = true;
909
1164
  this.tally.topics.add(n.id);
910
- emitir = { kind: 'fact', text: existente.text, fid: existente.id || id,
911
- conf: existente.confidence ?? 1, at: existente.added, src: existente.source, keys: existente.keys,
912
- ...(existente.shelf_life ? { shelf: existente.shelf_life } : {}) };
913
1165
  }
1166
+ // Un intento anterior pudo guardar la neurona pero fallar antes
1167
+ // de persistir su entrega. Reemitir permite recuperarla incluso
1168
+ // tras reiniciar; applyOps deduplica por fid. No es verificación
1169
+ // nueva y conserva la fecha original, no la del reintento.
1170
+ emitir = { kind: 'fact', text: existente.text, fid: existente.id || id,
1171
+ conf: existente.confidence ?? 1, at: existente.added, src: existente.source, keys: existente.keys,
1172
+ ...(existente.reference ? { reference: existente.reference } : {}),
1173
+ ...(existente.temporal ? { temporal: existente.temporal } : {}),
1174
+ ...(existente.verification_invalidated_at ? { verification_invalidated_at: existente.verification_invalidated_at } : {}),
1175
+ ...(existente.shelf_life ? { shelf: existente.shelf_life } : {}) };
914
1176
  const sv = (0, shelf_js_1.shelfOfFact)(existente);
915
1177
  shelf = { shelf_life: sv.shelf, inferred: sv.inferred, ...(sv.reason ? { reason: sv.reason } : {}) };
1178
+ // The same line again WITH supersedes: the retry after fixing a
1179
+ // mistyped id. It used to skip the retirement in silence, and
1180
+ // the old value stayed live while the caller believed it gone.
1181
+ supersedeDesde(n, { id: existente.id || (0, hash_js_1.factId)(existente.text), text: existente.text });
916
1182
  }
917
1183
  if (!isDuplicate) {
918
- if (options?.supersedes?.length) {
919
- const retirado = this.retire(n, options.supersedes, id, 'superseded');
920
- superseded = retirado.count;
921
- supersedesUnmatched = retirado.unmatched;
922
- }
1184
+ supersedeDesde(n, { id, text: content });
923
1185
  // Near-duplicate check, against what will still be active
924
1186
  // AFTER supersedes ran — retiring the old telling is exactly
925
1187
  // the fix this warning exists to suggest, so a properly
@@ -939,6 +1201,10 @@ class Cortex {
939
1201
  fact.keys = keys;
940
1202
  if (vidaPedida)
941
1203
  fact.shelf_life = vidaPedida;
1204
+ if (reference)
1205
+ fact.reference = reference;
1206
+ if (temporal)
1207
+ fact.temporal = temporal;
942
1208
  const sv = (0, shelf_js_1.shelfOfFact)(fact);
943
1209
  shelf = { shelf_life: sv.shelf, inferred: sv.inferred, ...(sv.reason ? { reason: sv.reason } : {}) };
944
1210
  n.facts.push(fact);
@@ -948,6 +1214,8 @@ class Cortex {
948
1214
  this.tally.topics.add(n.id);
949
1215
  emitir = { kind: 'fact', text: content, fid: id,
950
1216
  conf: fact.confidence, at: fact.added, src: fact.source, keys: fact.keys,
1217
+ ...(fact.reference ? { reference: fact.reference } : {}),
1218
+ ...(fact.temporal ? { temporal: fact.temporal } : {}),
951
1219
  ...(fact.shelf_life ? { shelf: fact.shelf_life } : {}) };
952
1220
  }
953
1221
  break;
@@ -955,6 +1223,12 @@ class Cortex {
955
1223
  case 'decision': {
956
1224
  if (retirada(n, content))
957
1225
  return null;
1226
+ // A retry no longer adds a second copy under the same entry id.
1227
+ const previa = n.decisions.find(d => mismaEntrada(d.text || ''));
1228
+ if (previa) {
1229
+ emitir = { kind: 'decision', text: previa.text, why: previa.rationale || undefined, at: previa.date || (0, fs_js_1.now)() };
1230
+ break;
1231
+ }
958
1232
  const decision = {
959
1233
  text: content,
960
1234
  date: (0, fs_js_1.now)(),
@@ -971,16 +1245,18 @@ class Cortex {
971
1245
  case 'pattern': {
972
1246
  if (retirada(n, content))
973
1247
  return null;
974
- if (!n.patterns.includes(content)) {
1248
+ const previo = n.patterns.find(mismaEntrada);
1249
+ if (previo === undefined) {
975
1250
  n.patterns.push(content);
976
1251
  fechar(n, content);
977
1252
  origen(n, content);
978
- emitir = { kind: 'pattern', text: content, at: (0, fs_js_1.now)() };
979
1253
  }
1254
+ const texto = previo ?? content;
1255
+ emitir = { kind: 'pattern', text: texto, at: n.entry_dates?.[(0, ops_js_1.entryId)(texto)] || (0, fs_js_1.now)() };
980
1256
  break;
981
1257
  }
982
1258
  case 'preference': {
983
- if (!n.preferences.includes(content)) {
1259
+ if (!n.preferences.some(mismaEntrada)) {
984
1260
  n.preferences.push(content);
985
1261
  fechar(n, content);
986
1262
  }
@@ -991,13 +1267,15 @@ class Cortex {
991
1267
  return null;
992
1268
  if (!n.errors)
993
1269
  n.errors = [];
994
- if (!n.errors.includes(content)) {
1270
+ const previo = n.errors.find(mismaEntrada);
1271
+ if (previo === undefined) {
995
1272
  n.errors.push(content);
996
1273
  fechar(n, content);
997
1274
  origen(n, content);
998
1275
  this.tally.topics.add(n.id);
999
- emitir = { kind: 'error', text: content, at: (0, fs_js_1.now)() };
1000
1276
  }
1277
+ const texto = previo ?? content;
1278
+ emitir = { kind: 'error', text: texto, at: n.entry_dates?.[(0, ops_js_1.entryId)(texto)] || (0, fs_js_1.now)() };
1001
1279
  break;
1002
1280
  }
1003
1281
  case 'debt': {
@@ -1005,13 +1283,15 @@ class Cortex {
1005
1283
  return null;
1006
1284
  if (!n.debts)
1007
1285
  n.debts = [];
1008
- if (!n.debts.includes(content)) {
1286
+ const previo = n.debts.find(mismaEntrada);
1287
+ if (previo === undefined) {
1009
1288
  n.debts.push(content);
1010
1289
  fechar(n, content);
1011
1290
  origen(n, content);
1012
1291
  this.tally.topics.add(n.id);
1013
- emitir = { kind: 'debt', text: content, at: (0, fs_js_1.now)() };
1014
1292
  }
1293
+ const texto = previo ?? content;
1294
+ emitir = { kind: 'debt', text: texto, at: n.entry_dates?.[(0, ops_js_1.entryId)(texto)] || (0, fs_js_1.now)() };
1015
1295
  break;
1016
1296
  }
1017
1297
  }
@@ -1022,13 +1302,16 @@ class Cortex {
1022
1302
  }
1023
1303
  return n;
1024
1304
  });
1025
- const final = (actualizada || neuron);
1305
+ if (gone || !actualizada)
1306
+ throw this.deletedMeanwhile(targetId);
1307
+ const final = actualizada;
1026
1308
  if (skippedRetired) {
1027
1309
  // Nothing was written, so nothing to index, emit or tally.
1028
1310
  return {
1029
1311
  neuron: final, action: 'skipped_retired', superseded: 0, supersedes_unmatched: [],
1030
1312
  near_duplicates: [], redacted: limpio.found,
1031
1313
  duplicate: false, updated_in_place: false, skipped_retired: skippedRetired,
1314
+ resolved_by: resolvedBy, ...(ambiguous?.length ? { ambiguous } : {}),
1032
1315
  };
1033
1316
  }
1034
1317
  await this.reindex(final);
@@ -1036,18 +1319,20 @@ class Cortex {
1036
1319
  // key and the least likely to be worth sharing.
1037
1320
  if (emitir)
1038
1321
  await this.emit(final.id, emitir);
1039
- // A reconfirming re-learn reaches the team as a check: the latest wins.
1040
- const v = verificar;
1041
- if (v)
1042
- await this.emit(final.id, { kind: 'verify', eid: v.eid, ekind: 'fact', at: v.at });
1322
+ // The retirement travels too (2.11), exactly as crbro_revise sends it:
1323
+ // before, teammates got the new value and kept the old one as current.
1324
+ for (const fid of retiradosAhora) {
1325
+ const f = final.facts.find(x => x.id === fid);
1326
+ await this.emit(final.id, { kind: 'status', fid, to: 'superseded', at: f?.revised || (0, fs_js_1.now)() });
1327
+ }
1043
1328
  return {
1044
1329
  neuron: final, action, superseded, supersedes_unmatched: supersedesUnmatched,
1045
1330
  near_duplicates: nearDuplicates, redacted: limpio.found,
1046
1331
  duplicate, updated_in_place: updatedInPlace, skipped_retired: null,
1047
1332
  ...(confirmations !== undefined ? { confirmations } : {}),
1048
1333
  ...(shelf ? { shelf } : {}),
1049
- ...(reconfirmed ? { reconfirmed } : {}),
1050
1334
  ...(shelfLengthened ? { shelf_lengthened: true } : {}),
1335
+ resolved_by: resolvedBy, ...(ambiguous?.length ? { ambiguous } : {}),
1051
1336
  };
1052
1337
  }
1053
1338
  /**
@@ -1060,14 +1345,18 @@ class Cortex {
1060
1345
  * caller usually has the text in front of it and not the hash.
1061
1346
  */
1062
1347
  async revise(neuronRef, targets, options) {
1063
- const neuron = (await this.peek(neuronRef)) || (await this.findByName(neuronRef));
1348
+ const neuron = await this.resolveForWrite(neuronRef);
1064
1349
  if (!neuron)
1065
1350
  return { neuron: null, revised: 0, unmatched: targets.slice() };
1351
+ if (options?.note)
1352
+ options = { ...options, note: (0, secrets_js_1.redact)(options.note).text }; // G8: notes are stored and shared
1066
1353
  const status = options?.status || 'superseded';
1067
1354
  let revised = 0;
1068
1355
  let unmatched = targets.slice();
1069
- const actualizada = await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(neuron.id), current => {
1070
- const n = current || neuron;
1356
+ const actualizada = await this.updateNeuron(neuron.id, current => {
1357
+ if (!current)
1358
+ return null; // deleted meanwhile: never resurrect it (2.11)
1359
+ const n = current;
1071
1360
  const retirado = status === 'active'
1072
1361
  ? this.reactivate(n, targets, options?.note)
1073
1362
  : this.retire(n, targets, options?.replacedBy, status, options?.note);
@@ -1114,17 +1403,21 @@ class Cortex {
1114
1403
  * clock restarts too: the fact is shared, and so is the world it describes.
1115
1404
  */
1116
1405
  async verify(neuronRef, targets) {
1406
+ // Validar antes de cualquier escritura, incluida la creación de neuronas.
1407
+ const evidence = (0, evidence_js_1.makeVerification)(targets.evidence, (0, fs_js_1.now)());
1117
1408
  const facts = (targets.facts || []).filter(t => typeof t === 'string');
1118
1409
  const entries = (targets.entries || []).filter(t => typeof t === 'string');
1119
- const neuron = (await this.peek(neuronRef)) || (await this.findByName(neuronRef));
1410
+ const neuron = await this.resolveForWrite(neuronRef);
1120
1411
  if (!neuron)
1121
1412
  return { neuron: null, verified: [], unmatched: [...facts, ...entries], retired: [] };
1122
1413
  const verified = [];
1123
1414
  let unmatched = [];
1124
1415
  let retired = [];
1125
1416
  const emitir = [];
1126
- const actualizada = await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(neuron.id), current => {
1127
- const n = current || neuron;
1417
+ const actualizada = await this.updateNeuron(neuron.id, current => {
1418
+ if (!current)
1419
+ return null; // deleted meanwhile: never resurrect it (2.11)
1420
+ const n = current;
1128
1421
  const cuando = (0, fs_js_1.now)();
1129
1422
  verified.length = 0;
1130
1423
  unmatched = [];
@@ -1143,10 +1436,13 @@ class Cortex {
1143
1436
  }
1144
1437
  for (const f of vivos) {
1145
1438
  f.id = f.id || (0, hash_js_1.factId)(f.text);
1146
- f.verified = cuando;
1439
+ // Un check explícito posterior al cambio debe sobrevivir incluso
1440
+ // cuando ambas llamadas caben en el mismo milisegundo.
1441
+ f.verified = new Date(Math.max(Date.parse(cuando), (Date.parse(f.verification_invalidated_at || '') || 0) + 1)).toISOString();
1442
+ f.verification = { ...evidence, at: f.verified };
1147
1443
  if (!verified.includes(f.id))
1148
1444
  verified.push(f.id);
1149
- emitir.push({ eid: f.id, ekind: 'fact', at: cuando });
1445
+ emitir.push({ eid: f.id, ekind: 'fact', at: f.verified, verification: f.verification });
1150
1446
  }
1151
1447
  }
1152
1448
  const candidatas = [
@@ -1168,9 +1464,10 @@ class Cortex {
1168
1464
  for (const c of vivas) {
1169
1465
  const k = (0, ops_js_1.entryId)(c.text);
1170
1466
  (n.entry_verified ||= {})[k] = cuando;
1467
+ (n.entry_verification ||= {})[k] = { ...evidence, at: cuando };
1171
1468
  if (!verified.includes(k))
1172
1469
  verified.push(k);
1173
- emitir.push({ eid: k, ekind: 'entry', at: cuando });
1470
+ emitir.push({ eid: k, ekind: 'entry', at: cuando, verification: n.entry_verification[k] });
1174
1471
  }
1175
1472
  }
1176
1473
  if (verified.length === 0)
@@ -1214,6 +1511,7 @@ class Cortex {
1214
1511
  let count = 0;
1215
1512
  const wanted = targets.map(t => t.trim().toLowerCase());
1216
1513
  const hit = new Set();
1514
+ const ids = [];
1217
1515
  for (const fact of neuron.facts) {
1218
1516
  if (fact.status === 'superseded' || fact.status === 'retracted')
1219
1517
  continue;
@@ -1234,8 +1532,9 @@ class Cortex {
1234
1532
  if (note)
1235
1533
  fact.revision_note = note;
1236
1534
  count++;
1535
+ ids.push(fid);
1237
1536
  }
1238
- return { count, unmatched: targets.filter((_, i) => !hit.has(i)) };
1537
+ return { count, unmatched: targets.filter((_, i) => !hit.has(i)), ids };
1239
1538
  }
1240
1539
  /**
1241
1540
  * Bring retired facts back. The inverse of retire: only superseded or
@@ -1280,15 +1579,19 @@ class Cortex {
1280
1579
  * survives a sync because applyOps copies and prunes it.
1281
1580
  */
1282
1581
  async retireEntries(neuronRef, targets, options) {
1283
- const neuron = (await this.peek(neuronRef)) || (await this.findByName(neuronRef));
1582
+ const neuron = await this.resolveForWrite(neuronRef);
1284
1583
  if (!neuron)
1285
1584
  return { neuron: null, revised: 0, unmatched: targets.slice() };
1585
+ if (options?.note)
1586
+ options = { ...options, note: (0, secrets_js_1.redact)(options.note).text }; // G8: notes are stored and shared
1286
1587
  const status = options?.status || 'superseded';
1287
1588
  const wanted = targets.map(t => (0, ops_js_1.normalizeText)(t).toLowerCase());
1288
1589
  let revised = 0;
1289
1590
  let unmatched = targets.slice();
1290
- const actualizada = await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(neuron.id), current => {
1291
- const n = current || neuron;
1591
+ const actualizada = await this.updateNeuron(neuron.id, current => {
1592
+ if (!current)
1593
+ return null; // deleted meanwhile: never resurrect it (2.11)
1594
+ const n = current;
1292
1595
  const estados = n.entry_status || {};
1293
1596
  const hit = new Set();
1294
1597
  const cuando = (0, fs_js_1.now)();
@@ -1351,7 +1654,7 @@ class Cortex {
1351
1654
  * Not emitted: NeuronOp is only an announcement, and metadata is local.
1352
1655
  */
1353
1656
  async setMeta(neuronRef, meta) {
1354
- const neuron = (await this.peek(neuronRef)) || (await this.findByName(neuronRef));
1657
+ const neuron = await this.resolveForWrite(neuronRef);
1355
1658
  if (!neuron)
1356
1659
  return { neuron: null, changed: [], redacted: [] };
1357
1660
  let redacted = [];
@@ -1367,8 +1670,10 @@ class Cortex {
1367
1670
  const domain = meta.domain === undefined ? undefined : meta.domain.trim();
1368
1671
  const name = meta.name === undefined ? undefined : meta.name.trim();
1369
1672
  const changed = [];
1370
- const actualizada = await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(neuron.id), current => {
1371
- const n = current || neuron;
1673
+ const actualizada = await this.updateNeuron(neuron.id, current => {
1674
+ if (!current)
1675
+ return null; // deleted meanwhile: never resurrect it (2.11)
1676
+ const n = current;
1372
1677
  if (summary !== undefined && summary !== n.summary) {
1373
1678
  n.summary = summary;
1374
1679
  changed.push('summary');
@@ -1414,7 +1719,7 @@ class Cortex {
1414
1719
  // The other way round, an exact neuron id could land on a near-miss whose
1415
1720
  // prefix-stripped id matches first, and the map would be written to one
1416
1721
  // neuron while every reader resolves the other.
1417
- let neuron = (await this.peek(neuronRef)) || (await this.findByName(neuronRef));
1722
+ let neuron = await this.resolveForWrite(neuronRef);
1418
1723
  let action = 'updated';
1419
1724
  if (!neuron) {
1420
1725
  const nType = options?.neuronType || (0, ids_js_1.inferNeuronType)(neuronRef);
@@ -1423,8 +1728,10 @@ class Cortex {
1423
1728
  }
1424
1729
  const cuando = (0, fs_js_1.now)();
1425
1730
  const vaciar = content.trim() === '';
1426
- const actualizada = await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(neuron.id), current => {
1427
- const n = current || neuron;
1731
+ const actualizada = await this.updateNeuron(neuron.id, current => {
1732
+ if (!current)
1733
+ return null; // deleted meanwhile: never resurrect it (2.11)
1734
+ const n = current;
1428
1735
  // An empty map is not a map: writing '' clears it outright, so the
1429
1736
  // reader never meets a document that exists but says nothing.
1430
1737
  if (vaciar)
@@ -1435,7 +1742,9 @@ class Cortex {
1435
1742
  n.access_count = (n.access_count || 0) + 1;
1436
1743
  return n;
1437
1744
  });
1438
- const final = (actualizada || neuron);
1745
+ if (!actualizada)
1746
+ throw this.deletedMeanwhile(neuron.id);
1747
+ const final = actualizada;
1439
1748
  await this.reindex(final);
1440
1749
  // Clearing emits too: an empty map op is the tombstone that wins the LWW
1441
1750
  // on every machine, so a stale copy cannot resurrect the cleared map.
@@ -1451,36 +1760,48 @@ class Cortex {
1451
1760
  * slice, which is the only number a pager can trust.
1452
1761
  */
1453
1762
  async listWithTotal(options) {
1454
- const all = await this.list({ ...options, limit: Number.MAX_SAFE_INTEGER, offset: 0 });
1763
+ const unreadable = [];
1764
+ const all = await this.list({ ...options, limit: Number.MAX_SAFE_INTEGER, offset: 0 }, unreadable);
1455
1765
  const limit = options?.limit || 50;
1456
1766
  const offset = Math.max(0, options?.offset || 0);
1457
- return { total: all.length, rows: all.slice(offset, offset + limit) };
1767
+ return { total: all.length, rows: all.slice(offset, offset + limit), unreadable };
1458
1768
  }
1459
1769
  /**
1460
1770
  * List all neurons with optional filters.
1461
1771
  */
1462
- async list(options) {
1772
+ async list(options, unreadable) {
1463
1773
  const ids = await (0, fs_js_1.listJSONFiles)(this.brain.paths.cortex);
1464
1774
  const neurons = [];
1775
+ const calor = await this.heatMap();
1465
1776
  for (const id of ids) {
1466
- const neuron = await (0, fs_js_1.readJSON)(this.brain.paths.neuron(id));
1777
+ let neuron;
1778
+ try {
1779
+ neuron = await (0, fs_js_1.readJSON)(this.brain.paths.neuron(id));
1780
+ }
1781
+ catch (err) {
1782
+ // One unreadable file is listed apart, never the end of the listing.
1783
+ const info = await this.noteUnreadable(id, err);
1784
+ unreadable?.push(info);
1785
+ continue;
1786
+ }
1467
1787
  if (!neuron)
1468
1788
  continue;
1789
+ const heat = calor.get(neuron.id) ?? neuron.heat;
1469
1790
  // Apply filters
1470
1791
  if (options?.domain && neuron.domain !== options.domain)
1471
1792
  continue;
1472
1793
  if (options?.type && neuron.type !== options.type)
1473
1794
  continue;
1474
- if (options?.min_heat && neuron.heat < options.min_heat)
1795
+ if (options?.min_heat && heat < options.min_heat)
1475
1796
  continue;
1476
1797
  neurons.push({
1477
1798
  id: neuron.id,
1478
1799
  name: neuron.name,
1479
1800
  domain: neuron.domain,
1480
1801
  type: neuron.type,
1481
- heat: neuron.heat,
1802
+ heat,
1482
1803
  last_accessed: neuron.last_accessed,
1483
- facts_count: neuron.facts.length,
1804
+ facts_count: (neuron.facts || []).length,
1484
1805
  });
1485
1806
  }
1486
1807
  // Sort by heat (descending)
@@ -1494,7 +1815,7 @@ class Cortex {
1494
1815
  * Update a neuron's summary.
1495
1816
  */
1496
1817
  async updateSummary(id, summary) {
1497
- const neuron = await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(id), current => {
1818
+ const neuron = await this.updateNeuron(id, current => {
1498
1819
  if (!current)
1499
1820
  return null;
1500
1821
  current.summary = summary;
@@ -1510,7 +1831,7 @@ class Cortex {
1510
1831
  * Add tags to a neuron.
1511
1832
  */
1512
1833
  async addTags(id, tags) {
1513
- const neuron = await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(id), current => {
1834
+ const neuron = await this.updateNeuron(id, current => {
1514
1835
  if (!current)
1515
1836
  return null;
1516
1837
  for (const tag of tags) {
@@ -1533,10 +1854,10 @@ class Cortex {
1533
1854
  * not exist at all — a credential, someone's personal data.
1534
1855
  */
1535
1856
  async forget(neuronRef, targets) {
1536
- const found = (await this.peek(neuronRef)) || (await this.findByName(neuronRef));
1857
+ const found = await this.resolveForWrite(neuronRef);
1537
1858
  if (!found)
1538
1859
  return { neuron_id: null, removed: 0, backup: null };
1539
- const backup = await this.quarantine(found);
1860
+ let backup = null;
1540
1861
  let removed = 0;
1541
1862
  const wanted = targets.map(t => t.trim().toLowerCase());
1542
1863
  // An entry goes when a target names its text or its id: the entryId the
@@ -1550,42 +1871,83 @@ class Cortex {
1550
1871
  return wanted.includes(t) || wanted.includes((0, ops_js_1.entryId)(text).toLowerCase())
1551
1872
  || (!!ownId && wanted.includes(ownId.toLowerCase()));
1552
1873
  };
1553
- const after = await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(found.id), current => {
1874
+ const factMatches = (f) => {
1875
+ const fid = (f.id || (0, hash_js_1.factId)(f.text)).toLowerCase();
1876
+ return wanted.includes(fid) || wanted.includes(f.text.trim().toLowerCase());
1877
+ };
1878
+ // What left, read inside the lock: the quarantine copy and the emitted
1879
+ // removals describe exactly what this call took out, not a snapshot read
1880
+ // before somebody else wrote.
1881
+ let quitado = null;
1882
+ const after = await this.updateNeuron(found.id, async (current) => {
1554
1883
  if (!current)
1555
1884
  return null;
1556
- const antes = current.facts.length + current.decisions.length +
1557
- current.patterns.length + current.preferences.length;
1885
+ const q = {
1886
+ quarantine_format: 2, kind: 'entries',
1887
+ neuron_id: current.id, name: current.name, type: current.type, domain: current.domain, taken_at: (0, fs_js_1.now)(),
1888
+ facts: (current.facts || []).filter(factMatches).map(copyFact),
1889
+ decisions: (current.decisions || []).filter(d => matches(d.text, d.id)).map(d => ({ ...d })),
1890
+ patterns: (current.patterns || []).filter(p => matches(p)),
1891
+ preferences: (current.preferences || []).filter(p => matches(p)),
1892
+ errors: (current.errors || []).filter(e => matches(e)),
1893
+ debts: (current.debts || []).filter(d => matches(d)),
1894
+ ...(current.map && wanted.includes(current.map.text.trim().toLowerCase()) ? { map: { ...current.map } } : {}),
1895
+ };
1896
+ removed = q.facts.length + q.decisions.length + q.patterns.length + q.preferences.length
1897
+ + q.errors.length + q.debts.length + (q.map ? 1 : 0);
1898
+ // Nothing matched: nothing is written, and no copy is kept either.
1899
+ if (removed === 0)
1900
+ return null;
1901
+ // The sidecars of what leaves travel with it, so restore puts each entry
1902
+ // back with its date, retirement, provenance and check.
1903
+ const llaves = new Set([...q.decisions.map(d => d.text), ...q.patterns, ...q.preferences, ...q.errors, ...q.debts].map(ops_js_1.entryId));
1904
+ const parte = (rec) => {
1905
+ if (!rec)
1906
+ return undefined;
1907
+ const out = {};
1908
+ for (const k of Object.keys(rec))
1909
+ if (llaves.has(k))
1910
+ out[k] = rec[k];
1911
+ return Object.keys(out).length ? out : undefined;
1912
+ };
1913
+ const fechas = parte(current.entry_dates);
1914
+ if (fechas)
1915
+ q.entry_dates = fechas;
1916
+ const estados = parte(current.entry_status);
1917
+ if (estados)
1918
+ q.entry_status = estados;
1919
+ const origenes = parte(current.entry_source);
1920
+ if (origenes)
1921
+ q.entry_source = origenes;
1922
+ const verificadas = parte(current.entry_verified);
1923
+ if (verificadas)
1924
+ q.entry_verified = verificadas;
1925
+ const evidencias = parte(current.entry_verification);
1926
+ if (evidencias)
1927
+ q.entry_verification = evidencias;
1928
+ // The copy first, inside the lock: if it cannot be written (a full disk,
1929
+ // encryption on without its key) nothing is removed.
1930
+ backup = await (0, quarantine_js_1.writeQuarantine)(this.brain.paths.quarantine, current.id, q, 'entries');
1931
+ quitado = q;
1558
1932
  // Dated entries before the removal, so their dates can go with them.
1559
1933
  const fechadasAntes = [
1560
1934
  ...current.patterns, ...current.preferences,
1561
1935
  ...(current.errors || []), ...(current.debts || []),
1562
1936
  ];
1563
- current.facts = current.facts.filter(f => {
1564
- const fid = (f.id || (0, hash_js_1.factId)(f.text)).toLowerCase();
1565
- return !(wanted.includes(fid) || wanted.includes(f.text.trim().toLowerCase()));
1566
- });
1937
+ current.facts = current.facts.filter(f => !factMatches(f));
1567
1938
  // Decisions, patterns and preferences too. A credential is no less
1568
1939
  // exposed for sitting in one of those, and nothing else could remove it.
1569
1940
  current.decisions = current.decisions.filter(d => !matches(d.text, d.id));
1570
1941
  current.patterns = current.patterns.filter(p => !matches(p));
1571
1942
  current.preferences = current.preferences.filter(p => !matches(p));
1572
- const erroresAntes = (current.errors || []).length;
1573
1943
  if (current.errors) {
1574
1944
  current.errors = current.errors.filter(e => !matches(e));
1575
1945
  }
1576
- const deudasAntes = (current.debts || []).length;
1577
1946
  if (current.debts) {
1578
1947
  current.debts = current.debts.filter(d => !matches(d));
1579
1948
  }
1580
- let mapaBorrado = 0;
1581
- if (current.map && wanted.includes(current.map.text.trim().toLowerCase())) {
1949
+ if (q.map)
1582
1950
  delete current.map;
1583
- mapaBorrado = 1;
1584
- }
1585
- removed = antes - (current.facts.length + current.decisions.length +
1586
- current.patterns.length + current.preferences.length) + (erroresAntes - (current.errors || []).length) + (deudasAntes - (current.debts || []).length) + mapaBorrado;
1587
- if (removed === 0)
1588
- return null;
1589
1951
  // Drop the dates of what left, so the sidecar never outlives its entry.
1590
1952
  if (current.entry_dates) {
1591
1953
  const quedan = new Set([
@@ -1619,6 +1981,15 @@ class Cortex {
1619
1981
  if (Object.keys(current.entry_verified).length === 0)
1620
1982
  delete current.entry_verified;
1621
1983
  }
1984
+ if (current.entry_verification) {
1985
+ const vivos = liveEntryKeys(current);
1986
+ for (const k of Object.keys(current.entry_verification)) {
1987
+ if (!vivos.has(k))
1988
+ delete current.entry_verification[k];
1989
+ }
1990
+ if (Object.keys(current.entry_verification).length === 0)
1991
+ delete current.entry_verification;
1992
+ }
1622
1993
  // And for provenance: a forgotten teammate line that comes back through
1623
1994
  // a sync replay must be labelled by that replay, not by a stale key.
1624
1995
  if (current.entry_source) {
@@ -1633,7 +2004,8 @@ class Cortex {
1633
2004
  current.last_accessed = (0, fs_js_1.now)();
1634
2005
  return current;
1635
2006
  });
1636
- if (removed > 0 && after) {
2007
+ const q = quitado;
2008
+ if (removed > 0 && after && q) {
1637
2009
  await this.reindex(after);
1638
2010
  // The removal must travel, or the next sync resurrects from the team
1639
2011
  // log exactly what the user asked to destroy. Facts retract (status
@@ -1641,33 +2013,22 @@ class Cortex {
1641
2013
  // a purge op keyed by content hash; a cleared map emits the empty-map
1642
2014
  // tombstone that wins the LWW. The emitter no-ops on unshared neurons.
1643
2015
  const cuando = (0, fs_js_1.now)();
1644
- for (const f of found.facts || []) {
1645
- const fid = (f.id || (0, hash_js_1.factId)(f.text)).toLowerCase();
1646
- if (wanted.includes(fid) || wanted.includes(f.text.trim().toLowerCase())) {
1647
- await this.emit(found.id, { kind: 'status', fid: f.id || (0, hash_js_1.factId)(f.text), to: 'retracted', at: cuando, why: 'forgotten' });
1648
- }
2016
+ for (const f of q.facts) {
2017
+ await this.emit(found.id, { kind: 'status', fid: f.id || (0, hash_js_1.factId)(f.text), to: 'retracted', at: cuando, why: 'forgotten' });
1649
2018
  }
1650
- for (const d of found.decisions || []) {
1651
- if (matches(d.text, d.id)) {
1652
- await this.emit(found.id, { kind: 'decision_purge', key: (0, ops_js_1.entryId)(d.text), at: cuando });
1653
- }
2019
+ for (const d of q.decisions) {
2020
+ await this.emit(found.id, { kind: 'decision_purge', key: (0, ops_js_1.entryId)(d.text), at: cuando });
1654
2021
  }
1655
- for (const p of found.patterns || []) {
1656
- if (matches(p)) {
1657
- await this.emit(found.id, { kind: 'pattern_purge', key: (0, ops_js_1.entryId)(p), at: cuando });
1658
- }
2022
+ for (const p of q.patterns) {
2023
+ await this.emit(found.id, { kind: 'pattern_purge', key: (0, ops_js_1.entryId)(p), at: cuando });
1659
2024
  }
1660
- for (const e of found.errors || []) {
1661
- if (matches(e)) {
1662
- await this.emit(found.id, { kind: 'error_purge', key: (0, ops_js_1.entryId)(e), at: cuando });
1663
- }
2025
+ for (const e of q.errors) {
2026
+ await this.emit(found.id, { kind: 'error_purge', key: (0, ops_js_1.entryId)(e), at: cuando });
1664
2027
  }
1665
- for (const d of found.debts || []) {
1666
- if (matches(d)) {
1667
- await this.emit(found.id, { kind: 'debt_purge', key: (0, ops_js_1.entryId)(d), at: cuando });
1668
- }
2028
+ for (const d of q.debts) {
2029
+ await this.emit(found.id, { kind: 'debt_purge', key: (0, ops_js_1.entryId)(d), at: cuando });
1669
2030
  }
1670
- if (found.map && wanted.includes(found.map.text.trim().toLowerCase())) {
2031
+ if (q.map) {
1671
2032
  await this.emit(found.id, { kind: 'map', text: '', at: cuando });
1672
2033
  }
1673
2034
  }
@@ -1684,37 +2045,60 @@ class Cortex {
1684
2045
  * emitted: there is no neuron-delete op.
1685
2046
  */
1686
2047
  async forgetNeuron(neuronRef) {
1687
- const found = (await this.peek(neuronRef)) || (await this.findByName(neuronRef));
1688
- if (!found) {
1689
- return {
1690
- neuron_id: null, backup: null,
1691
- counts: { facts: 0, decisions: 0, patterns: 0, preferences: 0, errors: 0, debts: 0, connections: 0 },
1692
- };
1693
- }
1694
- const backup = await this.quarantine(found);
1695
- await this.deleteNeuronFile(found.id);
2048
+ const vacio = { facts: 0, decisions: 0, patterns: 0, preferences: 0, errors: 0, debts: 0, connections: 0 };
2049
+ const found = await this.resolveForWrite(neuronRef);
2050
+ if (!found)
2051
+ return { neuron_id: null, backup: null, counts: vacio };
2052
+ // Copy and delete under the neuron's lock (2.11): a learn landing between
2053
+ // the copy and the unlink used to be deleted without being in the copy.
2054
+ const r = await this.quarantineAndDelete(found.id);
2055
+ if (!r)
2056
+ return { neuron_id: null, backup: null, counts: vacio };
2057
+ const n = r.neuron;
1696
2058
  return {
1697
- neuron_id: found.id,
1698
- backup,
2059
+ neuron_id: n.id,
2060
+ backup: r.backup,
1699
2061
  counts: {
1700
- facts: (found.facts || []).length,
1701
- decisions: (found.decisions || []).length,
1702
- patterns: (found.patterns || []).length,
1703
- preferences: (found.preferences || []).length,
1704
- errors: (found.errors || []).length,
1705
- debts: (found.debts || []).length,
1706
- connections: (found.connections || []).length,
2062
+ facts: (n.facts || []).length,
2063
+ decisions: (n.decisions || []).length,
2064
+ patterns: (n.patterns || []).length,
2065
+ preferences: (n.preferences || []).length,
2066
+ errors: (n.errors || []).length,
2067
+ debts: (n.debts || []).length,
2068
+ connections: (n.connections || []).length,
1707
2069
  },
1708
2070
  };
1709
2071
  }
1710
2072
  /**
1711
- * Quarantine copies of one neuron, newest first. Stamps sort lexically, so
1712
- * the file name alone orders them.
2073
+ * Under the neuron's lock: read it as it is now, copy it whole to
2074
+ * quarantine, delete the file. Null when it is already gone. If the copy
2075
+ * cannot be written (encryption on without its key, a full disk) nothing is
2076
+ * deleted. Index and manifest follow outside the lock.
2077
+ */
2078
+ async quarantineAndDelete(id) {
2079
+ const file = this.brain.paths.neuron(id);
2080
+ const r = await (0, lock_js_1.withLock)(file, async () => {
2081
+ const current = await this.readStrict(id);
2082
+ if (!current)
2083
+ return null;
2084
+ const backup = await this.quarantine(current);
2085
+ await (0, fs_js_1.deleteJSON)(file);
2086
+ return { neuron: current, backup };
2087
+ });
2088
+ if (!r)
2089
+ return null;
2090
+ await this.unindex(id);
2091
+ const manifest = await this.brain.getManifest();
2092
+ await this.brain.updateManifest({ total_neurons: Math.max(0, manifest.total_neurons - 1) });
2093
+ return r;
2094
+ }
2095
+ /**
2096
+ * Quarantine copies of one neuron, newest first: whole copies and copies of
2097
+ * forgotten entries, clear or sealed. Stamps sort lexically, so the file
2098
+ * name alone orders them; `base` is what restore's `file` option takes.
1713
2099
  */
1714
2100
  async quarantineCopies(neuronId) {
1715
- const prefijo = `${neuronId}.`;
1716
- const files = await (0, fs_js_1.listJSONFiles)(this.brain.paths.quarantine);
1717
- return files.filter(f => f.startsWith(prefijo)).sort().reverse();
2101
+ return (await (0, quarantine_js_1.listQuarantine)(this.brain.paths.quarantine, neuronId)).map(c => ({ base: c.base, file: c.file }));
1718
2102
  }
1719
2103
  /**
1720
2104
  * Bring a neuron back from quarantine.
@@ -1724,30 +2108,40 @@ class Cortex {
1724
2108
  * (a forget of facts, or the topic was re-created since) the copy is
1725
2109
  * unioned into it. The quarantine file stays where it is, so a restore is
1726
2110
  * repeatable. Nothing is emitted.
2111
+ *
2112
+ * A copy of forgotten entries (2.11) holds those entries and their
2113
+ * sidecars, not the neuron: unioned into the neuron when it exists, or
2114
+ * written as a neuron of its own name, type and domain when it does not.
2115
+ * Sealed copies are opened with the at-rest key.
1727
2116
  */
1728
2117
  async restoreNeuron(neuronId, options) {
1729
2118
  const nada = { neuron: null, restored_from: null, merged_into_existing: false, moved: null };
1730
- let basename = null;
2119
+ const copias = await this.quarantineCopies(neuronId);
2120
+ let elegida;
1731
2121
  if (options?.file) {
1732
- const pedido = options.file.replace(/\.json$/i, '');
1733
- const copias = await this.quarantineCopies(neuronId);
1734
- basename = copias.includes(pedido) ? pedido : null;
2122
+ const pedido = options.file.replace(/\.json(\.enc)?$/i, '');
2123
+ elegida = copias.find(c => c.base === pedido);
1735
2124
  }
1736
2125
  else {
1737
- basename = (await this.quarantineCopies(neuronId))[0] || null;
2126
+ elegida = copias[0];
1738
2127
  }
1739
- if (!basename)
2128
+ if (!elegida)
1740
2129
  return nada;
1741
- const ruta = `${this.brain.paths.quarantine}/${basename}.json`;
1742
- const copia = await (0, fs_js_1.readJSON)(ruta);
1743
- if (!copia || !copia.id)
2130
+ const ruta = node_path_1.default.join(this.brain.paths.quarantine, elegida.file);
2131
+ const leida = await (0, quarantine_js_1.readQuarantine)(ruta);
2132
+ let copia = null;
2133
+ if ((0, quarantine_js_1.isEntriesCopy)(leida))
2134
+ copia = (0, quarantine_js_1.entriesAsNeuron)(leida);
2135
+ else if (leida && typeof leida === 'object' && leida.id)
2136
+ copia = leida;
2137
+ if (!copia)
1744
2138
  return nada;
1745
2139
  copia.id = neuronId;
1746
2140
  // Decide inside the lock, like create: whether the neuron exists again is
1747
2141
  // only known for sure while nobody else can write it.
1748
2142
  let escrita = false;
1749
2143
  let moved = null;
1750
- const saved = await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(neuronId), current => {
2144
+ const saved = await this.updateNeuron(neuronId, current => {
1751
2145
  if (!current) {
1752
2146
  escrita = true;
1753
2147
  return copia;
@@ -1776,25 +2170,48 @@ class Cortex {
1776
2170
  * re-creates it).
1777
2171
  */
1778
2172
  async mergeNeurons(fromRef, intoRef) {
1779
- const from = (await this.peek(fromRef)) || (await this.findByName(fromRef));
1780
- const into = (await this.peek(intoRef)) || (await this.findByName(intoRef));
2173
+ const from = await this.resolveForWrite(fromRef);
2174
+ const into = await this.resolveForWrite(intoRef);
1781
2175
  if (!from || !into || from.id === into.id) {
1782
2176
  return { from: from?.id ?? null, into: into?.id ?? null, backup: null, moved: null };
1783
2177
  }
1784
- const backup = await this.quarantine(from);
2178
+ // Under the source's lock (2.11): what is copied, unioned and deleted is
2179
+ // the source as it is at that moment, so a learn into it meanwhile is
2180
+ // either in all three or waits and then finds it gone.
1785
2181
  let antes = into;
1786
2182
  let moved = null;
1787
- const saved = await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(into.id), current => {
1788
- antes = current || into;
1789
- const r = unionNeuron(antes, from);
1790
- moved = r.moved;
1791
- return r.neuron;
2183
+ let intoGone = false;
2184
+ const hecho = await (0, lock_js_1.withLock)(this.brain.paths.neuron(from.id), async () => {
2185
+ const fuente = await this.readStrict(from.id);
2186
+ if (!fuente)
2187
+ return null;
2188
+ const backup = await this.quarantine(fuente);
2189
+ const saved = await this.updateNeuron(into.id, current => {
2190
+ if (!current) {
2191
+ intoGone = true;
2192
+ return null;
2193
+ }
2194
+ antes = current;
2195
+ const r = unionNeuron(current, fuente);
2196
+ moved = r.moved;
2197
+ return r.neuron;
2198
+ });
2199
+ if (!saved || intoGone)
2200
+ return { backup, final: null };
2201
+ await (0, fs_js_1.deleteJSON)(this.brain.paths.neuron(from.id));
2202
+ return { backup, final: saved };
1792
2203
  });
1793
- const final = (saved || into);
2204
+ if (!hecho)
2205
+ return { from: null, into: into.id, backup: null, moved: null };
2206
+ if (!hecho.final)
2207
+ throw this.deletedMeanwhile(into.id);
2208
+ const final = hecho.final;
1794
2209
  await this.reindex(final);
1795
2210
  await this.emitAdded(antes, final);
1796
- await this.deleteNeuronFile(from.id);
1797
- return { from: from.id, into: into.id, backup, moved };
2211
+ await this.unindex(from.id);
2212
+ const manifest = await this.brain.getManifest();
2213
+ await this.brain.updateManifest({ total_neurons: Math.max(0, manifest.total_neurons - 1) });
2214
+ return { from: from.id, into: into.id, backup: hecho.backup, moved };
1798
2215
  }
1799
2216
  /**
1800
2217
  * Emit what a union added, by diffing the result against what the target
@@ -1803,18 +2220,24 @@ class Cortex {
1803
2220
  */
1804
2221
  async emitAdded(antes, final) {
1805
2222
  const cuando = (0, fs_js_1.now)();
1806
- const teniaFact = new Set(antes.facts.map(f => f.id || (0, hash_js_1.factId)(f.text)));
2223
+ const teniaFact = new Map(antes.facts.map(f => [f.id || (0, hash_js_1.factId)(f.text), f]));
1807
2224
  for (const f of final.facts) {
1808
2225
  const fid = f.id || (0, hash_js_1.factId)(f.text);
1809
- if (teniaFact.has(fid))
1810
- continue;
1811
- await this.emit(final.id, { kind: 'fact', text: f.text, fid, conf: f.confidence ?? 1, at: f.added, src: f.source, keys: f.keys,
1812
- ...(f.shelf_life ? { shelf: f.shelf_life } : {}) });
1813
- if (f.verified)
1814
- await this.emit(final.id, { kind: 'verify', eid: fid, ekind: 'fact', at: f.verified });
2226
+ const previo = teniaFact.get(fid) || antes.facts.find(p => (0, ops_js_1.normalizeText)(p.text) === (0, ops_js_1.normalizeText)(f.text));
2227
+ const metadata = (v) => JSON.stringify([v.keys, v.shelf_life, v.reference, v.temporal, v.verification_invalidated_at]);
2228
+ if (!previo || metadata(previo) !== metadata(f)) {
2229
+ await this.emit(final.id, { kind: 'fact', text: f.text, fid, conf: f.confidence ?? 1, at: f.added, src: f.source, keys: f.keys,
2230
+ ...(f.reference ? { reference: f.reference } : {}), ...(f.temporal ? { temporal: f.temporal } : {}),
2231
+ ...(f.verification_invalidated_at ? { verification_invalidated_at: f.verification_invalidated_at } : {}),
2232
+ ...(f.shelf_life ? { shelf: f.shelf_life } : {}) });
2233
+ }
2234
+ if (f.verified && (previo?.verified !== f.verified || JSON.stringify(previo?.verification) !== JSON.stringify(f.verification))) {
2235
+ await this.emit(final.id, { kind: 'verify', eid: fid, ekind: 'fact', at: f.verified,
2236
+ ...(f.verification ? { verification: f.verification } : {}) });
2237
+ }
1815
2238
  // A moved fact that was already retired must arrive retired, or the
1816
2239
  // teammates would see as current what the source had superseded.
1817
- if (f.status === 'superseded' || f.status === 'retracted') {
2240
+ if (previo?.status !== f.status && (f.status === 'superseded' || f.status === 'retracted')) {
1818
2241
  await this.emit(final.id, { kind: 'status', fid, to: f.status, at: f.revised || cuando, why: f.revision_note });
1819
2242
  }
1820
2243
  }
@@ -1842,9 +2265,10 @@ class Cortex {
1842
2265
  }
1843
2266
  // Checks of decisions and patterns that arrived with them, or are newer than the target's.
1844
2267
  for (const [k, at] of Object.entries(final.entry_verified || {})) {
1845
- if (antes.entry_verified?.[k] === at)
2268
+ if (antes.entry_verified?.[k] === at && JSON.stringify(antes.entry_verification?.[k]) === JSON.stringify(final.entry_verification?.[k]))
1846
2269
  continue;
1847
- await this.emit(final.id, { kind: 'verify', eid: k, ekind: 'entry', at });
2270
+ await this.emit(final.id, { kind: 'verify', eid: k, ekind: 'entry', at,
2271
+ ...(final.entry_verification?.[k] ? { verification: final.entry_verification[k] } : {}) });
1848
2272
  }
1849
2273
  // Preferences are never emitted, here as in learn.
1850
2274
  }
@@ -1862,7 +2286,7 @@ class Cortex {
1862
2286
  * moves: it describes the system, not an entry.
1863
2287
  */
1864
2288
  async moveEntries(fromRef, targets, intoRef, options) {
1865
- const from = (await this.peek(fromRef)) || (await this.findByName(fromRef));
2289
+ const from = await this.resolveForWrite(fromRef);
1866
2290
  if (!from)
1867
2291
  return { from: null, into: null, created: false, moved: 0, unmatched: targets, backup: null };
1868
2292
  const wanted = new Set(targets.map(t => t.trim().toLowerCase()).filter(Boolean));
@@ -1891,6 +2315,7 @@ class Cortex {
1891
2315
  entry_dates: { ...(from.entry_dates || {}) },
1892
2316
  entry_status: { ...(from.entry_status || {}) },
1893
2317
  entry_verified: { ...(from.entry_verified || {}) },
2318
+ entry_verification: structuredClone(from.entry_verification || {}),
1894
2319
  };
1895
2320
  delete parte.map;
1896
2321
  const textos = [
@@ -1901,7 +2326,7 @@ class Cortex {
1901
2326
  const unmatched = targets.filter(t => !usados.has(t.trim().toLowerCase()));
1902
2327
  if (textos.length === 0)
1903
2328
  return { from: from.id, into: null, created: false, moved: 0, unmatched, backup: null };
1904
- let into = (await this.peek(intoRef)) || (await this.findByName(intoRef));
2329
+ let into = await this.resolveForWrite(intoRef);
1905
2330
  let created = false;
1906
2331
  if (!into) {
1907
2332
  into = await this.create(intoRef, from.type, options?.domain || from.domain);
@@ -1910,7 +2335,7 @@ class Cortex {
1910
2335
  if (into.id === from.id)
1911
2336
  return { from: from.id, into: into.id, created: false, moved: 0, unmatched, backup: null };
1912
2337
  let antes = into;
1913
- const saved = await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(into.id), current => {
2338
+ const saved = await this.updateNeuron(into.id, current => {
1914
2339
  antes = current || into;
1915
2340
  return unionNeuron(antes, parte).neuron;
1916
2341
  });
@@ -1954,8 +2379,10 @@ class Cortex {
1954
2379
  const targetId = target.id;
1955
2380
  for (const n of sources)
1956
2381
  await this.quarantine(n);
1957
- const saved = await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(targetId), current => {
1958
- let acc = (current || target);
2382
+ const saved = await this.updateNeuron(targetId, current => {
2383
+ if (!current)
2384
+ return null; // deleted meanwhile: never resurrect it (2.11)
2385
+ let acc = current;
1959
2386
  for (const n of sources) {
1960
2387
  if (n.id === targetId)
1961
2388
  continue;
@@ -1969,13 +2396,36 @@ class Cortex {
1969
2396
  }
1970
2397
  return acc;
1971
2398
  });
1972
- const final = (saved || target);
2399
+ if (!saved)
2400
+ throw this.deletedMeanwhile(targetId);
2401
+ const final = saved;
1973
2402
  await this.reindex(final);
1974
2403
  const folded = [];
1975
2404
  for (const n of sources) {
1976
2405
  if (n.id === targetId)
1977
2406
  continue;
1978
- await this.deleteNeuronFile(n.id);
2407
+ // Delete only the source as it was read: one written since keeps its
2408
+ // file (its old line is in the digest too — a duplicate, never a loss).
2409
+ const borrada = await (0, lock_js_1.withLock)(this.brain.paths.neuron(n.id), async () => {
2410
+ let ahora = null;
2411
+ try {
2412
+ ahora = await (0, fs_js_1.readJSON)(this.brain.paths.neuron(n.id));
2413
+ }
2414
+ catch {
2415
+ return false;
2416
+ }
2417
+ if (!ahora || JSON.stringify(ahora) !== JSON.stringify(n))
2418
+ return false;
2419
+ await (0, fs_js_1.deleteJSON)(this.brain.paths.neuron(n.id));
2420
+ return true;
2421
+ });
2422
+ if (!borrada) {
2423
+ skipped.push(n.id);
2424
+ continue;
2425
+ }
2426
+ await this.unindex(n.id);
2427
+ const manifest = await this.brain.getManifest();
2428
+ await this.brain.updateManifest({ total_neurons: Math.max(0, manifest.total_neurons - 1) });
1979
2429
  folded.push(n.id);
1980
2430
  }
1981
2431
  const entries = final.facts.length + final.decisions.length + final.patterns.length
@@ -1989,7 +2439,7 @@ class Cortex {
1989
2439
  async auditSecrets() {
1990
2440
  const out = [];
1991
2441
  for (const id of await (0, fs_js_1.listJSONFiles)(this.brain.paths.cortex)) {
1992
- const n = await (0, fs_js_1.readJSON)(this.brain.paths.neuron(id));
2442
+ const n = await this.peek(id); // an unreadable file is skipped and listed in unreadable()
1993
2443
  if (!n)
1994
2444
  continue;
1995
2445
  const kinds = new Set();
@@ -2007,8 +2457,9 @@ class Cortex {
2007
2457
  // Every field, not just facts. A credential is just as exposed sitting in
2008
2458
  // a decision or a preference, and those were invisible here — an audit
2009
2459
  // could report a neuron clean while a key sat in preferences[0].
2010
- const facts = contar((n.facts || []).map(f => f.text));
2011
- const decisions = contar((n.decisions || []).map(d => d.text));
2460
+ // Rationales, aliases and revision notes count with their entry (G8).
2461
+ const facts = contar((n.facts || []).map(f => [f.text, ...(f.keys || []), f.revision_note || ''].join('\n')));
2462
+ const decisions = contar((n.decisions || []).map(d => `${d.text}\n${d.rationale || ''}`));
2012
2463
  const patterns = contar(n.patterns || []);
2013
2464
  const preferences = contar(n.preferences || []);
2014
2465
  const errors = contar(n.errors || []);