crbro-memory 2.10.0 → 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 (205) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +256 -465
  3. package/SECURITY.md +114 -12
  4. package/bin/crbro.mjs +539 -300
  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 +139 -19
  22. package/dist/engine/brain.js +22 -6
  23. package/dist/engine/codexconfig.js +476 -0
  24. package/dist/engine/cortex.js +667 -232
  25. package/dist/engine/doctor.js +312 -14
  26. package/dist/engine/heat.js +81 -37
  27. package/dist/engine/hippocampus.js +18 -7
  28. package/dist/engine/hookrefresh.js +296 -0
  29. package/dist/engine/hookverify.js +15 -3
  30. package/dist/engine/installer.js +1022 -0
  31. package/dist/engine/installio.js +279 -0
  32. package/dist/engine/keychain.js +721 -162
  33. package/dist/engine/maintenance.js +355 -87
  34. package/dist/engine/modinstall.js +98 -45
  35. package/dist/engine/prefrontal.js +29 -6
  36. package/dist/engine/quarantine.js +204 -0
  37. package/dist/engine/secrets.js +41 -5
  38. package/dist/engine/shelf.js +25 -3
  39. package/dist/engine/synapses.js +183 -80
  40. package/dist/engine/triggers.js +141 -11
  41. package/dist/index.js +37 -3
  42. package/dist/mcp-surface.json +1 -0
  43. package/dist/miner/extractor.js +64 -17
  44. package/dist/miner/scheduler.js +144 -68
  45. package/dist/search/index.js +797 -176
  46. package/dist/search/semantic.js +208 -18
  47. package/dist/search/tokenize.js +44 -0
  48. package/dist/server.js +583 -373
  49. package/dist/sync/git.js +203 -72
  50. package/dist/sync/space.js +100 -25
  51. package/dist/sync/validate.js +204 -0
  52. package/dist/utils/budget.js +15 -2
  53. package/dist/utils/fs.js +40 -6
  54. package/dist/utils/ids.js +128 -14
  55. package/dist/utils/lock.js +94 -7
  56. package/hooks/crbro-codex.mjs +284 -0
  57. package/hooks/crbro-guard.mjs +281 -173
  58. package/hooks/crbro-hooks.shipped.json +20 -0
  59. package/hooks/crbro-lifecycle.mjs +16 -4
  60. package/hooks/crbro-subagent.mjs +180 -180
  61. package/package.json +9 -5
  62. package/dist/daemon/daemon.d.ts +0 -25
  63. package/dist/daemon/daemon.d.ts.map +0 -1
  64. package/dist/daemon/daemon.js.map +0 -1
  65. package/dist/daemon/endpoint.d.ts +0 -66
  66. package/dist/daemon/endpoint.d.ts.map +0 -1
  67. package/dist/daemon/endpoint.js.map +0 -1
  68. package/dist/daemon/lines.d.ts +0 -55
  69. package/dist/daemon/lines.d.ts.map +0 -1
  70. package/dist/daemon/lines.js.map +0 -1
  71. package/dist/daemon/proxy.d.ts +0 -55
  72. package/dist/daemon/proxy.d.ts.map +0 -1
  73. package/dist/daemon/proxy.js.map +0 -1
  74. package/dist/engine/backup.d.ts +0 -68
  75. package/dist/engine/backup.d.ts.map +0 -1
  76. package/dist/engine/backup.js.map +0 -1
  77. package/dist/engine/brain.d.ts +0 -79
  78. package/dist/engine/brain.d.ts.map +0 -1
  79. package/dist/engine/brain.js.map +0 -1
  80. package/dist/engine/cortex.d.ts +0 -682
  81. package/dist/engine/cortex.d.ts.map +0 -1
  82. package/dist/engine/cortex.js.map +0 -1
  83. package/dist/engine/dates.d.ts +0 -28
  84. package/dist/engine/dates.d.ts.map +0 -1
  85. package/dist/engine/dates.js.map +0 -1
  86. package/dist/engine/doctor.d.ts +0 -93
  87. package/dist/engine/doctor.d.ts.map +0 -1
  88. package/dist/engine/doctor.js.map +0 -1
  89. package/dist/engine/evidence.d.ts +0 -26
  90. package/dist/engine/evidence.d.ts.map +0 -1
  91. package/dist/engine/evidence.js.map +0 -1
  92. package/dist/engine/heat.d.ts +0 -28
  93. package/dist/engine/heat.d.ts.map +0 -1
  94. package/dist/engine/heat.js.map +0 -1
  95. package/dist/engine/hippocampus.d.ts +0 -60
  96. package/dist/engine/hippocampus.d.ts.map +0 -1
  97. package/dist/engine/hippocampus.js.map +0 -1
  98. package/dist/engine/hookverify.d.ts +0 -42
  99. package/dist/engine/hookverify.d.ts.map +0 -1
  100. package/dist/engine/hookverify.js.map +0 -1
  101. package/dist/engine/keychain.d.ts +0 -32
  102. package/dist/engine/keychain.d.ts.map +0 -1
  103. package/dist/engine/keychain.js.map +0 -1
  104. package/dist/engine/maintenance.d.ts +0 -218
  105. package/dist/engine/maintenance.d.ts.map +0 -1
  106. package/dist/engine/maintenance.js.map +0 -1
  107. package/dist/engine/modinstall.d.ts +0 -215
  108. package/dist/engine/modinstall.d.ts.map +0 -1
  109. package/dist/engine/modinstall.js.map +0 -1
  110. package/dist/engine/postmortem.d.ts +0 -81
  111. package/dist/engine/postmortem.d.ts.map +0 -1
  112. package/dist/engine/postmortem.js.map +0 -1
  113. package/dist/engine/prefrontal.d.ts +0 -64
  114. package/dist/engine/prefrontal.d.ts.map +0 -1
  115. package/dist/engine/prefrontal.js.map +0 -1
  116. package/dist/engine/projects.d.ts +0 -20
  117. package/dist/engine/projects.d.ts.map +0 -1
  118. package/dist/engine/projects.js.map +0 -1
  119. package/dist/engine/secrets.d.ts +0 -30
  120. package/dist/engine/secrets.d.ts.map +0 -1
  121. package/dist/engine/secrets.js.map +0 -1
  122. package/dist/engine/shelf.d.ts +0 -155
  123. package/dist/engine/shelf.d.ts.map +0 -1
  124. package/dist/engine/shelf.js.map +0 -1
  125. package/dist/engine/source.d.ts +0 -6
  126. package/dist/engine/source.d.ts.map +0 -1
  127. package/dist/engine/source.js.map +0 -1
  128. package/dist/engine/synapses.d.ts +0 -82
  129. package/dist/engine/synapses.d.ts.map +0 -1
  130. package/dist/engine/synapses.js.map +0 -1
  131. package/dist/engine/triggers.d.ts +0 -53
  132. package/dist/engine/triggers.d.ts.map +0 -1
  133. package/dist/engine/triggers.js.map +0 -1
  134. package/dist/engine/usage.d.ts +0 -61
  135. package/dist/engine/usage.d.ts.map +0 -1
  136. package/dist/engine/usage.js.map +0 -1
  137. package/dist/index.d.ts +0 -2
  138. package/dist/index.d.ts.map +0 -1
  139. package/dist/index.js.map +0 -1
  140. package/dist/miner/extractor.d.ts +0 -12
  141. package/dist/miner/extractor.d.ts.map +0 -1
  142. package/dist/miner/extractor.js.map +0 -1
  143. package/dist/miner/index.d.ts +0 -61
  144. package/dist/miner/index.d.ts.map +0 -1
  145. package/dist/miner/index.js.map +0 -1
  146. package/dist/miner/scheduler.d.ts +0 -30
  147. package/dist/miner/scheduler.d.ts.map +0 -1
  148. package/dist/miner/scheduler.js.map +0 -1
  149. package/dist/miner/types.d.ts +0 -11
  150. package/dist/miner/types.d.ts.map +0 -1
  151. package/dist/miner/types.js.map +0 -1
  152. package/dist/search/index.d.ts +0 -328
  153. package/dist/search/index.d.ts.map +0 -1
  154. package/dist/search/index.js.map +0 -1
  155. package/dist/search/quality.d.ts +0 -18
  156. package/dist/search/quality.d.ts.map +0 -1
  157. package/dist/search/quality.js.map +0 -1
  158. package/dist/search/semantic.d.ts +0 -80
  159. package/dist/search/semantic.d.ts.map +0 -1
  160. package/dist/search/semantic.js.map +0 -1
  161. package/dist/search/synonyms.d.ts +0 -2
  162. package/dist/search/synonyms.d.ts.map +0 -1
  163. package/dist/search/synonyms.js.map +0 -1
  164. package/dist/search/tokenize.d.ts +0 -39
  165. package/dist/search/tokenize.d.ts.map +0 -1
  166. package/dist/search/tokenize.js.map +0 -1
  167. package/dist/server.d.ts +0 -41
  168. package/dist/server.d.ts.map +0 -1
  169. package/dist/server.js.map +0 -1
  170. package/dist/sync/git.d.ts +0 -36
  171. package/dist/sync/git.d.ts.map +0 -1
  172. package/dist/sync/git.js.map +0 -1
  173. package/dist/sync/materialize.d.ts +0 -36
  174. package/dist/sync/materialize.d.ts.map +0 -1
  175. package/dist/sync/materialize.js.map +0 -1
  176. package/dist/sync/ops.d.ts +0 -147
  177. package/dist/sync/ops.d.ts.map +0 -1
  178. package/dist/sync/ops.js.map +0 -1
  179. package/dist/sync/space.d.ts +0 -118
  180. package/dist/sync/space.d.ts.map +0 -1
  181. package/dist/sync/space.js.map +0 -1
  182. package/dist/types/index.d.ts +0 -369
  183. package/dist/types/index.d.ts.map +0 -1
  184. package/dist/types/index.js.map +0 -1
  185. package/dist/utils/budget.d.ts +0 -44
  186. package/dist/utils/budget.d.ts.map +0 -1
  187. package/dist/utils/budget.js.map +0 -1
  188. package/dist/utils/fs.d.ts +0 -59
  189. package/dist/utils/fs.d.ts.map +0 -1
  190. package/dist/utils/fs.js.map +0 -1
  191. package/dist/utils/hash.d.ts +0 -20
  192. package/dist/utils/hash.d.ts.map +0 -1
  193. package/dist/utils/hash.js.map +0 -1
  194. package/dist/utils/ids.d.ts +0 -47
  195. package/dist/utils/ids.d.ts.map +0 -1
  196. package/dist/utils/ids.js.map +0 -1
  197. package/dist/utils/lock.d.ts +0 -13
  198. package/dist/utils/lock.d.ts.map +0 -1
  199. package/dist/utils/lock.js.map +0 -1
  200. package/dist/utils/transcripts.d.ts +0 -63
  201. package/dist/utils/transcripts.d.ts.map +0 -1
  202. package/dist/utils/transcripts.js.map +0 -1
  203. package/dist/version.d.ts +0 -26
  204. package/dist/version.d.ts.map +0 -1
  205. package/dist/version.js.map +0 -1
