crbro-memory 1.15.0 → 2.0.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 (50) hide show
  1. package/README.md +70 -41
  2. package/bin/crbro.mjs +84 -41
  3. package/dist/engine/brain.js +1 -1
  4. package/dist/engine/brain.js.map +1 -1
  5. package/dist/engine/cortex.d.ts +189 -1
  6. package/dist/engine/cortex.d.ts.map +1 -1
  7. package/dist/engine/cortex.js +639 -17
  8. package/dist/engine/cortex.js.map +1 -1
  9. package/dist/engine/hippocampus.d.ts +27 -1
  10. package/dist/engine/hippocampus.d.ts.map +1 -1
  11. package/dist/engine/hippocampus.js +51 -2
  12. package/dist/engine/hippocampus.js.map +1 -1
  13. package/dist/engine/maintenance.d.ts +49 -2
  14. package/dist/engine/maintenance.d.ts.map +1 -1
  15. package/dist/engine/maintenance.js +301 -28
  16. package/dist/engine/maintenance.js.map +1 -1
  17. package/dist/engine/prefrontal.d.ts +25 -7
  18. package/dist/engine/prefrontal.d.ts.map +1 -1
  19. package/dist/engine/prefrontal.js +55 -27
  20. package/dist/engine/prefrontal.js.map +1 -1
  21. package/dist/engine/synapses.d.ts +38 -0
  22. package/dist/engine/synapses.d.ts.map +1 -1
  23. package/dist/engine/synapses.js +116 -11
  24. package/dist/engine/synapses.js.map +1 -1
  25. package/dist/search/index.d.ts +18 -2
  26. package/dist/search/index.d.ts.map +1 -1
  27. package/dist/search/index.js +54 -10
  28. package/dist/search/index.js.map +1 -1
  29. package/dist/search/semantic.d.ts +8 -0
  30. package/dist/search/semantic.d.ts.map +1 -1
  31. package/dist/search/semantic.js +34 -12
  32. package/dist/search/semantic.js.map +1 -1
  33. package/dist/server.d.ts +7 -0
  34. package/dist/server.d.ts.map +1 -1
  35. package/dist/server.js +764 -827
  36. package/dist/server.js.map +1 -1
  37. package/dist/sync/materialize.d.ts +1 -1
  38. package/dist/sync/materialize.d.ts.map +1 -1
  39. package/dist/sync/materialize.js +40 -3
  40. package/dist/sync/materialize.js.map +1 -1
  41. package/dist/sync/ops.d.ts +9 -4
  42. package/dist/sync/ops.d.ts.map +1 -1
  43. package/dist/sync/ops.js.map +1 -1
  44. package/dist/sync/space.d.ts +27 -0
  45. package/dist/sync/space.d.ts.map +1 -1
  46. package/dist/sync/space.js +113 -1
  47. package/dist/sync/space.js.map +1 -1
  48. package/dist/types/index.d.ts +29 -0
  49. package/dist/types/index.d.ts.map +1 -1
  50. package/package.json +56 -54
@@ -3,6 +3,7 @@
3
3
  // Neuron CRUD — create, read, update, list neurons
4
4
  Object.defineProperty(exports, "__esModule", { value: true });
5
5
  exports.Cortex = void 0;
6
+ exports.unionNeuron = unionNeuron;
6
7
  exports.normalizeKeys = normalizeKeys;
7
8
  const fs_js_1 = require("../utils/fs.js");
8
9
  const ids_js_1 = require("../utils/ids.js");
@@ -62,6 +63,171 @@ function sameNumbers(a, b) {
62
63
  const nb = b.match(/\d+/g) || [];
63
64
  return na.length === nb.length && na.every((v, i) => v === nb[i]);
64
65
  }
