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