@@ -1,15 +1,24 @@
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");
15
24
  const materialize_js_1 = require("../sync/materialize.js");
@@ -25,8 +34,34 @@ const TYPE_PREFIX_RE = /^(project_|tech_|lang_|person_|domain_|process_|protocol
25
34
  */
26
35
  const NEAR_DUP_THRESHOLD = 0.8;
27
36
  const NEAR_DUP_MIN_LENGTH = 60;
28
- /** Minimum similarity before we accept a near-miss as "the same topic". */
29
- 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
+ }
30
65
  const newTally = () => ({ facts: 0, decisions: 0, topics: new Set(), witnessed: new Set() });
31
66
  exports.newTally = newTally;
32
67
  /**
@@ -41,28 +76,26 @@ exports.sessionScope = new node_async_hooks_1.AsyncLocalStorage();
41
76
  * for "Widget Catalog" vs "widget_catalog", and — unlike substring containment —
42
77
  * it does not consider a 60-character walkthrough id a match for "SEO".
43
78
  */
44
- function similarity(a, b) {
45
- if (a === b)
46
- return 1;
47
- 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)
48
90
  return 0;
49
- const bigrams = (s) => {
50
- const out = new Map();
51
- for (let i = 0; i < s.length - 1; i++) {
52
- const g = s.slice(i, i + 2);
53
- out.set(g, (out.get(g) || 0) + 1);
54
- }
55
- return out;
56
- };
57
- const A = bigrams(a);
58
- const B = bigrams(b);
91
+ const B = bigramsOf(b);
59
92
  let shared = 0;