66
+ /** Where a fact's lifecycle stands, for "take the furthest-along status". */
67
+ function statusRank(status) {
68
+ return status === 'retracted' ? 2 : status === 'superseded' ? 1 : 0;
69
+ }
70
+ function earliestOf(a, b) {
71
+ if (!a)
72
+ return b || '';
73
+ if (!b)
74
+ return a;
75
+ return a < b ? a : b;
76
+ }
77
+ function copyFact(f) {
78
+ const out = { ...f };
79
+ if (f.keys)
80
+ out.keys = [...f.keys];
81
+ if (f.supersedes)
82
+ out.supersedes = [...f.supersedes];
83
+ return out;
84
+ }
85
+ /** Content hashes of every entry that can carry a date or a retirement. */
86
+ function liveEntryKeys(n) {
87
+ return new Set([
88
+ ...n.decisions.map(d => d.text || ''),
89
+ ...n.patterns, ...n.preferences,
90
+ ...(n.errors || []), ...(n.debts || []),
91
+ ].map(ops_js_1.entryId));
92
+ }
93
+ /**
94
+ * Everything of `source` folded into `target`, as a new object.
95
+ *
96
+ * Shared by restoreNeuron (a quarantine copy back over a neuron that was
97
+ * re-created since) and mergeNeurons (two neurons that turned out to be one
98
+ * topic). Pure on purpose: neither input is touched, so the caller can diff
99
+ * the result against what it had and emit only what actually moved.
100
+ *
101
+ * Facts line up by id (the content hash), falling back to normalised text;
102
+ * on a collision the target's telling stays but takes the furthest-along
103
+ * status (active < superseded < retracted) and the earliest `added`, and the
104
+ * keys are unioned. Everything else is a set union keyed by normalised text
105
+ * with the target's order first. Local observations (heat, access_count)
106
+ * combine as max and sum; identity (id, name, domain, type) is the target's.
107
+ */
108
+ function unionNeuron(target, source) {
109
+ const moved = { facts: 0, decisions: 0, patterns: 0, preferences: 0, errors: 0, debts: 0, tags: 0, map: 0 };
110
+ // ── Facts ──
111
+ const facts = target.facts.map(copyFact);
112
+ const byId = new Map();
113
+ const byText = new Map();
114
+ for (const f of facts) {
115
+ byId.set(f.id || (0, hash_js_1.factId)(f.text), f);
116
+ byText.set((0, ops_js_1.normalizeText)(f.text), f);
117
+ }
118
+ for (const sf of source.facts || []) {
119
+ const sid = sf.id || (0, hash_js_1.factId)(sf.text);
120
+ const hit = byId.get(sid) || byText.get((0, ops_js_1.normalizeText)(sf.text));
121
+ if (!hit) {
122
+ const copia = copyFact(sf);
123
+ copia.id = sid;
124
+ facts.push(copia);
125
+ byId.set(sid, copia);
126
+ byText.set((0, ops_js_1.normalizeText)(sf.text), copia);
127
+ moved.facts++;
128
+ continue;
129
+ }
130
+ if (statusRank(sf.status) > statusRank(hit.status)) {
131
+ hit.status = sf.status;
132
+ if (sf.revised)
133
+ hit.revised = sf.revised;
134
+ if (sf.revision_note)
135
+ hit.revision_note = sf.revision_note;
136
+ if (sf.superseded_by)
137
+ hit.superseded_by = sf.superseded_by;
138
+ }
139
+ hit.added = earliestOf(hit.added, sf.added);
140
+ const keys = normalizeKeys([...(hit.keys || []), ...(sf.keys || [])]);
141
+ if (keys.length)
142
+ hit.keys = keys;
143
+ else
144
+ delete hit.keys;
145
+ }
146
+ // ── Decisions: by normalised text, the target's rationale wins ──
147
+ const decisions = target.decisions.map(d => ({ ...d }));
148
+ const vistas = new Set(decisions.map(d => (0, ops_js_1.normalizeText)(d.text)));
149
+ for (const d of source.decisions || []) {
150
+ const k = (0, ops_js_1.normalizeText)(d.text);
151
+ if (vistas.has(k))
152
+ continue;
153
+ vistas.add(k);
154
+ decisions.push({ ...d });
155
+ moved.decisions++;
156
+ }
157
+ // ── Plain string lists: set union, target order first ──
158
+ const unir = (a, b) => {
159
+ const out = [...a];
160
+ const seen = new Set(a.map(ops_js_1.normalizeText));
161
+ let added = 0;
162
+ for (const t of b) {
163
+ const k = (0, ops_js_1.normalizeText)(t);
164
+ if (seen.has(k))
165
+ continue;
166
+ seen.add(k);
167
+ out.push(t);
168
+ added++;
169
+ }
170
+ return { out, added };
171
+ };
172
+ const patterns = unir(target.patterns || [], source.patterns || []);
173
+ const preferences = unir(target.preferences || [], source.preferences || []);
174
+ const errors = unir(target.errors || [], source.errors || []);
175
+ const debts = unir(target.debts || [], source.debts || []);
176
+ const tags = unir(target.tags || [], source.tags || []);
177
+ moved.patterns = patterns.added;
178
+ moved.preferences = preferences.added;
179
+ moved.errors = errors.added;
180
+ moved.debts = debts.added;
181
+ moved.tags = tags.added;
182
+ const neuron = {
183
+ ...target,
184
+ facts,
185
+ decisions,
186
+ patterns: patterns.out,
187
+ preferences: preferences.out,
188
+ errors: errors.out,
189
+ debts: debts.out,
190
+ tags: tags.out,
191
+ connections: [...new Set([...(target.connections || []), ...(source.connections || [])])]
192
+ .filter(c => c !== target.id && c !== source.id),
193
+ summary: target.summary || source.summary || '',
194
+ heat: Math.max(target.heat || 0, source.heat || 0),
195
+ access_count: (target.access_count || 0) + (source.access_count || 0),
196
+ created: earliestOf(target.created, source.created) || (0, fs_js_1.now)(),
197
+ last_accessed: (0, fs_js_1.now)(),
198
+ };
199
+ // ── Sidecars: union, then pruned to what is actually in the neuron ──
200
+ const vivos = liveEntryKeys(neuron);
201
+ const fechas = {};
202
+ for (const [k, v] of Object.entries(source.entry_dates || {}))
203
+ fechas[k] = v;
204
+ for (const [k, v] of Object.entries(target.entry_dates || {}))
205
+ fechas[k] = earliestOf(fechas[k], v);
206
+ const fechasVivas = {};
207
+ for (const k of Object.keys(fechas).sort())
208
+ if (vivos.has(k))
209
+ fechasVivas[k] = fechas[k];
210
+ neuron.entry_dates = fechasVivas;
211
+ const estados = { ...(source.entry_status || {}), ...(target.entry_status || {}) };
212
+ const estadosVivos = {};
213
+ for (const k of Object.keys(estados).sort())
214
+ if (vivos.has(k))
215
+ estadosVivos[k] = estados[k];
216
+ if (Object.keys(estadosVivos).length)
217
+ neuron.entry_status = estadosVivos;
218
+ else
219
+ delete neuron.entry_status;
220
+ // ── Map: the target's if it has one, else the source's ──
221
+ if (target.map)
222
+ neuron.map = { ...target.map };
223
+ else if (source.map) {
224
+ neuron.map = { ...source.map };
225
+ moved.map = 1;
226
+ }
227
+ else
228
+ delete neuron.map;
229
+ return { neuron, moved };
230
+ }
65
231
  /**
66
232
  * Aliases a future question may use, as stored: trimmed, lower-cased, at
67
233
  * most 40 characters each, unique, at most eight. Order is kept.
@@ -87,6 +253,7 @@ class Cortex {
87
253
  */