60
93
  for (const [g, countA] of A) {
61
94
  const countB = B.get(g);
62
95
  if (countB)
63
96
  shared += Math.min(countA, countB);
64
97
  }
65
- return (2 * shared) / (a.length - 1 + b.length - 1);
98
+ return (2 * shared) / (aLen - 1 + b.length - 1);
66
99
  }
67
100
  /**
68
101
  * Do two slugs carry the same numbers, in the same order?
@@ -89,17 +122,20 @@ function nearDuplicatesAmong(content, candidates) {
89
122
  if (content.length < NEAR_DUP_MIN_LENGTH)
90
123
  return [];
91
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);
92
128
  const out = [];
93
129
  for (const c of candidates) {
94
130
  if (!c.text || c.text.length < NEAR_DUP_MIN_LENGTH)
95
131
  continue;
96
132
  // Dice over bigrams cannot reach the threshold when one side is much
97
133
  // longer: 2·min/(a+b) bounds it. A free filter before the real count.
98
- const otro = c.text.toLowerCase();
99
- const a = nuevo.length, b = otro.length;
134
+ const a = nuevo.length, b = c.text.length;
100
135
  if ((2 * Math.min(a, b)) / (a + b) < NEAR_DUP_THRESHOLD)
101
136
  continue;
102
- const score = similarity(nuevo, otro);
137
+ const otro = c.text.toLowerCase();
138
+ const score = nuevo === otro ? 1 : diceFrom(bigramasNuevo, nuevo.length, otro);
103
139
  if (score >= NEAR_DUP_THRESHOLD)
104
140
  out.push({ id: c.id, similarity: Math.round(score * 100) / 100, text: c.text });
105
141
  }
@@ -548,20 +584,108 @@ class Cortex {
548
584
  /**
549
585
  * Copy a whole neuron to .quarantine/ with a timestamp, before anything
550
586
  * destructive. Same stamp format everywhere, so restoreNeuron can pick the
551
- * 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.
552
589
  */
553
590
  async quarantine(neuron) {
554
- const sello = (0, fs_js_1.now)().replace(/[:.]/g, '-');
555
- const backup = `${this.brain.paths.quarantine}/${neuron.id}.${sello}.json`;
556
- await (0, fs_js_1.writeJSON)(backup, neuron);
557
- return backup;
591
+ return (0, quarantine_js_1.writeQuarantine)(this.brain.paths.quarantine, neuron.id, neuron, 'whole');
558
592
  }