88
254
  indexer = null;
89
255
  emitter = null;
256
+ remover = null;
90
257
  /** What this session has actually written, for an honest consolidate(). */
91
258
  tally = { facts: 0, decisions: 0, topics: new Set() };
92
259
  constructor(brain) {
@@ -109,6 +276,37 @@ class Cortex {
109
276
  setEmitter(emitter) {
110
277
  this.emitter = emitter;
111
278
  }
279
+ setRemover(remover) {
280
+ this.remover = remover;
281
+ }
282
+ async unindex(neuronId) {
283
+ if (!this.remover)
284
+ return;
285
+ try {
286
+ await this.remover(neuronId);
287
+ }
288
+ catch {
289
+ // The index is derived data; a stale chunk is a nuisance, not a loss.
290
+ }
291
+ }
292
+ /**
293
+ * Copy a whole neuron to .quarantine/ with a timestamp, before anything
294
+ * destructive. Same stamp format everywhere, so restoreNeuron can pick the
295
+ * newest copy by name alone.
296
+ */
297
+ async quarantine(neuron) {
298
+ const sello = (0, fs_js_1.now)().replace(/[:.]/g, '-');
299
+ const backup = `${this.brain.paths.quarantine}/${neuron.id}.${sello}.json`;
300
+ await (0, fs_js_1.writeJSON)(backup, neuron);
301
+ return backup;
302
+ }
303
+ /** Remove the neuron file, drop its chunks, and keep the manifest honest. */
304
+ async deleteNeuronFile(id) {
305
+ await (0, fs_js_1.deleteJSON)(this.brain.paths.neuron(id));
306
+ await this.unindex(id);
307
+ const manifest = await this.brain.getManifest();
308
+ await this.brain.updateManifest({ total_neurons: Math.max(0, manifest.total_neurons - 1) });
309
+ }
112
310
  async emit(neuronId, change) {
113
311
  if (!this.emitter)
114
312
  return;
@@ -292,7 +490,11 @@ class Cortex {
292
490
  }
293
491
  if (!neuron) {
294
492
  if (options?.createIfMissing === false) {
295
- return { neuron: null, action: 'skipped', superseded: 0, supersedes_unmatched: options?.supersedes || [], near_duplicates: [], redacted: limpio.found };
493
+ return {
494
+ neuron: null, action: 'skipped', superseded: 0,
495
+ supersedes_unmatched: options?.supersedes || [], near_duplicates: [], redacted: limpio.found,
496
+ duplicate: false, updated_in_place: false, skipped_retired: null,
497
+ };
296
498
  }
297
499
  const nType = options?.neuronType || (0, ids_js_1.inferNeuronType)(topic);
298
500
  const domain = options?.domain || 'general';
@@ -306,6 +508,19 @@ class Cortex {
306
508
  let supersedesUnmatched = [];
307
509
  let nearDuplicates = [];
308
510
  let emitir = null;
511
+ let duplicate = false;
512
+ let updatedInPlace = false;
513
+ let skippedRetired = null;
514
+ // A retired decision, pattern, error or debt must not come back through
515
+ // learn: the sidecar says so, and the index already hides it.
516
+ const retirada = (n, text) => {
517
+ const k = (0, ops_js_1.entryId)(text);
518
+ const est = n.entry_status?.[k];
519
+ if (!est)
520
+ return false;
521
+ skippedRetired = { id: k, status: est.status, revised: est.revised, note: est.note };
522
+ return true;
523
+ };
309
524
  // From here on we work on a fresh read inside the lock. Mutating the copy
310
525
  // fetched a moment ago and saving it over the top is exactly how a
311
526
  // concurrent writer's facts disappeared.
@@ -323,16 +538,53 @@ class Cortex {
323
538
  const id = (0, hash_js_1.factId)(content);
324
539
  const keys = normalizeKeys(options?.keys);
325
540
  const existente = n.facts.find(f => f.text.toLowerCase() === content.toLowerCase());
541
+ if (existente && (existente.status === 'superseded' || existente.status === 'retracted')) {
542
+ // The same line was deliberately retired. Re-adding it would
543
+ // undo that revision by accident; report it and write nothing.
544
+ skippedRetired = {
545
+ id: existente.id || (0, hash_js_1.factId)(existente.text),
546
+ status: existente.status,
547
+ revised: existente.revised,
548
+ note: existente.revision_note,
549
+ };
550
+ return null;
551
+ }
326
552
  const isDuplicate = existente !== undefined;
327
- if (existente && keys.length) {
328
- // The same line again, with aliases: merge them, no sibling.
329
- // The indexer re-indexes the neuron afterwards, keys included.
330
- const merged = normalizeKeys([...(existente.keys || []), ...keys]);
331
- if (merged.length !== (existente.keys || []).length) {
332
- existente.keys = merged;
553
+ if (existente) {
554
+ // The same line again: no sibling, but confidence and aliases
555
+ // may be edited in place. The indexer re-indexes the neuron
556
+ // afterwards, keys included.
557
+ duplicate = true;
558
+ let cambiado = false;
559
+ if (options?.confidence !== undefined && options.confidence !== existente.confidence) {
560
+ existente.confidence = options.confidence;
561
+ cambiado = true;
562
+ }
563
+ if (options?.keysReplace) {
564
+ const actuales = existente.keys || [];
565
+ if (keys.length !== actuales.length || keys.some((k, i) => k !== actuales[i])) {
566
+ if (keys.length)
567
+ existente.keys = keys;
568
+ else
569
+ delete existente.keys;
570
+ cambiado = true;
571
+ }
572
+ }
573
+ else if (keys.length) {
574
+ const merged = normalizeKeys([...(existente.keys || []), ...keys]);
575
+ if (merged.length !== (existente.keys || []).length) {
576
+ existente.keys = merged;
577
+ cambiado = true;
578
+ }
579
+ }
580
+ if (cambiado) {
581
+ // Note for shared neurons: materialize takes max(conf) and
582
+ // unions keys, so lowering confidence or dropping an alias
583
+ // stays local. Documented, not fought — the log is append-only.
584
+ updatedInPlace = true;
333
585
  this.tally.topics.add(n.id);
334
586
  emitir = { kind: 'fact', text: existente.text, fid: existente.id || id,
335
- conf: existente.confidence ?? 1, at: existente.added, src: existente.source, keys: merged };
587
+ conf: existente.confidence ?? 1, at: existente.added, src: existente.source, keys: existente.keys };
336
588
  }
337
589
  }
338
590
  if (!isDuplicate) {
@@ -367,6 +619,8 @@ class Cortex {
367
619
  break;
368
620
  }
369
621
  case 'decision': {
622
+ if (retirada(n, content))
623
+ return null;
370
624
  const decision = {
371
625
  text: content,
372
626
  date: (0, fs_js_1.now)(),
@@ -380,6 +634,8 @@ class Cortex {
380
634
  break;
381
635
  }
382
636
  case 'pattern': {
637
+ if (retirada(n, content))
638
+ return null;
383
639
  if (!n.patterns.includes(content)) {
384
640
  n.patterns.push(content);
385
641
  fechar(n, content);
@@ -395,6 +651,8 @@ class Cortex {
395
651
  break;
396
652
  }
397
653
  case 'error': {
654
+ if (retirada(n, content))
655
+ return null;
398
656
  if (!n.errors)
399
657
  n.errors = [];
400
658
  if (!n.errors.includes(content)) {
@@ -406,6 +664,8 @@ class Cortex {
406
664
  break;
407
665
  }
408
666
  case 'debt': {
667
+ if (retirada(n, content))
668
+ return null;
409
669
  if (!n.debts)
410
670
  n.debts = [];
411
671
  if (!n.debts.includes(content)) {
@@ -425,12 +685,24 @@ class Cortex {
425
685
  return n;
426
686
  });
427
687
  const final = (actualizada || neuron);
688
+ if (skippedRetired) {
689
+ // Nothing was written, so nothing to index, emit or tally.
690
+ return {
691
+ neuron: final, action: 'skipped_retired', superseded: 0, supersedes_unmatched: [],
692
+ near_duplicates: [], redacted: limpio.found,
693
+ duplicate: false, updated_in_place: false, skipped_retired: skippedRetired,
694
+ };
695
+ }
428
696
  await this.reindex(final);
429
697
  // Preferences are never emitted: they are the field most likely to hold a
430
698
  // key and the least likely to be worth sharing.
431
699
  if (emitir)
432
700
  await this.emit(final.id, emitir);
433
- return { neuron: final, action, superseded, supersedes_unmatched: supersedesUnmatched, near_duplicates: nearDuplicates, redacted: limpio.found };
701
+ return {
702
+ neuron: final, action, superseded, supersedes_unmatched: supersedesUnmatched,
703
+ near_duplicates: nearDuplicates, redacted: limpio.found,
704
+ duplicate, updated_in_place: updatedInPlace, skipped_retired: null,
705
+ };
434
706
  }
435
707
  /**
436
708
  * Mark facts as no longer current.
@@ -445,11 +717,14 @@ class Cortex {
445
717
  const neuron = (await this.peek(neuronRef)) || (await this.findByName(neuronRef));
446
718
  if (!neuron)
447
719
  return { neuron: null, revised: 0, unmatched: targets.slice() };
720
+ const status = options?.status || 'superseded';
448
721
  let revised = 0;
449
722
  let unmatched = targets.slice();
450
723
  const actualizada = await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(neuron.id), current => {
451
724
  const n = current || neuron;
452
- const retirado = this.retire(n, targets, options?.replacedBy, options?.status || 'superseded', options?.note);
725
+ const retirado = status === 'active'
726
+ ? this.reactivate(n, targets, options?.note)
727
+ : this.retire(n, targets, options?.replacedBy, status, options?.note);
453
728
  revised = retirado.count;
454
729
  unmatched = retirado.unmatched;
455
730
  if (revised === 0)
@@ -462,8 +737,12 @@ class Cortex {
462
737
  // which is exactly the failure this feature exists to close.
463
738
  const n = (actualizada || neuron);
464
739
  await this.reindex(n);
465
- // 'active' is not a retirement, so it is never emitted as one.
466
- const estado = options?.status === 'retracted' ? 'retracted' : 'superseded';
740
+ // 'active' is not a retirement, so it is never emitted: StatusOp only
741
+ // moves forward, and on a shared neuron the next sync re-applies the
742
+ // retirement from the log. Reactivation is local; the server says so.
743
+ if (status === 'active')
744
+ return { neuron: n, revised, unmatched };
745
+ const estado = status === 'retracted' ? 'retracted' : 'superseded';
467
746
  for (const t of targets) {
468
747
  const f = n.facts.find(x => (x.id || '') === t || x.text.trim().toLowerCase() === t.trim().toLowerCase());
469
748
  if (f?.id) {
@@ -541,6 +820,168 @@ class Cortex {
541
820
  }
542
821
  return { count, unmatched: targets.filter((_, i) => !hit.has(i)) };
543
822
  }
823
+ /**
824
+ * Bring retired facts back. The inverse of retire: only superseded or
825
+ * retracted facts match, by id or by trimmed lower-cased text. Clears
826
+ * superseded_by (the replacement no longer replaces it) and stamps the
827
+ * revision date; the note, if given, records why it came back.
828
+ */
829
+ reactivate(neuron, targets, note) {
830
+ let count = 0;
831
+ const wanted = targets.map(t => t.trim().toLowerCase());
832
+ const hit = new Set();
833
+ for (const fact of neuron.facts) {
834
+ if (fact.status !== 'superseded' && fact.status !== 'retracted')
835
+ continue;
836
+ const fid = fact.id || (0, hash_js_1.factId)(fact.text);
837
+ const idPos = wanted.indexOf(fid.toLowerCase());
838
+ const textPos = wanted.indexOf(fact.text.trim().toLowerCase());
839
+ if (idPos === -1 && textPos === -1)
840
+ continue;
841
+ if (idPos !== -1)
842
+ hit.add(idPos);
843
+ if (textPos !== -1)
844
+ hit.add(textPos);
845
+ fact.id = fid;
846
+ fact.status = 'active';
847
+ delete fact.superseded_by;
848
+ fact.revised = (0, fs_js_1.now)();
849
+ if (note)
850
+ fact.revision_note = note;
851
+ count++;
852
+ }
853
+ return { count, unmatched: targets.filter((_, i) => !hit.has(i)) };
854
+ }
855
+ /**
856
+ * Retire — or reactivate — decisions, patterns, errors and debts by exact
857
+ * text (trimmed, case-insensitive). These are plain strings, so their
858
+ * status lives in the entry_status sidecar keyed by entryId(text), the
859
+ * same way their dates do. A retired entry stays in the file and leaves
860
+ * recall exactly like a superseded fact; status 'active' deletes the key.
861
+ *
862
+ * Never emitted: the sidecar travels only through the local file and
863
+ * survives a sync because applyOps copies and prunes it.
864
+ */
865
+ async retireEntries(neuronRef, targets, options) {
866
+ const neuron = (await this.peek(neuronRef)) || (await this.findByName(neuronRef));
867
+ if (!neuron)
868
+ return { neuron: null, revised: 0, unmatched: targets.slice() };
869
+ const status = options?.status || 'superseded';
870
+ const wanted = targets.map(t => (0, ops_js_1.normalizeText)(t).toLowerCase());
871
+ let revised = 0;
872
+ let unmatched = targets.slice();
873
+ const actualizada = await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(neuron.id), current => {
874
+ const n = current || neuron;
875
+ const estados = n.entry_status || {};
876
+ const hit = new Set();
877
+ const cuando = (0, fs_js_1.now)();
878
+ const textos = [
879
+ ...n.decisions.map(d => d.text || ''),
880
+ ...n.patterns,
881
+ ...(n.errors || []),
882
+ ...(n.debts || []),
883
+ ];
884
+ for (const t of textos) {
885
+ const pos = wanted.indexOf((0, ops_js_1.normalizeText)(t).toLowerCase());
886
+ if (pos === -1)
887
+ continue;
888
+ const k = (0, ops_js_1.entryId)(t);
889
+ const retirado = estados[k] !== undefined;
890
+ if (status === 'active') {
891
+ // Only a retired entry can come back.
892
+ if (!retirado)
893
+ continue;
894
+ delete estados[k];
895
+ }
896
+ else {
897
+ // An already-retired entry never matches again.
898
+ if (retirado)
899
+ continue;
900
+ const est = { status: status, revised: cuando };
901
+ if (options?.note)
902
+ est.note = options.note;
903
+ estados[k] = est;
904
+ }
905
+ hit.add(pos);
906
+ revised++;
907
+ }
908
+ unmatched = targets.filter((_, i) => !hit.has(i));
909
+ if (revised === 0)
910
+ return null;
911
+ if (Object.keys(estados).length)
912
+ n.entry_status = estados;
913
+ else
914
+ delete n.entry_status;
915
+ n.last_accessed = cuando;
916
+ return n;
917
+ });
918
+ const n = (actualizada || neuron);
919
+ // The index drops retired entries (and picks reactivated ones back up).
920
+ if (revised > 0)
921
+ await this.reindex(n);
922
+ return { neuron: n, revised, unmatched };
923
+ }
924
+ /**
925
+ * Edit a neuron's metadata: summary, domain, tags, name.
926
+ *
927
+ * Only fields that actually differ are written and reported; a call that
928
+ * changes nothing writes nothing. `tags` replaces the whole list — protocol
929
+ * neurons keep their 'priority:'/'source:' tags only if the caller re-sends
930
+ * them. `name` changes the display name only: the id, and with it the file,
931
+ * the synapses, the shared map and the connections, never move. `domain`
932
+ * replaces unconditionally, unlike learn, which only fills in 'general'.
933
+ *
934
+ * Not emitted: NeuronOp is only an announcement, and metadata is local.
935
+ */
936
+ async setMeta(neuronRef, meta) {
937
+ const neuron = (await this.peek(neuronRef)) || (await this.findByName(neuronRef));
938
+ if (!neuron)
939
+ return { neuron: null, changed: [], redacted: [] };
940
+ let redacted = [];
941
+ let summary;
942
+ if (meta.summary !== undefined) {
943
+ const limpio = (0, secrets_js_1.redact)(meta.summary);
944
+ summary = limpio.text;
945
+ redacted = limpio.found;
946
+ }
947
+ const tags = meta.tags === undefined
948
+ ? undefined
949
+ : [...new Set(meta.tags.map(t => String(t || '').trim()).filter(Boolean))];
950
+ const domain = meta.domain === undefined ? undefined : meta.domain.trim();
951
+ const name = meta.name === undefined ? undefined : meta.name.trim();
952
+ const changed = [];
953
+ const actualizada = await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(neuron.id), current => {
954
+ const n = current || neuron;
955
+ if (summary !== undefined && summary !== n.summary) {
956
+ n.summary = summary;
957
+ changed.push('summary');
958
+ }
959
+ if (domain !== undefined && domain && domain !== n.domain) {
960
+ n.domain = domain;
961
+ changed.push('domain');
962
+ }
963
+ if (tags !== undefined) {
964
+ const actuales = n.tags || [];
965
+ if (tags.length !== actuales.length || tags.some((t, i) => t !== actuales[i])) {
966
+ n.tags = tags;
967
+ changed.push('tags');
968
+ }
969
+ }
970
+ if (name !== undefined && name && name !== n.name) {
971
+ n.name = name;
972
+ changed.push('name');
973
+ }
974
+ if (changed.length === 0)
975
+ return null;
976
+ n.last_accessed = (0, fs_js_1.now)();
977
+ return n;
978
+ });
979
+ const n = (actualizada || neuron);
980
+ // The header chunk carries name and tags; the summary is indexed too.
981
+ if (changed.length > 0)
982
+ await this.reindex(n);
983
+ return { neuron: n, changed, redacted };
984
+ }
544
985
  /**
545
986
  * Replace the neuron's system map.
546
987
  *
@@ -613,9 +1054,10 @@ class Cortex {
613
1054
  }
614
1055
  // Sort by heat (descending)
615
1056
  neurons.sort((a, b) => b.heat - a.heat);
616
- // Apply limit
1057
+ // Apply offset, then limit
617
1058
  const limit = options?.limit || 50;
618
- return neurons.slice(0, limit);
1059
+ const offset = Math.max(0, options?.offset || 0);
1060
+ return neurons.slice(offset, offset + limit);
619
1061
  }
620
1062
  /**
621
1063
  * Update a neuron's summary.
@@ -663,9 +1105,7 @@ class Cortex {
663
1105
  const found = (await this.peek(neuronRef)) || (await this.findByName(neuronRef));
664
1106
  if (!found)
665
1107
  return { neuron_id: null, removed: 0, backup: null };
666
- const sello = (0, fs_js_1.now)().replace(/[:.]/g, '-');
667
- const backup = `${this.brain.paths.quarantine}/${found.id}.${sello}.json`;
668
- await (0, fs_js_1.writeJSON)(backup, found);
1108
+ const backup = await this.quarantine(found);
669
1109
  let removed = 0;
670
1110
  const wanted = targets.map(t => t.trim().toLowerCase());
671
1111
  const after = await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(found.id), current => {
@@ -716,6 +1156,17 @@ class Cortex {
716
1156
  delete current.entry_dates[k];
717
1157
  }
718
1158
  }
1159
+ // Same for retirements: a key whose entry is gone is pruned, so the
1160
+ // sidecar cannot hide a line that no longer exists.
1161
+ if (current.entry_status) {
1162
+ const vivos = liveEntryKeys(current);
1163
+ for (const k of Object.keys(current.entry_status)) {
1164
+ if (!vivos.has(k))
1165
+ delete current.entry_status[k];
1166
+ }
1167
+ if (Object.keys(current.entry_status).length === 0)
1168
+ delete current.entry_status;
1169
+ }
719
1170
  current.last_accessed = (0, fs_js_1.now)();
720
1171
  return current;
721
1172
  });
@@ -733,6 +1184,16 @@ class Cortex {
733
1184
  await this.emit(found.id, { kind: 'status', fid: f.id || (0, hash_js_1.factId)(f.text), to: 'retracted', at: cuando, why: 'forgotten' });
734
1185
  }
735
1186
  }
1187
+ for (const d of found.decisions || []) {
1188
+ if (wanted.includes((d.text || '').trim().toLowerCase())) {
1189
+ await this.emit(found.id, { kind: 'decision_purge', key: (0, ops_js_1.entryId)(d.text), at: cuando });
1190
+ }
1191
+ }
1192
+ for (const p of found.patterns || []) {
1193
+ if (wanted.includes((p || '').trim().toLowerCase())) {
1194
+ await this.emit(found.id, { kind: 'pattern_purge', key: (0, ops_js_1.entryId)(p), at: cuando });
1195
+ }
1196
+ }
736
1197
  for (const e of found.errors || []) {
737
1198
  if (wanted.includes((e || '').trim().toLowerCase())) {
738
1199
  await this.emit(found.id, { kind: 'error_purge', key: (0, ops_js_1.entryId)(e), at: cuando });
@@ -749,6 +1210,167 @@ class Cortex {
749
1210
  }
750
1211
  return { neuron_id: found.id, removed, backup: removed > 0 ? backup : null };
751
1212
  }
1213
+ /**
1214
+ * Delete a whole neuron. Quarantine copy first, then the file goes, the
1215
+ * index drops its chunks and the manifest is decremented.
1216
+ *
1217
+ * Synapses are not touched here (the Cortex has no Synapses): the server
1218
+ * calls synapses.removeAllFor(id) right after. Nor is shared state checked
1219
+ * — the server refuses beforehand when the neuron is shared, because the
1220
+ * next sync would simply re-create it from the team log. Nothing is
1221
+ * emitted: there is no neuron-delete op.
1222
+ */
1223
+ async forgetNeuron(neuronRef) {
1224
+ const found = (await this.peek(neuronRef)) || (await this.findByName(neuronRef));
1225
+ if (!found) {
1226
+ return {
1227
+ neuron_id: null, backup: null,
1228
+ counts: { facts: 0, decisions: 0, patterns: 0, preferences: 0, errors: 0, debts: 0, connections: 0 },
1229
+ };
1230
+ }
1231
+ const backup = await this.quarantine(found);
1232
+ await this.deleteNeuronFile(found.id);
1233
+ return {
1234
+ neuron_id: found.id,
1235
+ backup,
1236
+ counts: {
1237
+ facts: (found.facts || []).length,
1238
+ decisions: (found.decisions || []).length,
1239
+ patterns: (found.patterns || []).length,
1240
+ preferences: (found.preferences || []).length,
1241
+ errors: (found.errors || []).length,
1242
+ debts: (found.debts || []).length,
1243
+ connections: (found.connections || []).length,
1244
+ },
1245
+ };
1246
+ }
1247
+ /**
1248
+ * Quarantine copies of one neuron, newest first. Stamps sort lexically, so
1249
+ * the file name alone orders them.
1250
+ */
1251
+ async quarantineCopies(neuronId) {
1252
+ const prefijo = `${neuronId}.`;
1253
+ const files = await (0, fs_js_1.listJSONFiles)(this.brain.paths.quarantine);
1254
+ return files.filter(f => f.startsWith(prefijo)).sort().reverse();
1255
+ }
1256
+ /**
1257
+ * Bring a neuron back from quarantine.
1258
+ *
1259
+ * The newest copy is used unless `file` names an exact basename. If the
1260
+ * neuron no longer exists the copy is written back as it was; if it does
1261
+ * (a forget of facts, or the topic was re-created since) the copy is
1262
+ * unioned into it. The quarantine file stays where it is, so a restore is
1263
+ * repeatable. Nothing is emitted.
1264
+ */
1265
+ async restoreNeuron(neuronId, options) {
1266
+ const nada = { neuron: null, restored_from: null, merged_into_existing: false, moved: null };
1267
+ let basename = null;
1268
+ if (options?.file) {
1269
+ const pedido = options.file.replace(/\.json$/i, '');
1270
+ const copias = await this.quarantineCopies(neuronId);
1271
+ basename = copias.includes(pedido) ? pedido : null;
1272
+ }
1273
+ else {
1274
+ basename = (await this.quarantineCopies(neuronId))[0] || null;
1275
+ }
1276
+ if (!basename)
1277
+ return nada;
1278
+ const ruta = `${this.brain.paths.quarantine}/${basename}.json`;
1279
+ const copia = await (0, fs_js_1.readJSON)(ruta);
1280
+ if (!copia || !copia.id)
1281
+ return nada;
1282
+ copia.id = neuronId;
1283
+ // Decide inside the lock, like create: whether the neuron exists again is
1284
+ // only known for sure while nobody else can write it.
1285
+ let escrita = false;
1286
+ let moved = null;
1287
+ const saved = await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(neuronId), current => {
1288
+ if (!current) {
1289
+ escrita = true;
1290
+ return copia;
1291
+ }
1292
+ const r = unionNeuron(current, copia);
1293
+ moved = r.moved;
1294
+ return r.neuron;
1295
+ });
1296
+ const final = (saved || copia);
1297
+ if (escrita) {
1298
+ const manifest = await this.brain.getManifest();
1299
+ await this.brain.updateManifest({ total_neurons: manifest.total_neurons + 1 });
1300
+ }
1301
+ await this.reindex(final);
1302
+ return { neuron: final, restored_from: ruta, merged_into_existing: !escrita, moved };
1303
+ }
1304
+ /**
1305
+ * Fold one neuron into another and delete the first.
1306
+ *
1307
+ * `from` is quarantined, unioned into `into`, and then removed exactly like
1308
+ * forgetNeuron. Each element that actually moved is emitted for `into`, so
1309
+ * a shared target receives the knowledge (the emitter no-ops when `into`
1310
+ * is unshared). Synapses are not touched — the server calls
1311
+ * synapses.rewire(from, into) right after — and the server refuses
1312
+ * beforehand when `from` is shared (unshare first, or the next sync
1313
+ * re-creates it).
1314
+ */
1315
+ async mergeNeurons(fromRef, intoRef) {
1316
+ const from = (await this.peek(fromRef)) || (await this.findByName(fromRef));
1317
+ const into = (await this.peek(intoRef)) || (await this.findByName(intoRef));
1318
+ if (!from || !into || from.id === into.id) {
1319
+ return { from: from?.id ?? null, into: into?.id ?? null, backup: null, moved: null };
1320
+ }
1321
+ const backup = await this.quarantine(from);
1322
+ let antes = into;
1323
+ let moved = null;
1324
+ const saved = await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(into.id), current => {
1325
+ antes = current || into;
1326
+ const r = unionNeuron(antes, from);
1327
+ moved = r.moved;
1328
+ return r.neuron;
1329
+ });
1330
+ const final = (saved || into);
1331
+ await this.reindex(final);
1332
+ // Emit what moved, by diffing the result against what `into` had. The
1333
+ // union is pure, so this is exact: nothing the target already held is
1334
+ // re-announced.
1335
+ const cuando = (0, fs_js_1.now)();
1336
+ const teniaFact = new Set(antes.facts.map(f => f.id || (0, hash_js_1.factId)(f.text)));
1337
+ for (const f of final.facts) {
1338
+ const fid = f.id || (0, hash_js_1.factId)(f.text);
1339
+ if (teniaFact.has(fid))
1340
+ continue;
1341
+ await this.emit(final.id, { kind: 'fact', text: f.text, fid, conf: f.confidence ?? 1, at: f.added, src: f.source, keys: f.keys });
1342
+ // A moved fact that was already retired must arrive retired, or the
1343
+ // teammates would see as current what the source had superseded.
1344
+ if (f.status === 'superseded' || f.status === 'retracted') {
1345
+ await this.emit(final.id, { kind: 'status', fid, to: f.status, at: f.revised || cuando, why: f.revision_note });
1346
+ }
1347
+ }
1348
+ const teniaDecision = new Set(antes.decisions.map(d => (0, ops_js_1.normalizeText)(d.text)));
1349
+ for (const d of final.decisions) {
1350
+ if (teniaDecision.has((0, ops_js_1.normalizeText)(d.text)))
1351
+ continue;
1352
+ await this.emit(final.id, { kind: 'decision', text: d.text, why: d.rationale || undefined, at: d.date || cuando });
1353
+ }
1354
+ const fecha = (t) => final.entry_dates?.[(0, ops_js_1.entryId)(t)] || cuando;
1355
+ const teniaPattern = new Set((antes.patterns || []).map(ops_js_1.normalizeText));
1356
+ for (const p of final.patterns) {
1357
+ if (!teniaPattern.has((0, ops_js_1.normalizeText)(p)))
1358
+ await this.emit(final.id, { kind: 'pattern', text: p, at: fecha(p) });
1359
+ }
1360
+ const teniaError = new Set((antes.errors || []).map(ops_js_1.normalizeText));
1361
+ for (const e of final.errors || []) {
1362
+ if (!teniaError.has((0, ops_js_1.normalizeText)(e)))
1363
+ await this.emit(final.id, { kind: 'error', text: e, at: fecha(e) });
1364
+ }
1365
+ const teniaDebt = new Set((antes.debts || []).map(ops_js_1.normalizeText));
1366
+ for (const d of final.debts || []) {
1367
+ if (!teniaDebt.has((0, ops_js_1.normalizeText)(d)))
1368
+ await this.emit(final.id, { kind: 'debt', text: d, at: fecha(d) });
1369
+ }
1370
+ // Preferences are never emitted, here as in learn.
1371
+ await this.deleteNeuronFile(from.id);
1372
+ return { from: from.id, into: into.id, backup, moved };
1373
+ }
752
1374
  /**
753
1375
  * Which neurons hold something that looks like a credential.
754
1376
  * Reports the kind and where it is, never the value.