559
- /** Remove the neuron file, drop its chunks, and keep the manifest honest. */
560
- async deleteNeuronFile(id) {
561
- await (0, fs_js_1.deleteJSON)(this.brain.paths.neuron(id));
562
- await this.unindex(id);
563
- const manifest = await this.brain.getManifest();
564
- 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.`);
565
689
  }
566
690
  async emit(neuronId, change) {
567
691
  if (!this.emitter)
@@ -582,7 +706,7 @@ class Cortex {
582
706
  async mergeFromSync(nid, ops) {
583
707
  let report;
584
708
  let created = false;
585
- const saved = await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(nid), current => {
709
+ const saved = await this.updateNeuron(nid, current => {
586
710
  created = current === null;
587
711
  const merged = (0, materialize_js_1.applyOps)(current, ops, { id: nid });
588
712
  report = merged.report;
@@ -597,19 +721,46 @@ class Cortex {
597
721
  if (!this.indexer)
598
722
  return;
599
723
  try {
600
- 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 });
601
728
  }
602
729
  catch {
603
730
  // Indexing must never break a write. The index is derived data.
604
731
  }
605
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
+ }
606
757
  /**
607
758
  * Get a neuron by ID.
608
759
  */
609
760
  async get(id) {
610
761
  // Even a read touches the file (access time and count), so it goes through
611
762
  // the same locked read-modify-write as everything else.
612
- return (0, fs_js_1.updateJSON)(this.brain.paths.neuron(id), current => {
763
+ return this.updateNeuron(id, current => {
613
764
  if (!current)
614
765
  return null;
615
766
  current.last_accessed = (0, fs_js_1.now)();
@@ -619,9 +770,24 @@ class Cortex {
619
770
  }
620
771
  /**
621
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.
622
777
  */
623
778
  async peek(id) {
624
- 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
+ }
625
791
  }
626
792
  /**
627
793
  * Find a neuron by name.
@@ -635,49 +801,76 @@ class Cortex {
635
801
  * wrong neuron, and no amount of index tuning repairs that. A wrong guess
636
802
  * is worse than a new neuron.
637
803
  */
638
- async findByName(name) {
639
- const ids = await (0, fs_js_1.listJSONFiles)(this.brain.paths.cortex);
640
- const slug = (0, ids_js_1.toSnakeCase)(name);
641
- if (!slug)
804
+ async findByName(name, options) {
805
+ const r = await this.resolveTopic(name, options);
806
+ if (!r)
642
807
  return null;
643
- // Neurons created before accents were folded live under a mangled name
644
- // ("bsqueda" for "búsqueda"). Try the correct slug first, then that one,
645
- // so upgrading does not orphan them.
646
- const legacy = (0, ids_js_1.legacySnakeCase)(name);
647
- const candidatos = legacy && legacy !== slug ? [slug, legacy] : [slug];
648
- // 1. Exact: the id itself, or the id minus its type prefix.
649
- for (const buscado of candidatos) {
650
- for (const id of ids) {
651
- if (id === buscado || id.replace(TYPE_PREFIX_RE, '') === buscado) {
652
- return this.peek(id);
653
- }
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]) };
654
847
  }
655
- }
656
- // 2. Near-miss, but only a real one. Ties go to the most used neuron.
657
- const candidates = [];
658
- for (const id of ids) {
659
- const bare = id.replace(TYPE_PREFIX_RE, '');
660
- const score = similarity(slug, bare);
661
- if (score >= NAME_MATCH_THRESHOLD && sameNumbers(slug, bare)) {
662
- 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 };
663
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');
664
866
  }
665
- if (candidates.length === 0)
666
- return null;
667
- candidates.sort((a, b) => b.score - a.score);
668
- const top = candidates[0].score;
669
- const tied = candidates.filter(c => c.score >= top - 0.001);
670
- if (tied.length === 1)
671
- return this.peek(tied[0].id);
672
- let best = null;
673
- for (const c of tied) {
674
- const n = await this.peek(c.id);
675
- if (!n)
676
- continue;
677
- if (!best || (n.access_count || 0) > (best.access_count || 0))
678
- 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');
679
872
  }
680
- return best;
873
+ return null;
681
874
  }
682
875
  /**
683
876
  * Create a new neuron.
@@ -689,7 +882,7 @@ class Cortex {
689
882
  // Check-then-create has to happen inside the lock, or two clients starting
690
883
  // the same topic at the same moment both think they are the first.
691
884
  let creada = false;
692
- const neuron = await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(id), current => {
885
+ const neuron = await this.updateNeuron(id, current => {
693
886
  if (current)
694
887
  return null;
695
888
  creada = true;
@@ -733,32 +926,72 @@ class Cortex {
733
926
  // exists and what kind — the knowledge without the liability.
734
927
  const limpio = (0, secrets_js_1.redact)(content);
735
928
  content = limpio.text;
736
- 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
+ }
737
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.
738
942
  if (options?.neuronId) {
739
- neuron = await this.peek(options.neuronId);
740
- }
741
- if (!neuron) {
742
- 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
+ }
743
965
  }
744
- if (!neuron) {
745
- if (options?.createIfMissing === false) {
746
- return {
747
- neuron: null, action: 'skipped', superseded: 0,
748
- supersedes_unmatched: options?.supersedes || [], near_duplicates: [], redacted: limpio.found,
749
- duplicate: false, updated_in_place: false, skipped_retired: null,
750
- };
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';
751
986
  }
752
- const nType = options?.neuronType || (0, ids_js_1.inferNeuronType)(topic);
753
- const domain = options?.domain || 'general';
754
- neuron = await this.create(topic, nType, domain);
755
- action = 'created';
756
987
  }
757
988
  let superseded = 0;
758
- // Stays empty unless retire() actually runs: a duplicate fact skips the
759
- // whole block, and warning "still live" about targets nobody touched is
760
- // 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.
761
992
  let supersedesUnmatched = [];
993
+ let retiradosAhora = [];
994
+ let gone = false;
762
995
  let nearDuplicates = [];
763
996
  let emitir = null;
764
997
  let duplicate = false;
@@ -803,13 +1036,50 @@ class Cortex {
803
1036
  delete n.entry_source;
804
1037
  }
805
1038
  };
806
- const actualizada = await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(neuron.id), current => {
807
- 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;
808
1078
  switch (type) {
809
1079
  case 'fact': {
810
1080
  const id = (0, hash_js_1.factId)(content);
811
1081
  const keys = normalizeKeys(options?.keys);
812
- const existente = n.facts.find(f => f.text.toLowerCase() === content.toLowerCase());
1082
+ const existente = n.facts.find(f => mismoHecho(f.text));
813
1083
  if (existente && (existente.status === 'superseded' || existente.status === 'retracted')) {
814
1084
  // The same line was deliberately retired. Re-adding it would
815
1085
  // undo that revision by accident; report it and write nothing.
@@ -905,13 +1175,13 @@ class Cortex {
905
1175
  ...(existente.shelf_life ? { shelf: existente.shelf_life } : {}) };
906
1176
  const sv = (0, shelf_js_1.shelfOfFact)(existente);
907
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 });
908
1182
  }
909
1183
  if (!isDuplicate) {
910
- if (options?.supersedes?.length) {
911
- const retirado = this.retire(n, options.supersedes, id, 'superseded');
912
- superseded = retirado.count;
913
- supersedesUnmatched = retirado.unmatched;
914
- }
1184
+ supersedeDesde(n, { id, text: content });
915
1185
  // Near-duplicate check, against what will still be active
916
1186
  // AFTER supersedes ran — retiring the old telling is exactly
917
1187
  // the fix this warning exists to suggest, so a properly
@@ -953,6 +1223,12 @@ class Cortex {
953
1223
  case 'decision': {
954
1224
  if (retirada(n, content))
955
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
+ }
956
1232
  const decision = {
957
1233
  text: content,
958
1234
  date: (0, fs_js_1.now)(),
@@ -969,16 +1245,18 @@ class Cortex {
969
1245
  case 'pattern': {
970
1246
  if (retirada(n, content))
971
1247
  return null;
972
- if (!n.patterns.includes(content)) {
1248
+ const previo = n.patterns.find(mismaEntrada);
1249
+ if (previo === undefined) {
973
1250
  n.patterns.push(content);
974
1251
  fechar(n, content);
975
1252
  origen(n, content);
976
1253
  }
977
- emitir = { kind: 'pattern', text: content, at: n.entry_dates?.[(0, ops_js_1.entryId)(content)] || (0, fs_js_1.now)() };
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)() };
978
1256
  break;
979
1257
  }
980
1258
  case 'preference': {
981
- if (!n.preferences.includes(content)) {
1259
+ if (!n.preferences.some(mismaEntrada)) {
982
1260
  n.preferences.push(content);
983
1261
  fechar(n, content);
984
1262
  }
@@ -989,13 +1267,15 @@ class Cortex {
989
1267
  return null;
990
1268
  if (!n.errors)
991
1269
  n.errors = [];
992
- if (!n.errors.includes(content)) {
1270
+ const previo = n.errors.find(mismaEntrada);
1271
+ if (previo === undefined) {
993
1272
  n.errors.push(content);
994
1273
  fechar(n, content);
995
1274
  origen(n, content);
996
1275
  this.tally.topics.add(n.id);
997
1276
  }
998
- emitir = { kind: 'error', text: content, at: n.entry_dates?.[(0, ops_js_1.entryId)(content)] || (0, fs_js_1.now)() };
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)() };
999
1279
  break;
1000
1280
  }
1001
1281
  case 'debt': {
@@ -1003,13 +1283,15 @@ class Cortex {
1003
1283
  return null;
1004
1284
  if (!n.debts)
1005
1285
  n.debts = [];
1006
- if (!n.debts.includes(content)) {
1286
+ const previo = n.debts.find(mismaEntrada);
1287
+ if (previo === undefined) {
1007
1288
  n.debts.push(content);
1008
1289
  fechar(n, content);
1009
1290
  origen(n, content);
1010
1291
  this.tally.topics.add(n.id);
1011
1292
  }
1012
- emitir = { kind: 'debt', text: content, at: n.entry_dates?.[(0, ops_js_1.entryId)(content)] || (0, fs_js_1.now)() };
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)() };
1013
1295
  break;
1014
1296
  }
1015
1297
  }
@@ -1020,13 +1302,16 @@ class Cortex {
1020
1302
  }
1021
1303
  return n;
1022
1304
  });
1023
- const final = (actualizada || neuron);
1305
+ if (gone || !actualizada)
1306
+ throw this.deletedMeanwhile(targetId);
1307
+ const final = actualizada;
1024
1308
  if (skippedRetired) {
1025
1309
  // Nothing was written, so nothing to index, emit or tally.
1026
1310
  return {
1027
1311
  neuron: final, action: 'skipped_retired', superseded: 0, supersedes_unmatched: [],
1028
1312
  near_duplicates: [], redacted: limpio.found,
1029
1313
  duplicate: false, updated_in_place: false, skipped_retired: skippedRetired,
1314
+ resolved_by: resolvedBy, ...(ambiguous?.length ? { ambiguous } : {}),
1030
1315
  };
1031
1316
  }
1032
1317
  await this.reindex(final);
@@ -1034,6 +1319,12 @@ class Cortex {
1034
1319
  // key and the least likely to be worth sharing.
1035
1320
  if (emitir)
1036
1321
  await this.emit(final.id, emitir);
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
+ }
1037
1328
  return {
1038
1329
  neuron: final, action, superseded, supersedes_unmatched: supersedesUnmatched,
1039
1330
  near_duplicates: nearDuplicates, redacted: limpio.found,
@@ -1041,6 +1332,7 @@ class Cortex {
1041
1332
  ...(confirmations !== undefined ? { confirmations } : {}),
1042
1333
  ...(shelf ? { shelf } : {}),
1043
1334
  ...(shelfLengthened ? { shelf_lengthened: true } : {}),
1335
+ resolved_by: resolvedBy, ...(ambiguous?.length ? { ambiguous } : {}),
1044
1336
  };
1045
1337
  }
1046
1338
  /**
@@ -1053,14 +1345,18 @@ class Cortex {
1053
1345
  * caller usually has the text in front of it and not the hash.
1054
1346
  */
1055
1347
  async revise(neuronRef, targets, options) {
1056
- const neuron = (await this.peek(neuronRef)) || (await this.findByName(neuronRef));
1348
+ const neuron = await this.resolveForWrite(neuronRef);
1057
1349
  if (!neuron)
1058
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
1059
1353
  const status = options?.status || 'superseded';
1060
1354
  let revised = 0;
1061
1355
  let unmatched = targets.slice();
1062
- const actualizada = await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(neuron.id), current => {
1063
- 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;
1064
1360
  const retirado = status === 'active'
1065
1361
  ? this.reactivate(n, targets, options?.note)
1066
1362
  : this.retire(n, targets, options?.replacedBy, status, options?.note);
@@ -1111,15 +1407,17 @@ class Cortex {
1111
1407
  const evidence = (0, evidence_js_1.makeVerification)(targets.evidence, (0, fs_js_1.now)());
1112
1408
  const facts = (targets.facts || []).filter(t => typeof t === 'string');
1113
1409
  const entries = (targets.entries || []).filter(t => typeof t === 'string');
1114
- const neuron = (await this.peek(neuronRef)) || (await this.findByName(neuronRef));
1410
+ const neuron = await this.resolveForWrite(neuronRef);
1115
1411
  if (!neuron)
1116
1412
  return { neuron: null, verified: [], unmatched: [...facts, ...entries], retired: [] };
1117
1413
  const verified = [];
1118
1414
  let unmatched = [];
1119
1415
  let retired = [];
1120
1416
  const emitir = [];
1121
- const actualizada = await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(neuron.id), current => {
1122
- 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;
1123
1421
  const cuando = (0, fs_js_1.now)();
1124
1422
  verified.length = 0;
1125
1423
  unmatched = [];
@@ -1213,6 +1511,7 @@ class Cortex {
1213
1511
  let count = 0;
1214
1512
  const wanted = targets.map(t => t.trim().toLowerCase());
1215
1513
  const hit = new Set();
1514
+ const ids = [];
1216
1515
  for (const fact of neuron.facts) {
1217
1516
  if (fact.status === 'superseded' || fact.status === 'retracted')
1218
1517
  continue;
@@ -1233,8 +1532,9 @@ class Cortex {
1233
1532
  if (note)
1234
1533
  fact.revision_note = note;
1235
1534
  count++;
1535
+ ids.push(fid);
1236
1536
  }
1237
- return { count, unmatched: targets.filter((_, i) => !hit.has(i)) };
1537
+ return { count, unmatched: targets.filter((_, i) => !hit.has(i)), ids };
1238
1538
  }
1239
1539
  /**
1240
1540
  * Bring retired facts back. The inverse of retire: only superseded or
@@ -1279,15 +1579,19 @@ class Cortex {
1279
1579
  * survives a sync because applyOps copies and prunes it.
1280
1580
  */
1281
1581
  async retireEntries(neuronRef, targets, options) {
1282
- const neuron = (await this.peek(neuronRef)) || (await this.findByName(neuronRef));
1582
+ const neuron = await this.resolveForWrite(neuronRef);
1283
1583
  if (!neuron)
1284
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
1285
1587
  const status = options?.status || 'superseded';
1286
1588
  const wanted = targets.map(t => (0, ops_js_1.normalizeText)(t).toLowerCase());
1287
1589
  let revised = 0;
1288
1590
  let unmatched = targets.slice();
1289
- const actualizada = await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(neuron.id), current => {
1290
- 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;
1291
1595
  const estados = n.entry_status || {};
1292
1596
  const hit = new Set();
1293
1597
  const cuando = (0, fs_js_1.now)();
@@ -1350,7 +1654,7 @@ class Cortex {
1350
1654
  * Not emitted: NeuronOp is only an announcement, and metadata is local.
1351
1655
  */
1352
1656
  async setMeta(neuronRef, meta) {
1353
- const neuron = (await this.peek(neuronRef)) || (await this.findByName(neuronRef));
1657
+ const neuron = await this.resolveForWrite(neuronRef);
1354
1658
  if (!neuron)
1355
1659
  return { neuron: null, changed: [], redacted: [] };
1356
1660
  let redacted = [];
@@ -1366,8 +1670,10 @@ class Cortex {
1366
1670
  const domain = meta.domain === undefined ? undefined : meta.domain.trim();
1367
1671
  const name = meta.name === undefined ? undefined : meta.name.trim();
1368
1672
  const changed = [];
1369
- const actualizada = await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(neuron.id), current => {
1370
- 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;
1371
1677
  if (summary !== undefined && summary !== n.summary) {
1372
1678
  n.summary = summary;
1373
1679
  changed.push('summary');
@@ -1413,7 +1719,7 @@ class Cortex {
1413
1719
  // The other way round, an exact neuron id could land on a near-miss whose
1414
1720
  // prefix-stripped id matches first, and the map would be written to one
1415
1721
  // neuron while every reader resolves the other.
1416
- let neuron = (await this.peek(neuronRef)) || (await this.findByName(neuronRef));
1722
+ let neuron = await this.resolveForWrite(neuronRef);
1417
1723
  let action = 'updated';
1418
1724
  if (!neuron) {
1419
1725
  const nType = options?.neuronType || (0, ids_js_1.inferNeuronType)(neuronRef);
@@ -1422,8 +1728,10 @@ class Cortex {
1422
1728
  }
1423
1729
  const cuando = (0, fs_js_1.now)();
1424
1730
  const vaciar = content.trim() === '';
1425
- const actualizada = await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(neuron.id), current => {
1426
- 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;
1427
1735
  // An empty map is not a map: writing '' clears it outright, so the
1428
1736
  // reader never meets a document that exists but says nothing.
1429
1737
  if (vaciar)
@@ -1434,7 +1742,9 @@ class Cortex {
1434
1742
  n.access_count = (n.access_count || 0) + 1;
1435
1743
  return n;
1436
1744
  });
1437
- const final = (actualizada || neuron);
1745
+ if (!actualizada)
1746
+ throw this.deletedMeanwhile(neuron.id);
1747
+ const final = actualizada;
1438
1748
  await this.reindex(final);
1439
1749
  // Clearing emits too: an empty map op is the tombstone that wins the LWW
1440
1750
  // on every machine, so a stale copy cannot resurrect the cleared map.
@@ -1450,36 +1760,48 @@ class Cortex {
1450
1760
  * slice, which is the only number a pager can trust.
1451
1761
  */
1452
1762
  async listWithTotal(options) {
1453
- 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);
1454
1765
  const limit = options?.limit || 50;
1455
1766
  const offset = Math.max(0, options?.offset || 0);
1456
- return { total: all.length, rows: all.slice(offset, offset + limit) };
1767
+ return { total: all.length, rows: all.slice(offset, offset + limit), unreadable };
1457
1768
  }
1458
1769
  /**
1459
1770
  * List all neurons with optional filters.
1460
1771
  */
1461
- async list(options) {
1772
+ async list(options, unreadable) {
1462
1773
  const ids = await (0, fs_js_1.listJSONFiles)(this.brain.paths.cortex);
1463
1774
  const neurons = [];
1775
+ const calor = await this.heatMap();
1464
1776
  for (const id of ids) {
1465
- 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
+ }
1466
1787
  if (!neuron)
1467
1788
  continue;
1789
+ const heat = calor.get(neuron.id) ?? neuron.heat;
1468
1790
  // Apply filters
1469
1791
  if (options?.domain && neuron.domain !== options.domain)
1470
1792
  continue;
1471
1793
  if (options?.type && neuron.type !== options.type)
1472
1794
  continue;
1473
- if (options?.min_heat && neuron.heat < options.min_heat)
1795
+ if (options?.min_heat && heat < options.min_heat)
1474
1796
  continue;
1475
1797
  neurons.push({
1476
1798
  id: neuron.id,
1477
1799
  name: neuron.name,
1478
1800
  domain: neuron.domain,
1479
1801
  type: neuron.type,
1480
- heat: neuron.heat,
1802
+ heat,
1481
1803
  last_accessed: neuron.last_accessed,
1482
- facts_count: neuron.facts.length,
1804
+ facts_count: (neuron.facts || []).length,
1483
1805
  });
1484
1806
  }
1485
1807
  // Sort by heat (descending)
@@ -1493,7 +1815,7 @@ class Cortex {
1493
1815
  * Update a neuron's summary.
1494
1816
  */
1495
1817
  async updateSummary(id, summary) {
1496
- const neuron = await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(id), current => {
1818
+ const neuron = await this.updateNeuron(id, current => {
1497
1819
  if (!current)
1498
1820
  return null;
1499
1821
  current.summary = summary;
@@ -1509,7 +1831,7 @@ class Cortex {
1509
1831
  * Add tags to a neuron.
1510
1832
  */
1511
1833
  async addTags(id, tags) {
1512
- const neuron = await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(id), current => {
1834
+ const neuron = await this.updateNeuron(id, current => {
1513
1835
  if (!current)
1514
1836
  return null;
1515
1837
  for (const tag of tags) {
@@ -1532,10 +1854,10 @@ class Cortex {
1532
1854
  * not exist at all — a credential, someone's personal data.
1533
1855
  */
1534
1856
  async forget(neuronRef, targets) {
1535
- const found = (await this.peek(neuronRef)) || (await this.findByName(neuronRef));
1857
+ const found = await this.resolveForWrite(neuronRef);
1536
1858
  if (!found)
1537
1859
  return { neuron_id: null, removed: 0, backup: null };
1538
- const backup = await this.quarantine(found);
1860
+ let backup = null;
1539
1861
  let removed = 0;
1540
1862
  const wanted = targets.map(t => t.trim().toLowerCase());
1541
1863
  // An entry goes when a target names its text or its id: the entryId the
@@ -1549,42 +1871,83 @@ class Cortex {
1549
1871
  return wanted.includes(t) || wanted.includes((0, ops_js_1.entryId)(text).toLowerCase())
1550
1872
  || (!!ownId && wanted.includes(ownId.toLowerCase()));
1551
1873
  };
1552
- 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) => {
1553
1883
  if (!current)
1554
1884
  return null;
1555
- const antes = current.facts.length + current.decisions.length +
1556
- 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;
1557
1932
  // Dated entries before the removal, so their dates can go with them.
1558
1933
  const fechadasAntes = [
1559
1934
  ...current.patterns, ...current.preferences,
1560
1935
  ...(current.errors || []), ...(current.debts || []),
1561
1936
  ];
1562
- current.facts = current.facts.filter(f => {
1563
- const fid = (f.id || (0, hash_js_1.factId)(f.text)).toLowerCase();
1564
- return !(wanted.includes(fid) || wanted.includes(f.text.trim().toLowerCase()));
1565
- });
1937
+ current.facts = current.facts.filter(f => !factMatches(f));
1566
1938
  // Decisions, patterns and preferences too. A credential is no less
1567
1939
  // exposed for sitting in one of those, and nothing else could remove it.
1568
1940
  current.decisions = current.decisions.filter(d => !matches(d.text, d.id));
1569
1941
  current.patterns = current.patterns.filter(p => !matches(p));
1570
1942
  current.preferences = current.preferences.filter(p => !matches(p));
1571
- const erroresAntes = (current.errors || []).length;
1572
1943
  if (current.errors) {
1573
1944
  current.errors = current.errors.filter(e => !matches(e));
1574
1945
  }
1575
- const deudasAntes = (current.debts || []).length;
1576
1946
  if (current.debts) {
1577
1947
  current.debts = current.debts.filter(d => !matches(d));
1578
1948
  }
1579
- let mapaBorrado = 0;
1580
- if (current.map && wanted.includes(current.map.text.trim().toLowerCase())) {
1949
+ if (q.map)
1581
1950
  delete current.map;
1582
- mapaBorrado = 1;
1583
- }
1584
- removed = antes - (current.facts.length + current.decisions.length +
1585
- current.patterns.length + current.preferences.length) + (erroresAntes - (current.errors || []).length) + (deudasAntes - (current.debts || []).length) + mapaBorrado;
1586
- if (removed === 0)
1587
- return null;
1588
1951
  // Drop the dates of what left, so the sidecar never outlives its entry.
1589
1952
  if (current.entry_dates) {
1590
1953
  const quedan = new Set([
@@ -1641,7 +2004,8 @@ class Cortex {
1641
2004
  current.last_accessed = (0, fs_js_1.now)();
1642
2005
  return current;
1643
2006
  });
1644
- if (removed > 0 && after) {
2007
+ const q = quitado;
2008
+ if (removed > 0 && after && q) {
1645
2009
  await this.reindex(after);
1646
2010
  // The removal must travel, or the next sync resurrects from the team
1647
2011
  // log exactly what the user asked to destroy. Facts retract (status
@@ -1649,33 +2013,22 @@ class Cortex {
1649
2013
  // a purge op keyed by content hash; a cleared map emits the empty-map
1650
2014
  // tombstone that wins the LWW. The emitter no-ops on unshared neurons.
1651
2015
  const cuando = (0, fs_js_1.now)();
1652
- for (const f of found.facts || []) {
1653
- const fid = (f.id || (0, hash_js_1.factId)(f.text)).toLowerCase();
1654
- if (wanted.includes(fid) || wanted.includes(f.text.trim().toLowerCase())) {
1655
- await this.emit(found.id, { kind: 'status', fid: f.id || (0, hash_js_1.factId)(f.text), to: 'retracted', at: cuando, why: 'forgotten' });
1656
- }
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' });
1657
2018
  }
1658
- for (const d of found.decisions || []) {
1659
- if (matches(d.text, d.id)) {
1660
- await this.emit(found.id, { kind: 'decision_purge', key: (0, ops_js_1.entryId)(d.text), at: cuando });
1661
- }
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 });
1662
2021
  }
1663
- for (const p of found.patterns || []) {
1664
- if (matches(p)) {
1665
- await this.emit(found.id, { kind: 'pattern_purge', key: (0, ops_js_1.entryId)(p), at: cuando });
1666
- }
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 });
1667
2024
  }
1668
- for (const e of found.errors || []) {
1669
- if (matches(e)) {
1670
- await this.emit(found.id, { kind: 'error_purge', key: (0, ops_js_1.entryId)(e), at: cuando });
1671
- }
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 });
1672
2027
  }
1673
- for (const d of found.debts || []) {
1674
- if (matches(d)) {
1675
- await this.emit(found.id, { kind: 'debt_purge', key: (0, ops_js_1.entryId)(d), at: cuando });
1676
- }
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 });
1677
2030
  }
1678
- if (found.map && wanted.includes(found.map.text.trim().toLowerCase())) {
2031
+ if (q.map) {
1679
2032
  await this.emit(found.id, { kind: 'map', text: '', at: cuando });
1680
2033
  }
1681
2034
  }
@@ -1692,37 +2045,60 @@ class Cortex {
1692
2045
  * emitted: there is no neuron-delete op.
1693
2046
  */
1694
2047
  async forgetNeuron(neuronRef) {
1695
- const found = (await this.peek(neuronRef)) || (await this.findByName(neuronRef));
1696
- if (!found) {
1697
- return {
1698
- neuron_id: null, backup: null,
1699
- counts: { facts: 0, decisions: 0, patterns: 0, preferences: 0, errors: 0, debts: 0, connections: 0 },
1700
- };
1701
- }
1702
- const backup = await this.quarantine(found);
1703
- 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;
1704
2058
  return {
1705
- neuron_id: found.id,
1706
- backup,
2059
+ neuron_id: n.id,
2060
+ backup: r.backup,
1707
2061
  counts: {
1708
- facts: (found.facts || []).length,
1709
- decisions: (found.decisions || []).length,
1710
- patterns: (found.patterns || []).length,
1711
- preferences: (found.preferences || []).length,
1712
- errors: (found.errors || []).length,
1713
- debts: (found.debts || []).length,
1714
- 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,
1715
2069
  },
1716
2070
  };
1717
2071
  }
1718
2072
  /**
1719
- * Quarantine copies of one neuron, newest first. Stamps sort lexically, so
1720
- * 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.
1721
2099
  */
1722
2100
  async quarantineCopies(neuronId) {
1723
- const prefijo = `${neuronId}.`;
1724
- const files = await (0, fs_js_1.listJSONFiles)(this.brain.paths.quarantine);
1725
- 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 }));
1726
2102
  }
1727
2103
  /**
1728
2104
  * Bring a neuron back from quarantine.
@@ -1732,30 +2108,40 @@ class Cortex {
1732
2108
  * (a forget of facts, or the topic was re-created since) the copy is
1733
2109
  * unioned into it. The quarantine file stays where it is, so a restore is
1734
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.
1735
2116
  */
1736
2117
  async restoreNeuron(neuronId, options) {
1737
2118
  const nada = { neuron: null, restored_from: null, merged_into_existing: false, moved: null };
1738
- let basename = null;
2119
+ const copias = await this.quarantineCopies(neuronId);
2120
+ let elegida;
1739
2121
  if (options?.file) {
1740
- const pedido = options.file.replace(/\.json$/i, '');
1741
- const copias = await this.quarantineCopies(neuronId);
1742
- basename = copias.includes(pedido) ? pedido : null;
2122
+ const pedido = options.file.replace(/\.json(\.enc)?$/i, '');
2123
+ elegida = copias.find(c => c.base === pedido);
1743
2124
  }
1744
2125
  else {
1745
- basename = (await this.quarantineCopies(neuronId))[0] || null;
2126
+ elegida = copias[0];
1746
2127
  }
1747
- if (!basename)
2128
+ if (!elegida)
1748
2129
  return nada;
1749
- const ruta = `${this.brain.paths.quarantine}/${basename}.json`;
1750
- const copia = await (0, fs_js_1.readJSON)(ruta);
1751
- 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)
1752
2138
  return nada;
1753
2139
  copia.id = neuronId;
1754
2140
  // Decide inside the lock, like create: whether the neuron exists again is
1755
2141
  // only known for sure while nobody else can write it.
1756
2142
  let escrita = false;
1757
2143
  let moved = null;
1758
- const saved = await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(neuronId), current => {
2144
+ const saved = await this.updateNeuron(neuronId, current => {
1759
2145
  if (!current) {
1760
2146
  escrita = true;
1761
2147
  return copia;
@@ -1784,25 +2170,48 @@ class Cortex {
1784
2170
  * re-creates it).
1785
2171
  */
1786
2172
  async mergeNeurons(fromRef, intoRef) {
1787
- const from = (await this.peek(fromRef)) || (await this.findByName(fromRef));
1788
- const into = (await this.peek(intoRef)) || (await this.findByName(intoRef));
2173
+ const from = await this.resolveForWrite(fromRef);
2174
+ const into = await this.resolveForWrite(intoRef);
1789
2175
  if (!from || !into || from.id === into.id) {
1790
2176
  return { from: from?.id ?? null, into: into?.id ?? null, backup: null, moved: null };
1791
2177
  }
1792
- 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.
1793
2181
  let antes = into;
1794
2182
  let moved = null;
1795
- const saved = await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(into.id), current => {
1796
- antes = current || into;
1797
- const r = unionNeuron(antes, from);
1798
- moved = r.moved;
1799
- 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 };
1800
2203
  });
1801
- 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;
1802
2209
  await this.reindex(final);
1803
2210
  await this.emitAdded(antes, final);
1804
- await this.deleteNeuronFile(from.id);
1805
- 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 };
1806
2215
  }
1807
2216
  /**
1808
2217
  * Emit what a union added, by diffing the result against what the target
@@ -1877,7 +2286,7 @@ class Cortex {
1877
2286
  * moves: it describes the system, not an entry.
1878
2287
  */
1879
2288
  async moveEntries(fromRef, targets, intoRef, options) {
1880
- const from = (await this.peek(fromRef)) || (await this.findByName(fromRef));
2289
+ const from = await this.resolveForWrite(fromRef);
1881
2290
  if (!from)
1882
2291
  return { from: null, into: null, created: false, moved: 0, unmatched: targets, backup: null };
1883
2292
  const wanted = new Set(targets.map(t => t.trim().toLowerCase()).filter(Boolean));
@@ -1917,7 +2326,7 @@ class Cortex {
1917
2326
  const unmatched = targets.filter(t => !usados.has(t.trim().toLowerCase()));
1918
2327
  if (textos.length === 0)
1919
2328
  return { from: from.id, into: null, created: false, moved: 0, unmatched, backup: null };
1920
- let into = (await this.peek(intoRef)) || (await this.findByName(intoRef));
2329
+ let into = await this.resolveForWrite(intoRef);
1921
2330
  let created = false;
1922
2331
  if (!into) {
1923
2332
  into = await this.create(intoRef, from.type, options?.domain || from.domain);
@@ -1926,7 +2335,7 @@ class Cortex {
1926
2335
  if (into.id === from.id)
1927
2336
  return { from: from.id, into: into.id, created: false, moved: 0, unmatched, backup: null };
1928
2337
  let antes = into;
1929
- const saved = await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(into.id), current => {
2338
+ const saved = await this.updateNeuron(into.id, current => {
1930
2339
  antes = current || into;
1931
2340
  return unionNeuron(antes, parte).neuron;
1932
2341
  });
@@ -1970,8 +2379,10 @@ class Cortex {
1970
2379
  const targetId = target.id;
1971
2380
  for (const n of sources)
1972
2381
  await this.quarantine(n);
1973
- const saved = await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(targetId), current => {
1974
- 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;
1975
2386
  for (const n of sources) {
1976
2387
  if (n.id === targetId)
1977
2388
  continue;
@@ -1985,13 +2396,36 @@ class Cortex {
1985
2396
  }
1986
2397
  return acc;
1987
2398
  });
1988
- const final = (saved || target);
2399
+ if (!saved)
2400
+ throw this.deletedMeanwhile(targetId);
2401
+ const final = saved;
1989
2402
  await this.reindex(final);
1990
2403
  const folded = [];
1991
2404
  for (const n of sources) {
1992
2405
  if (n.id === targetId)
1993
2406
  continue;
1994
- 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) });
1995
2429
  folded.push(n.id);
1996
2430
  }
1997
2431
  const entries = final.facts.length + final.decisions.length + final.patterns.length
@@ -2005,7 +2439,7 @@ class Cortex {
2005
2439
  async auditSecrets() {
2006
2440
  const out = [];
2007
2441
  for (const id of await (0, fs_js_1.listJSONFiles)(this.brain.paths.cortex)) {
2008
- 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()
2009
2443
  if (!n)
2010
2444
  continue;
2011
2445
  const kinds = new Set();
@@ -2023,8 +2457,9 @@ class Cortex {
2023
2457
  // Every field, not just facts. A credential is just as exposed sitting in
2024
2458
  // a decision or a preference, and those were invisible here — an audit
2025
2459
  // could report a neuron clean while a key sat in preferences[0].
2026
- const facts = contar((n.facts || []).map(f => f.text));
2027
- 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 || ''}`));
2028
2463
  const patterns = contar(n.patterns || []);
2029
2464
  const preferences = contar(n.preferences || []);
2030
2465
  const errors = contar(n.errors || []);