crbro-memory 2.10.0 → 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +256 -465
- package/SECURITY.md +114 -12
- package/bin/crbro.mjs +539 -300
- 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 +139 -19
- package/dist/engine/brain.js +22 -6
- package/dist/engine/codexconfig.js +476 -0
- package/dist/engine/cortex.js +667 -232
- package/dist/engine/doctor.js +312 -14
- package/dist/engine/heat.js +81 -37
- package/dist/engine/hippocampus.js +18 -7
- 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 +721 -162
- package/dist/engine/maintenance.js +355 -87
- package/dist/engine/modinstall.js +98 -45
- package/dist/engine/prefrontal.js +29 -6
- package/dist/engine/quarantine.js +204 -0
- package/dist/engine/secrets.js +41 -5
- package/dist/engine/shelf.js +25 -3
- 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 +797 -176
- package/dist/search/semantic.js +208 -18
- package/dist/search/tokenize.js +44 -0
- package/dist/server.js +583 -373
- package/dist/sync/git.js +203 -72
- package/dist/sync/space.js +100 -25
- 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 +94 -7
- 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 +9 -5
- 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 -682
- 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/doctor.d.ts +0 -93
- package/dist/engine/doctor.d.ts.map +0 -1
- package/dist/engine/doctor.js.map +0 -1
- package/dist/engine/evidence.d.ts +0 -26
- package/dist/engine/evidence.d.ts.map +0 -1
- package/dist/engine/evidence.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 -64
- package/dist/engine/prefrontal.d.ts.map +0 -1
- package/dist/engine/prefrontal.js.map +0 -1
- package/dist/engine/projects.d.ts +0 -20
- package/dist/engine/projects.d.ts.map +0 -1
- package/dist/engine/projects.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 -155
- 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 -328
- package/dist/search/index.d.ts.map +0 -1
- package/dist/search/index.js.map +0 -1
- package/dist/search/quality.d.ts +0 -18
- package/dist/search/quality.d.ts.map +0 -1
- package/dist/search/quality.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 -36
- package/dist/sync/materialize.d.ts.map +0 -1
- package/dist/sync/materialize.js.map +0 -1
- package/dist/sync/ops.d.ts +0 -147
- package/dist/sync/ops.d.ts.map +0 -1
- package/dist/sync/ops.js.map +0 -1
- package/dist/sync/space.d.ts +0 -118
- package/dist/sync/space.d.ts.map +0 -1
- package/dist/sync/space.js.map +0 -1
- package/dist/types/index.d.ts +0 -369
- 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 -13
- 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,15 +1,24 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
// ─── CRBRO Cortex Engine ─────────────────────────────────────────
|
|
3
3
|
// Neuron CRUD — create, read, update, list neurons
|
|
4
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
5
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
6
|
+
};
|
|
4
7
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
-
exports.Cortex = exports.MAX_PROMOTION_CANDIDATES = exports.sessionScope = exports.newTally = void 0;
|
|
8
|
+
exports.Cortex = exports.MAX_PROMOTION_CANDIDATES = exports.sessionScope = exports.newTally = exports.UnreadableNeuronError = void 0;
|
|
6
9
|
exports.nearDuplicatesAmong = nearDuplicatesAmong;
|
|
7
10
|
exports.findPromotionCandidates = findPromotionCandidates;
|
|
8
11
|
exports.unionNeuron = unionNeuron;
|
|
9
12
|
exports.normalizeKeys = normalizeKeys;
|
|
10
13
|
const node_async_hooks_1 = require("node:async_hooks");
|
|
14
|
+
const node_fs_1 = require("node:fs");
|
|
15
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
11
16
|
const fs_js_1 = require("../utils/fs.js");
|
|
17
|
+
const lock_js_1 = require("../utils/lock.js");
|
|
12
18
|
const ids_js_1 = require("../utils/ids.js");
|
|
19
|
+
const quarantine_js_1 = require("./quarantine.js");
|
|
20
|
+
const backup_js_1 = require("./backup.js");
|
|
21
|
+
const heat_js_1 = require("./heat.js");
|
|
13
22
|
const hash_js_1 = require("../utils/hash.js");
|
|
14
23
|
const ops_js_1 = require("../sync/ops.js");
|
|
15
24
|
const materialize_js_1 = require("../sync/materialize.js");
|
|
@@ -25,8 +34,34 @@ const TYPE_PREFIX_RE = /^(project_|tech_|lang_|person_|domain_|process_|protocol
|
|
|
25
34
|
*/
|
|
26
35
|
const NEAR_DUP_THRESHOLD = 0.8;
|
|
27
36
|
const NEAR_DUP_MIN_LENGTH = 60;
|
|
28
|
-
/**
|
|
29
|
-
|
|
37
|
+
/**
|
|
38
|
+
* A neuron file that exists and cannot be parsed. Readers skip it and list it
|
|
39
|
+
* (Cortex.unreadable); writers refuse with this error, which names the file
|
|
40
|
+
* and where a good copy may be.
|
|
41
|
+
*/
|
|
42
|
+
class UnreadableNeuronError extends Error {
|
|
43
|
+
neuronId;
|
|
44
|
+
path;
|
|
45
|
+
recovery;
|
|
46
|
+
constructor(neuronId, path, recovery, reason) {
|
|
47
|
+
super(`Neuron "${neuronId}" cannot be read (${reason}). Nothing was written. ${recovery}`);
|
|
48
|
+
this.neuronId = neuronId;
|
|
49
|
+
this.path = path;
|
|
50
|
+
this.recovery = recovery;
|
|
51
|
+
this.name = 'UnreadableNeuronError';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
exports.UnreadableNeuronError = UnreadableNeuronError;
|
|
55
|
+
/**
|
|
56
|
+
* The same identifier written two ways: underscores dropped and each long
|
|
57
|
+
* word's plural folded ("widget_catalogs" ~ "widget catalog", "octo_chat" ~
|
|
58
|
+
* "octochat"). Nothing else. The bigram similarity this replaces merged
|
|
59
|
+
* "sprint_a" into "sprint_b" (0.857 ≥ 0.85) and any two long names one letter
|
|
60
|
+
* apart: a letter that differs is usually what tells two things apart.
|
|
61
|
+
*/
|
|
62
|
+
function nameVariantKey(slug) {
|
|
63
|
+
return slug.split('_').filter(Boolean).map(t => (t.length > 4 && !/\d/.test(t) ? t.replace(/(es|s)$/, '') : t)).join('');
|
|
64
|
+
}
|
|
30
65
|
const newTally = () => ({ facts: 0, decisions: 0, topics: new Set(), witnessed: new Set() });
|
|
31
66
|
exports.newTally = newTally;
|
|
32
67
|
/**
|
|
@@ -41,28 +76,26 @@ exports.sessionScope = new node_async_hooks_1.AsyncLocalStorage();
|
|
|
41
76
|
* for "Widget Catalog" vs "widget_catalog", and — unlike substring containment —
|
|
42
77
|
* it does not consider a 60-character walkthrough id a match for "SEO".
|
|
43
78
|
*/
|
|
44
|
-
function
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
79
|
+
function bigramsOf(s) {
|
|
80
|
+
const out = new Map();
|
|
81
|
+
for (let i = 0; i < s.length - 1; i++) {
|
|
82
|
+
const g = s.slice(i, i + 2);
|
|
83
|
+
out.set(g, (out.get(g) || 0) + 1);
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
/** Dice of a precomputed bigram map (text of length aLen) against b. */
|
|
88
|
+
function diceFrom(A, aLen, b) {
|
|
89
|
+
if (aLen < 2 || b.length < 2)
|
|
48
90
|
return 0;
|
|
49
|
-
const
|
|
50
|
-
const out = new Map();
|
|
51
|
-
for (let i = 0; i < s.length - 1; i++) {
|
|
52
|
-
const g = s.slice(i, i + 2);
|
|
53
|
-
out.set(g, (out.get(g) || 0) + 1);
|
|
54
|
-
}
|
|
55
|
-
return out;
|
|
56
|
-
};
|
|
57
|
-
const A = bigrams(a);
|
|
58
|
-
const B = bigrams(b);
|
|
91
|
+
const B = bigramsOf(b);
|
|
59
92
|
let shared = 0;
|
|
60
93
|
for (const [g, countA] of A) {
|
|
61
94
|
const countB = B.get(g);
|
|
62
95
|
if (countB)
|
|
63
96
|
shared += Math.min(countA, countB);
|
|
64
97
|
}
|
|
65
|
-
return (2 * shared) / (
|
|
98
|
+
return (2 * shared) / (aLen - 1 + b.length - 1);
|
|
66
99
|
}
|
|
67
100
|
/**
|
|
68
101
|
* Do two slugs carry the same numbers, in the same order?
|
|
@@ -89,17 +122,20 @@ function nearDuplicatesAmong(content, candidates) {
|
|
|
89
122
|
if (content.length < NEAR_DUP_MIN_LENGTH)
|
|
90
123
|
return [];
|
|
91
124
|
const nuevo = content.toLowerCase();
|
|
125
|
+
// The new text's bigrams once, not once per candidate (2.11): on a 700-fact
|
|
126
|
+
// neuron that was the same map rebuilt hundreds of times per learn.
|
|
127
|
+
const bigramasNuevo = bigramsOf(nuevo);
|
|
92
128
|
const out = [];
|
|
93
129
|
for (const c of candidates) {
|
|
94
130
|
if (!c.text || c.text.length < NEAR_DUP_MIN_LENGTH)
|
|
95
131
|
continue;
|
|
96
132
|
// Dice over bigrams cannot reach the threshold when one side is much
|
|
97
133
|
// longer: 2·min/(a+b) bounds it. A free filter before the real count.
|
|
98
|
-
const
|
|
99
|
-
const a = nuevo.length, b = otro.length;
|
|
134
|
+
const a = nuevo.length, b = c.text.length;
|
|
100
135
|
if ((2 * Math.min(a, b)) / (a + b) < NEAR_DUP_THRESHOLD)
|
|
101
136
|
continue;
|
|
102
|
-
const
|
|
137
|
+
const otro = c.text.toLowerCase();
|
|
138
|
+
const score = nuevo === otro ? 1 : diceFrom(bigramasNuevo, nuevo.length, otro);
|
|
103
139
|
if (score >= NEAR_DUP_THRESHOLD)
|
|
104
140
|
out.push({ id: c.id, similarity: Math.round(score * 100) / 100, text: c.text });
|
|
105
141
|
}
|
|
@@ -548,20 +584,108 @@ class Cortex {
|
|
|
548
584
|
/**
|
|
549
585
|
* Copy a whole neuron to .quarantine/ with a timestamp, before anything
|
|
550
586
|
* destructive. Same stamp format everywhere, so restoreNeuron can pick the
|
|
551
|
-
* newest copy by name alone.
|
|
587
|
+
* newest copy by name alone. Sealed when encryption at rest is on; throws
|
|
588
|
+
* (and the caller removes nothing) when it is on and the key is missing.
|
|
552
589
|
*/
|
|
553
590
|
async quarantine(neuron) {
|
|
554
|
-
|
|
555
|
-
const backup = `${this.brain.paths.quarantine}/${neuron.id}.${sello}.json`;
|
|
556
|
-
await (0, fs_js_1.writeJSON)(backup, neuron);
|
|
557
|
-
return backup;
|
|
591
|
+
return (0, quarantine_js_1.writeQuarantine)(this.brain.paths.quarantine, neuron.id, neuron, 'whole');
|
|
558
592
|
}
|
|
559
|
-
/**
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
const
|
|
564
|
-
|
|
593
|
+
/** Neuron files that exist and could not be parsed, seen by this process (2.11). */
|
|
594
|
+
skipped = new Map();
|
|
595
|
+
/** Where a good copy of a neuron may be: the newest backup, or a quarantine copy. */
|
|
596
|
+
async recoveryHint(id) {
|
|
597
|
+
const partes = [];
|
|
598
|
+
try {
|
|
599
|
+
const copias = (await (0, quarantine_js_1.listQuarantine)(this.brain.paths.quarantine, id)).filter(c => c.kind === 'whole');
|
|
600
|
+
if (copias.length)
|
|
601
|
+
partes.push(`a whole copy is in quarantine (${node_path_1.default.join(this.brain.paths.quarantine, copias[0].file)}; crbro_forget restore:true brings it back once the bad file is moved aside)`);
|
|
602
|
+
}
|
|
603
|
+
catch { /* no quarantine */ }
|
|
604
|
+
try {
|
|
605
|
+
const dir = (0, backup_js_1.resolveBackupDir)(this.brain.paths.root);
|
|
606
|
+
const b = (await (0, backup_js_1.listBackups)(dir))[0];
|
|
607
|
+
if (b)
|
|
608
|
+
partes.push(`the newest backup is ${b.path} (npx crbro-memory backup restore "${b.file}" --into <empty folder>, then copy cortex/${id}.json back)`);
|
|
609
|
+
}
|
|
610
|
+
catch { /* no backups */ }
|
|
611
|
+
const donde = partes.length ? `Recovery: ${partes.join('; or ')}.` : 'No backup or quarantine copy was found.';
|
|
612
|
+
return `${donde} Or fix the JSON by hand (a UTF-8 byte-order mark is accepted since 2.11).`;
|
|
613
|
+
}
|
|
614
|
+
async noteUnreadable(id, err) {
|
|
615
|
+
const info = {
|
|
616
|
+
id,
|
|
617
|
+
path: this.brain.paths.neuron(id),
|
|
618
|
+
error: err instanceof fs_js_1.CorruptJSONError ? err.reason : err?.message || String(err),
|
|
619
|
+
recovery: await this.recoveryHint(id),
|
|
620
|
+
};
|
|
621
|
+
this.skipped.set(id, info);
|
|
622
|
+
return info;
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Unreadable neuron files: the ones readers met and skipped since this
|
|
626
|
+
* process started (default), or every one on disk now (`scan: true`).
|
|
627
|
+
* Callers that list or search the cortex surface these instead of failing.
|
|
628
|
+
*/
|
|
629
|
+
async unreadable(options) {
|
|
630
|
+
if (options?.scan) {
|
|
631
|
+
for (const id of await (0, fs_js_1.listJSONFiles)(this.brain.paths.cortex)) {
|
|
632
|
+
try {
|
|
633
|
+
await (0, fs_js_1.readJSON)(this.brain.paths.neuron(id));
|
|
634
|
+
this.skipped.delete(id);
|
|
635
|
+
}
|
|
636
|
+
catch (err) {
|
|
637
|
+
await this.noteUnreadable(id, err);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
// A file fixed since it was seen is no longer reported.
|
|
642
|
+
const out = [];
|
|
643
|
+
for (const [id, info] of this.skipped) {
|
|
644
|
+
try {
|
|
645
|
+
await (0, fs_js_1.readJSON)(this.brain.paths.neuron(id));
|
|
646
|
+
this.skipped.delete(id);
|
|
647
|
+
}
|
|
648
|
+
catch {
|
|
649
|
+
out.push(info);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
return out.sort((a, b) => (a.id < b.id ? -1 : 1));
|
|
653
|
+
}
|
|
654
|
+
/** A strict read for write paths: null when absent, UnreadableNeuronError when corrupt. */
|
|
655
|
+
async readStrict(id) {
|
|
656
|
+
try {
|
|
657
|
+
return await (0, fs_js_1.readJSON)(this.brain.paths.neuron(id));
|
|
658
|
+
}
|
|
659
|
+
catch (err) {
|
|
660
|
+
if (!(await (0, fs_js_1.fileExists)(this.brain.paths.neuron(id))))
|
|
661
|
+
return null;
|
|
662
|
+
const info = await this.noteUnreadable(id, err);
|
|
663
|
+
throw new UnreadableNeuronError(id, info.path, info.recovery, info.error);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* updateJSON on a neuron, with a corrupt file turned into an error that
|
|
668
|
+
* names it and says where a good copy is.
|
|
669
|
+
*/
|
|
670
|
+
async updateNeuron(id, mutate) {
|
|
671
|
+
try {
|
|
672
|
+
return await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(id), mutate);
|
|
673
|
+
}
|
|
674
|
+
catch (err) {
|
|
675
|
+
if (err instanceof fs_js_1.CorruptJSONError) {
|
|
676
|
+
const info = await this.noteUnreadable(id, err);
|
|
677
|
+
throw new UnreadableNeuronError(id, info.path, info.recovery, info.error);
|
|
678
|
+
}
|
|
679
|
+
throw err;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
/** Resolution for write paths: exact id first, then the name; a corrupt match is an error, not a miss. */
|
|
683
|
+
async resolveForWrite(ref) {
|
|
684
|
+
return (await this.readStrict(ref)) || (await this.findByName(ref, { strict: true }));
|
|
685
|
+
}
|
|
686
|
+
/** The neuron was deleted between resolution and the lock: a write must not bring it back. */
|
|
687
|
+
deletedMeanwhile(id) {
|
|
688
|
+
return new Error(`Neuron "${id}" was deleted while this write was in progress. Nothing was written, so it was not brought back.`);
|
|
565
689
|
}
|
|
566
690
|
async emit(neuronId, change) {
|
|
567
691
|
if (!this.emitter)
|
|
@@ -582,7 +706,7 @@ class Cortex {
|
|
|
582
706
|
async mergeFromSync(nid, ops) {
|
|
583
707
|
let report;
|
|
584
708
|
let created = false;
|
|
585
|
-
const saved = await
|
|
709
|
+
const saved = await this.updateNeuron(nid, current => {
|
|
586
710
|
created = current === null;
|
|
587
711
|
const merged = (0, materialize_js_1.applyOps)(current, ops, { id: nid });
|
|
588
712
|
report = merged.report;
|
|
@@ -597,19 +721,46 @@ class Cortex {
|
|
|
597
721
|
if (!this.indexer)
|
|
598
722
|
return;
|
|
599
723
|
try {
|
|
600
|
-
|
|
724
|
+
// Heat lives in the derived heat state since 2.11 and is no longer
|
|
725
|
+
// rewritten into every neuron file; the index gets the current value.
|
|
726
|
+
const h = (await this.heatMap()).get(neuron.id);
|
|
727
|
+
await this.indexer(h === undefined || h === neuron.heat ? neuron : { ...neuron, heat: h });
|
|
601
728
|
}
|
|
602
729
|
catch {
|
|
603
730
|
// Indexing must never break a write. The index is derived data.
|
|
604
731
|
}
|
|
605
732
|
}
|
|
733
|
+
heatCache = null;
|
|
734
|
+
/**
|
|
735
|
+
* id → heat from the derived heat state (prefrontal/heat_state.json),
|
|
736
|
+
* cached on the file's mtime. Empty when it was never computed: readers
|
|
737
|
+
* then fall back to the heat stored in the neuron, as before 2.11.
|
|
738
|
+
*/
|
|
739
|
+
async heatMap() {
|
|
740
|
+
const file = node_path_1.default.join(this.brain.paths.prefrontal, 'heat_state.json');
|
|
741
|
+
let mtime = 0;
|
|
742
|
+
try {
|
|
743
|
+
mtime = (await node_fs_1.promises.stat(file)).mtimeMs;
|
|
744
|
+
}
|
|
745
|
+
catch {
|
|
746
|
+
return new Map();
|
|
747
|
+
}
|
|
748
|
+
if (this.heatCache && this.heatCache.mtime === mtime)
|
|
749
|
+
return this.heatCache.map;
|
|
750
|
+
const state = await (0, heat_js_1.readHeatState)(this.brain).catch(() => null);
|
|
751
|
+
const map = new Map();
|
|
752
|
+
for (const [id, e] of Object.entries(state?.neurons || {}))
|
|
753
|
+
map.set(id, e.heat);
|
|
754
|
+
this.heatCache = { mtime, map };
|
|
755
|
+
return map;
|
|
756
|
+
}
|
|
606
757
|
/**
|
|
607
758
|
* Get a neuron by ID.
|
|
608
759
|
*/
|
|
609
760
|
async get(id) {
|
|
610
761
|
// Even a read touches the file (access time and count), so it goes through
|
|
611
762
|
// the same locked read-modify-write as everything else.
|
|
612
|
-
return
|
|
763
|
+
return this.updateNeuron(id, current => {
|
|
613
764
|
if (!current)
|
|
614
765
|
return null;
|
|
615
766
|
current.last_accessed = (0, fs_js_1.now)();
|
|
@@ -619,9 +770,24 @@ class Cortex {
|
|
|
619
770
|
}
|
|
620
771
|
/**
|
|
621
772
|
* Get a neuron by ID without updating access stats.
|
|
773
|
+
*
|
|
774
|
+
* Tolerant (2.11): a file that exists and cannot be parsed reads as null
|
|
775
|
+
* and is recorded in unreadable(), so one truncated neuron no longer takes
|
|
776
|
+
* down recall, inspect or consolidate. Write paths use a strict read.
|
|
622
777
|
*/
|
|
623
778
|
async peek(id) {
|
|
624
|
-
|
|
779
|
+
// A reference with a path separator is a name ("CI/CD") or an attempt to leave the brain, never an id.
|
|
780
|
+
if (typeof id !== 'string' || !id || /[\\/:\0]/.test(id))
|
|
781
|
+
return null;
|
|
782
|
+
try {
|
|
783
|
+
return await (0, fs_js_1.readJSON)(this.brain.paths.neuron(id));
|
|
784
|
+
}
|
|
785
|
+
catch (err) {
|
|
786
|
+
// An existing file that does not parse is recorded; anything else is a miss.
|
|
787
|
+
if (await (0, fs_js_1.fileExists)(this.brain.paths.neuron(id)))
|
|
788
|
+
await this.noteUnreadable(id, err);
|
|
789
|
+
return null;
|
|
790
|
+
}
|
|
625
791
|
}
|
|
626
792
|
/**
|
|
627
793
|
* Find a neuron by name.
|
|
@@ -635,49 +801,76 @@ class Cortex {
|
|
|
635
801
|
* wrong neuron, and no amount of index tuning repairs that. A wrong guess
|
|
636
802
|
* is worse than a new neuron.
|
|
637
803
|
*/
|
|
638
|
-
async findByName(name) {
|
|
639
|
-
const
|
|
640
|
-
|
|
641
|
-
if (!slug)
|
|
804
|
+
async findByName(name, options) {
|
|
805
|
+
const r = await this.resolveTopic(name, options);
|
|
806
|
+
if (!r)
|
|
642
807
|
return null;
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
808
|
+
return options?.strict ? this.readStrict(r.id) : this.peek(r.id);
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* Which existing neuron a topic names, without reading any neuron unless
|
|
812
|
+
* two candidates tie. In order, for each slug the name may be filed under
|
|
813
|
+
* (current rule, the 2.10 rule, the pre-1.x accent-mangling rule):
|
|
814
|
+
*
|
|
815
|
+
* 1. `id` — the slug IS an existing id ("project_crbro");
|
|
816
|
+
* 2. `name` — an id minus its type prefix equals the slug;
|
|
817
|
+
* 3. `variant` — the same identifier written with or without underscores
|
|
818
|
+
* or a plural (see nameVariantKey). Never a letter or a number apart.
|
|
819
|
+
*
|
|
820
|
+
* Several neurons of different types on one name (project_react and
|
|
821
|
+
* tech_react): the requested type wins, then the type an id-shaped topic
|
|
822
|
+
* names, then the type the name implies; failing that the most used, and
|
|
823
|
+
* the others come back in `ambiguous` so the caller can say so.
|
|
824
|
+
*/
|
|
825
|
+
async resolveTopic(name, options) {
|
|
826
|
+
const cands = (0, ids_js_1.slugCandidates)(name);
|
|
827
|
+
if (cands.length === 0)
|
|
828
|
+
return null;
|
|
829
|
+
const ids = await (0, fs_js_1.listJSONFiles)(this.brain.paths.cortex);
|
|
830
|
+
const idSet = new Set(ids);
|
|
831
|
+
const bareOf = (id) => id.replace(TYPE_PREFIX_RE, '');
|
|
832
|
+
const typeOf = (id) => (0, ids_js_1.typeOfIdShape)(id);
|
|
833
|
+
const prefer = [];
|
|
834
|
+
if (options?.type)
|
|
835
|
+
prefer.push(options.type);
|
|
836
|
+
const shape = (0, ids_js_1.typeOfIdShape)(cands[0]);
|
|
837
|
+
if (shape)
|
|
838
|
+
prefer.push(shape);
|
|
839
|
+
prefer.push((0, ids_js_1.inferNeuronType)(name));
|
|
840
|
+
const pick = async (matches, resolved_by) => {
|
|
841
|
+
if (matches.length === 1)
|
|
842
|
+
return { id: matches[0], resolved_by };
|
|
843
|
+
for (const t of prefer) {
|
|
844
|
+
const delTipo = matches.filter(id => typeOf(id) === t);
|
|
845
|
+
if (delTipo.length === 1)
|
|
846
|
+
return { id: delTipo[0], resolved_by, ambiguous: matches.filter(m => m !== delTipo[0]) };
|
|
654
847
|
}
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
if (score >= NAME_MATCH_THRESHOLD && sameNumbers(slug, bare)) {
|
|
662
|
-
candidates.push({ id, score });
|
|
848
|
+
let best = null;
|
|
849
|
+
for (const id of [...matches].sort()) {
|
|
850
|
+
const n = await this.peek(id);
|
|
851
|
+
const uses = n?.access_count || 0;
|
|
852
|
+
if (!best || uses > best.uses)
|
|
853
|
+
best = { id, uses };
|
|
663
854
|
}
|
|
855
|
+
return { id: best.id, resolved_by, ambiguous: matches.filter(m => m !== best.id) };
|
|
856
|
+
};
|
|
857
|
+
for (const s of cands) {
|
|
858
|
+
if (idSet.has(s))
|
|
859
|
+
return { id: s, resolved_by: 'id' };
|
|
860
|
+
// An id-shaped slug also matches an id that carries its prefix twice
|
|
861
|
+
// ("project_x" → project_project_x, made by 2.10 from such a topic):
|
|
862
|
+
// that one's bare id is the slug itself.
|
|
863
|
+
const bare = ids.filter(id => bareOf(id) === s);
|
|
864
|
+
if (bare.length)
|
|
865
|
+
return pick(bare, 'name');
|
|
664
866
|
}
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
if (tied.length === 1)
|
|
671
|
-
return this.peek(tied[0].id);
|
|
672
|
-
let best = null;
|
|
673
|
-
for (const c of tied) {
|
|
674
|
-
const n = await this.peek(c.id);
|
|
675
|
-
if (!n)
|
|
676
|
-
continue;
|
|
677
|
-
if (!best || (n.access_count || 0) > (best.access_count || 0))
|
|
678
|
-
best = n;
|
|
867
|
+
const key = nameVariantKey(cands[0].replace(TYPE_PREFIX_RE, ''));
|
|
868
|
+
if (key.length >= 4) {
|
|
869
|
+
const variantes = ids.filter(id => nameVariantKey(bareOf(id)) === key);
|
|
870
|
+
if (variantes.length)
|
|
871
|
+
return pick(variantes, 'variant');
|
|
679
872
|
}
|
|
680
|
-
return
|
|
873
|
+
return null;
|
|
681
874
|
}
|
|
682
875
|
/**
|
|
683
876
|
* Create a new neuron.
|
|
@@ -689,7 +882,7 @@ class Cortex {
|
|
|
689
882
|
// Check-then-create has to happen inside the lock, or two clients starting
|
|
690
883
|
// the same topic at the same moment both think they are the first.
|
|
691
884
|
let creada = false;
|
|
692
|
-
const neuron = await
|
|
885
|
+
const neuron = await this.updateNeuron(id, current => {
|
|
693
886
|
if (current)
|
|
694
887
|
return null;
|
|
695
888
|
creada = true;
|
|
@@ -733,32 +926,72 @@ class Cortex {
|
|
|
733
926
|
// exists and what kind — the knowledge without the liability.
|
|
734
927
|
const limpio = (0, secrets_js_1.redact)(content);
|
|
735
928
|
content = limpio.text;
|
|
736
|
-
|
|
929
|
+
// The rationale and the aliases are stored, indexed and shared too (G8).
|
|
930
|
+
if (options?.rationale || options?.keys?.length) {
|
|
931
|
+
options = { ...options,
|
|
932
|
+
...(options.rationale ? { rationale: (0, secrets_js_1.redact)(options.rationale).text } : {}),
|
|
933
|
+
...(options.keys?.length ? { keys: options.keys.map(k => (0, secrets_js_1.redact)(k).text) } : {}) };
|
|
934
|
+
}
|
|
737
935
|
let action = 'updated';
|
|
936
|
+
let targetId;
|
|
937
|
+
let resolvedBy;
|
|
938
|
+
let ambiguous;
|
|
939
|
+
// Resolution reads no neuron (2.11): only the id is needed here, and the
|
|
940
|
+
// lock below reads the file anyway. On a 1 MB neuron that was a whole
|
|
941
|
+
// extra parse per learn.
|
|
738
942
|
if (options?.neuronId) {
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
943
|
+
// An explicit id is a promise about where the knowledge goes. A typo in
|
|
944
|
+
// it used to fall back to the topic and write somewhere else, silently.
|
|
945
|
+
const nid = options.neuronId.trim();
|
|
946
|
+
const existe = !!nid && !/[\\/]/.test(nid) && await (0, fs_js_1.fileExists)(this.brain.paths.neuron(nid));
|
|
947
|
+
// The one case an absent id is created: it is exactly the id the topic
|
|
948
|
+
// itself makes ("Tienda" + project_tienda). Anything else would be the
|
|
949
|
+
// knowledge landing somewhere the caller did not name.
|
|
950
|
+
const tipoDelId = existe ? null : (0, ids_js_1.typeOfIdShape)(nid);
|
|
951
|
+
const mismoId = !!tipoDelId && !!topic.trim() && (0, ids_js_1.neuronId)(topic, tipoDelId) === nid;
|
|
952
|
+
if (existe) {
|
|
953
|
+
targetId = nid;
|
|
954
|
+
resolvedBy = 'neuron_id';
|
|
955
|
+
}
|
|
956
|
+
else if (mismoId && options.createIfMissing !== false) {
|
|
957
|
+
targetId = (await this.create(topic, tipoDelId, options.domain || 'general')).id;
|
|
958
|
+
action = 'created';
|
|
959
|
+
resolvedBy = 'created';
|
|
960
|
+
}
|
|
961
|
+
else {
|
|
962
|
+
throw new Error(`No neuron with id "${options.neuronId}". Nothing was written. ` +
|
|
963
|
+
'Find the right id with crbro_recall or crbro_inspect view=neurons, or pass only topic to resolve it by name.');
|
|
964
|
+
}
|
|
743
965
|
}
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
966
|
+
else {
|
|
967
|
+
const r = await this.resolveTopic(topic, { type: options?.neuronType });
|
|
968
|
+
if (r) {
|
|
969
|
+
targetId = r.id;
|
|
970
|
+
resolvedBy = r.resolved_by;
|
|
971
|
+
ambiguous = r.ambiguous;
|
|
972
|
+
}
|
|
973
|
+
else {
|
|
974
|
+
if (options?.createIfMissing === false) {
|
|
975
|
+
return {
|
|
976
|
+
neuron: null, action: 'skipped', superseded: 0,
|
|
977
|
+
supersedes_unmatched: options?.supersedes || [], near_duplicates: [], redacted: limpio.found,
|
|
978
|
+
duplicate: false, updated_in_place: false, skipped_retired: null,
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
const nType = options?.neuronType || (0, ids_js_1.inferNeuronType)(topic);
|
|
982
|
+
const domain = options?.domain || 'general';
|
|
983
|
+
targetId = (await this.create(topic, nType, domain)).id;
|
|
984
|
+
action = 'created';
|
|
985
|
+
resolvedBy = 'created';
|
|
751
986
|
}
|
|
752
|
-
const nType = options?.neuronType || (0, ids_js_1.inferNeuronType)(topic);
|
|
753
|
-
const domain = options?.domain || 'general';
|
|
754
|
-
neuron = await this.create(topic, nType, domain);
|
|
755
|
-
action = 'created';
|
|
756
987
|
}
|
|
757
988
|
let superseded = 0;
|
|
758
|
-
//
|
|
759
|
-
//
|
|
760
|
-
//
|
|
989
|
+
// Only targets that matched nothing, live or already retired: a retry of
|
|
990
|
+
// a supersedes that already ran finds its target retired, and warning
|
|
991
|
+
// "still live" about it would be a false alarm.
|
|
761
992
|
let supersedesUnmatched = [];
|
|
993
|
+
let retiradosAhora = [];
|
|
994
|
+
let gone = false;
|
|
762
995
|
let nearDuplicates = [];
|
|
763
996
|
let emitir = null;
|
|
764
997
|
let duplicate = false;
|
|
@@ -803,13 +1036,50 @@ class Cortex {
|
|
|
803
1036
|
delete n.entry_source;
|
|
804
1037
|
}
|
|
805
1038
|
};
|
|
806
|
-
|
|
807
|
-
|
|
1039
|
+
// One identity rule for every kind (2.11): the normalised text entryId
|
|
1040
|
+
// hashes — whitespace runs collapsed, NFC. Before, a pattern differing by
|
|
1041
|
+
// a line break was stored twice under one sidecar key, and a fact with a
|
|
1042
|
+
// trailing newline became a second fact.
|
|
1043
|
+
const clave = (0, ops_js_1.normalizeText)(content);
|
|
1044
|
+
const mismaEntrada = (t) => t === content || (0, ops_js_1.normalizeText)(t) === clave;
|
|
1045
|
+
const claveLower = clave.toLowerCase();
|
|
1046
|
+
const contentLower = content.toLowerCase();
|
|
1047
|
+
const mismoHecho = (t) => {
|
|
1048
|
+
if (t.length === content.length && t.toLowerCase() === contentLower)
|
|
1049
|
+
return true;
|
|
1050
|
+
// Normalising only shrinks a text, so a shorter one cannot match.
|
|
1051
|
+
if (t.length < clave.length || t.length > clave.length * 2 + 16)
|
|
1052
|
+
return false;
|
|
1053
|
+
return (0, ops_js_1.normalizeText)(t).toLowerCase() === claveLower;
|
|
1054
|
+
};
|
|
1055
|
+
const supersedeDesde = (n, propio) => {
|
|
1056
|
+
if (!options?.supersedes?.length)
|
|
1057
|
+
return;
|
|
1058
|
+
// A fact never supersedes itself, whatever the caller listed.
|
|
1059
|
+
const objetivos = options.supersedes.filter(t => t.trim().toLowerCase() !== propio.id.toLowerCase() && !mismoHecho(t.trim()));
|
|
1060
|
+
const r = this.retire(n, objetivos, propio.id, 'superseded');
|
|
1061
|
+
superseded = r.count;
|
|
1062
|
+
retiradosAhora = r.ids;
|
|
1063
|
+
// A target that is already retired was found: a retry of a supersedes
|
|
1064
|
+
// that already ran is not a miss.
|
|
1065
|
+
const yaRetirado = (t) => {
|
|
1066
|
+
const w = t.trim().toLowerCase();
|
|
1067
|
+
return n.facts.some(f => (f.status === 'superseded' || f.status === 'retracted')
|
|
1068
|
+
&& ((f.id || (0, hash_js_1.factId)(f.text)).toLowerCase() === w || f.text.trim().toLowerCase() === w));
|
|
1069
|
+
};
|
|
1070
|
+
supersedesUnmatched = r.unmatched.filter(t => !yaRetirado(t));
|
|
1071
|
+
};
|
|
1072
|
+
const actualizada = await this.updateNeuron(targetId, current => {
|
|
1073
|
+
if (!current) {
|
|
1074
|
+
gone = true;
|
|
1075
|
+
return null;
|
|
1076
|
+
}
|
|
1077
|
+
const n = current;
|
|
808
1078
|
switch (type) {
|
|
809
1079
|
case 'fact': {
|
|
810
1080
|
const id = (0, hash_js_1.factId)(content);
|
|
811
1081
|
const keys = normalizeKeys(options?.keys);
|
|
812
|
-
const existente = n.facts.find(f => f.text
|
|
1082
|
+
const existente = n.facts.find(f => mismoHecho(f.text));
|
|
813
1083
|
if (existente && (existente.status === 'superseded' || existente.status === 'retracted')) {
|
|
814
1084
|
// The same line was deliberately retired. Re-adding it would
|
|
815
1085
|
// undo that revision by accident; report it and write nothing.
|
|
@@ -905,13 +1175,13 @@ class Cortex {
|
|
|
905
1175
|
...(existente.shelf_life ? { shelf: existente.shelf_life } : {}) };
|
|
906
1176
|
const sv = (0, shelf_js_1.shelfOfFact)(existente);
|
|
907
1177
|
shelf = { shelf_life: sv.shelf, inferred: sv.inferred, ...(sv.reason ? { reason: sv.reason } : {}) };
|
|
1178
|
+
// The same line again WITH supersedes: the retry after fixing a
|
|
1179
|
+
// mistyped id. It used to skip the retirement in silence, and
|
|
1180
|
+
// the old value stayed live while the caller believed it gone.
|
|
1181
|
+
supersedeDesde(n, { id: existente.id || (0, hash_js_1.factId)(existente.text), text: existente.text });
|
|
908
1182
|
}
|
|
909
1183
|
if (!isDuplicate) {
|
|
910
|
-
|
|
911
|
-
const retirado = this.retire(n, options.supersedes, id, 'superseded');
|
|
912
|
-
superseded = retirado.count;
|
|
913
|
-
supersedesUnmatched = retirado.unmatched;
|
|
914
|
-
}
|
|
1184
|
+
supersedeDesde(n, { id, text: content });
|
|
915
1185
|
// Near-duplicate check, against what will still be active
|
|
916
1186
|
// AFTER supersedes ran — retiring the old telling is exactly
|
|
917
1187
|
// the fix this warning exists to suggest, so a properly
|
|
@@ -953,6 +1223,12 @@ class Cortex {
|
|
|
953
1223
|
case 'decision': {
|
|
954
1224
|
if (retirada(n, content))
|
|
955
1225
|
return null;
|
|
1226
|
+
// A retry no longer adds a second copy under the same entry id.
|
|
1227
|
+
const previa = n.decisions.find(d => mismaEntrada(d.text || ''));
|
|
1228
|
+
if (previa) {
|
|
1229
|
+
emitir = { kind: 'decision', text: previa.text, why: previa.rationale || undefined, at: previa.date || (0, fs_js_1.now)() };
|
|
1230
|
+
break;
|
|
1231
|
+
}
|
|
956
1232
|
const decision = {
|
|
957
1233
|
text: content,
|
|
958
1234
|
date: (0, fs_js_1.now)(),
|
|
@@ -969,16 +1245,18 @@ class Cortex {
|
|
|
969
1245
|
case 'pattern': {
|
|
970
1246
|
if (retirada(n, content))
|
|
971
1247
|
return null;
|
|
972
|
-
|
|
1248
|
+
const previo = n.patterns.find(mismaEntrada);
|
|
1249
|
+
if (previo === undefined) {
|
|
973
1250
|
n.patterns.push(content);
|
|
974
1251
|
fechar(n, content);
|
|
975
1252
|
origen(n, content);
|
|
976
1253
|
}
|
|
977
|
-
|
|
1254
|
+
const texto = previo ?? content;
|
|
1255
|
+
emitir = { kind: 'pattern', text: texto, at: n.entry_dates?.[(0, ops_js_1.entryId)(texto)] || (0, fs_js_1.now)() };
|
|
978
1256
|
break;
|
|
979
1257
|
}
|
|
980
1258
|
case 'preference': {
|
|
981
|
-
if (!n.preferences.
|
|
1259
|
+
if (!n.preferences.some(mismaEntrada)) {
|
|
982
1260
|
n.preferences.push(content);
|
|
983
1261
|
fechar(n, content);
|
|
984
1262
|
}
|
|
@@ -989,13 +1267,15 @@ class Cortex {
|
|
|
989
1267
|
return null;
|
|
990
1268
|
if (!n.errors)
|
|
991
1269
|
n.errors = [];
|
|
992
|
-
|
|
1270
|
+
const previo = n.errors.find(mismaEntrada);
|
|
1271
|
+
if (previo === undefined) {
|
|
993
1272
|
n.errors.push(content);
|
|
994
1273
|
fechar(n, content);
|
|
995
1274
|
origen(n, content);
|
|
996
1275
|
this.tally.topics.add(n.id);
|
|
997
1276
|
}
|
|
998
|
-
|
|
1277
|
+
const texto = previo ?? content;
|
|
1278
|
+
emitir = { kind: 'error', text: texto, at: n.entry_dates?.[(0, ops_js_1.entryId)(texto)] || (0, fs_js_1.now)() };
|
|
999
1279
|
break;
|
|
1000
1280
|
}
|
|
1001
1281
|
case 'debt': {
|
|
@@ -1003,13 +1283,15 @@ class Cortex {
|
|
|
1003
1283
|
return null;
|
|
1004
1284
|
if (!n.debts)
|
|
1005
1285
|
n.debts = [];
|
|
1006
|
-
|
|
1286
|
+
const previo = n.debts.find(mismaEntrada);
|
|
1287
|
+
if (previo === undefined) {
|
|
1007
1288
|
n.debts.push(content);
|
|
1008
1289
|
fechar(n, content);
|
|
1009
1290
|
origen(n, content);
|
|
1010
1291
|
this.tally.topics.add(n.id);
|
|
1011
1292
|
}
|
|
1012
|
-
|
|
1293
|
+
const texto = previo ?? content;
|
|
1294
|
+
emitir = { kind: 'debt', text: texto, at: n.entry_dates?.[(0, ops_js_1.entryId)(texto)] || (0, fs_js_1.now)() };
|
|
1013
1295
|
break;
|
|
1014
1296
|
}
|
|
1015
1297
|
}
|
|
@@ -1020,13 +1302,16 @@ class Cortex {
|
|
|
1020
1302
|
}
|
|
1021
1303
|
return n;
|
|
1022
1304
|
});
|
|
1023
|
-
|
|
1305
|
+
if (gone || !actualizada)
|
|
1306
|
+
throw this.deletedMeanwhile(targetId);
|
|
1307
|
+
const final = actualizada;
|
|
1024
1308
|
if (skippedRetired) {
|
|
1025
1309
|
// Nothing was written, so nothing to index, emit or tally.
|
|
1026
1310
|
return {
|
|
1027
1311
|
neuron: final, action: 'skipped_retired', superseded: 0, supersedes_unmatched: [],
|
|
1028
1312
|
near_duplicates: [], redacted: limpio.found,
|
|
1029
1313
|
duplicate: false, updated_in_place: false, skipped_retired: skippedRetired,
|
|
1314
|
+
resolved_by: resolvedBy, ...(ambiguous?.length ? { ambiguous } : {}),
|
|
1030
1315
|
};
|
|
1031
1316
|
}
|
|
1032
1317
|
await this.reindex(final);
|
|
@@ -1034,6 +1319,12 @@ class Cortex {
|
|
|
1034
1319
|
// key and the least likely to be worth sharing.
|
|
1035
1320
|
if (emitir)
|
|
1036
1321
|
await this.emit(final.id, emitir);
|
|
1322
|
+
// The retirement travels too (2.11), exactly as crbro_revise sends it:
|
|
1323
|
+
// before, teammates got the new value and kept the old one as current.
|
|
1324
|
+
for (const fid of retiradosAhora) {
|
|
1325
|
+
const f = final.facts.find(x => x.id === fid);
|
|
1326
|
+
await this.emit(final.id, { kind: 'status', fid, to: 'superseded', at: f?.revised || (0, fs_js_1.now)() });
|
|
1327
|
+
}
|
|
1037
1328
|
return {
|
|
1038
1329
|
neuron: final, action, superseded, supersedes_unmatched: supersedesUnmatched,
|
|
1039
1330
|
near_duplicates: nearDuplicates, redacted: limpio.found,
|
|
@@ -1041,6 +1332,7 @@ class Cortex {
|
|
|
1041
1332
|
...(confirmations !== undefined ? { confirmations } : {}),
|
|
1042
1333
|
...(shelf ? { shelf } : {}),
|
|
1043
1334
|
...(shelfLengthened ? { shelf_lengthened: true } : {}),
|
|
1335
|
+
resolved_by: resolvedBy, ...(ambiguous?.length ? { ambiguous } : {}),
|
|
1044
1336
|
};
|
|
1045
1337
|
}
|
|
1046
1338
|
/**
|
|
@@ -1053,14 +1345,18 @@ class Cortex {
|
|
|
1053
1345
|
* caller usually has the text in front of it and not the hash.
|
|
1054
1346
|
*/
|
|
1055
1347
|
async revise(neuronRef, targets, options) {
|
|
1056
|
-
const neuron =
|
|
1348
|
+
const neuron = await this.resolveForWrite(neuronRef);
|
|
1057
1349
|
if (!neuron)
|
|
1058
1350
|
return { neuron: null, revised: 0, unmatched: targets.slice() };
|
|
1351
|
+
if (options?.note)
|
|
1352
|
+
options = { ...options, note: (0, secrets_js_1.redact)(options.note).text }; // G8: notes are stored and shared
|
|
1059
1353
|
const status = options?.status || 'superseded';
|
|
1060
1354
|
let revised = 0;
|
|
1061
1355
|
let unmatched = targets.slice();
|
|
1062
|
-
const actualizada = await
|
|
1063
|
-
|
|
1356
|
+
const actualizada = await this.updateNeuron(neuron.id, current => {
|
|
1357
|
+
if (!current)
|
|
1358
|
+
return null; // deleted meanwhile: never resurrect it (2.11)
|
|
1359
|
+
const n = current;
|
|
1064
1360
|
const retirado = status === 'active'
|
|
1065
1361
|
? this.reactivate(n, targets, options?.note)
|
|
1066
1362
|
: this.retire(n, targets, options?.replacedBy, status, options?.note);
|
|
@@ -1111,15 +1407,17 @@ class Cortex {
|
|
|
1111
1407
|
const evidence = (0, evidence_js_1.makeVerification)(targets.evidence, (0, fs_js_1.now)());
|
|
1112
1408
|
const facts = (targets.facts || []).filter(t => typeof t === 'string');
|
|
1113
1409
|
const entries = (targets.entries || []).filter(t => typeof t === 'string');
|
|
1114
|
-
const neuron =
|
|
1410
|
+
const neuron = await this.resolveForWrite(neuronRef);
|
|
1115
1411
|
if (!neuron)
|
|
1116
1412
|
return { neuron: null, verified: [], unmatched: [...facts, ...entries], retired: [] };
|
|
1117
1413
|
const verified = [];
|
|
1118
1414
|
let unmatched = [];
|
|
1119
1415
|
let retired = [];
|
|
1120
1416
|
const emitir = [];
|
|
1121
|
-
const actualizada = await
|
|
1122
|
-
|
|
1417
|
+
const actualizada = await this.updateNeuron(neuron.id, current => {
|
|
1418
|
+
if (!current)
|
|
1419
|
+
return null; // deleted meanwhile: never resurrect it (2.11)
|
|
1420
|
+
const n = current;
|
|
1123
1421
|
const cuando = (0, fs_js_1.now)();
|
|
1124
1422
|
verified.length = 0;
|
|
1125
1423
|
unmatched = [];
|
|
@@ -1213,6 +1511,7 @@ class Cortex {
|
|
|
1213
1511
|
let count = 0;
|
|
1214
1512
|
const wanted = targets.map(t => t.trim().toLowerCase());
|
|
1215
1513
|
const hit = new Set();
|
|
1514
|
+
const ids = [];
|
|
1216
1515
|
for (const fact of neuron.facts) {
|
|
1217
1516
|
if (fact.status === 'superseded' || fact.status === 'retracted')
|
|
1218
1517
|
continue;
|
|
@@ -1233,8 +1532,9 @@ class Cortex {
|
|
|
1233
1532
|
if (note)
|
|
1234
1533
|
fact.revision_note = note;
|
|
1235
1534
|
count++;
|
|
1535
|
+
ids.push(fid);
|
|
1236
1536
|
}
|
|
1237
|
-
return { count, unmatched: targets.filter((_, i) => !hit.has(i)) };
|
|
1537
|
+
return { count, unmatched: targets.filter((_, i) => !hit.has(i)), ids };
|
|
1238
1538
|
}
|
|
1239
1539
|
/**
|
|
1240
1540
|
* Bring retired facts back. The inverse of retire: only superseded or
|
|
@@ -1279,15 +1579,19 @@ class Cortex {
|
|
|
1279
1579
|
* survives a sync because applyOps copies and prunes it.
|
|
1280
1580
|
*/
|
|
1281
1581
|
async retireEntries(neuronRef, targets, options) {
|
|
1282
|
-
const neuron =
|
|
1582
|
+
const neuron = await this.resolveForWrite(neuronRef);
|
|
1283
1583
|
if (!neuron)
|
|
1284
1584
|
return { neuron: null, revised: 0, unmatched: targets.slice() };
|
|
1585
|
+
if (options?.note)
|
|
1586
|
+
options = { ...options, note: (0, secrets_js_1.redact)(options.note).text }; // G8: notes are stored and shared
|
|
1285
1587
|
const status = options?.status || 'superseded';
|
|
1286
1588
|
const wanted = targets.map(t => (0, ops_js_1.normalizeText)(t).toLowerCase());
|
|
1287
1589
|
let revised = 0;
|
|
1288
1590
|
let unmatched = targets.slice();
|
|
1289
|
-
const actualizada = await
|
|
1290
|
-
|
|
1591
|
+
const actualizada = await this.updateNeuron(neuron.id, current => {
|
|
1592
|
+
if (!current)
|
|
1593
|
+
return null; // deleted meanwhile: never resurrect it (2.11)
|
|
1594
|
+
const n = current;
|
|
1291
1595
|
const estados = n.entry_status || {};
|
|
1292
1596
|
const hit = new Set();
|
|
1293
1597
|
const cuando = (0, fs_js_1.now)();
|
|
@@ -1350,7 +1654,7 @@ class Cortex {
|
|
|
1350
1654
|
* Not emitted: NeuronOp is only an announcement, and metadata is local.
|
|
1351
1655
|
*/
|
|
1352
1656
|
async setMeta(neuronRef, meta) {
|
|
1353
|
-
const neuron =
|
|
1657
|
+
const neuron = await this.resolveForWrite(neuronRef);
|
|
1354
1658
|
if (!neuron)
|
|
1355
1659
|
return { neuron: null, changed: [], redacted: [] };
|
|
1356
1660
|
let redacted = [];
|
|
@@ -1366,8 +1670,10 @@ class Cortex {
|
|
|
1366
1670
|
const domain = meta.domain === undefined ? undefined : meta.domain.trim();
|
|
1367
1671
|
const name = meta.name === undefined ? undefined : meta.name.trim();
|
|
1368
1672
|
const changed = [];
|
|
1369
|
-
const actualizada = await
|
|
1370
|
-
|
|
1673
|
+
const actualizada = await this.updateNeuron(neuron.id, current => {
|
|
1674
|
+
if (!current)
|
|
1675
|
+
return null; // deleted meanwhile: never resurrect it (2.11)
|
|
1676
|
+
const n = current;
|
|
1371
1677
|
if (summary !== undefined && summary !== n.summary) {
|
|
1372
1678
|
n.summary = summary;
|
|
1373
1679
|
changed.push('summary');
|
|
@@ -1413,7 +1719,7 @@ class Cortex {
|
|
|
1413
1719
|
// The other way round, an exact neuron id could land on a near-miss whose
|
|
1414
1720
|
// prefix-stripped id matches first, and the map would be written to one
|
|
1415
1721
|
// neuron while every reader resolves the other.
|
|
1416
|
-
let neuron =
|
|
1722
|
+
let neuron = await this.resolveForWrite(neuronRef);
|
|
1417
1723
|
let action = 'updated';
|
|
1418
1724
|
if (!neuron) {
|
|
1419
1725
|
const nType = options?.neuronType || (0, ids_js_1.inferNeuronType)(neuronRef);
|
|
@@ -1422,8 +1728,10 @@ class Cortex {
|
|
|
1422
1728
|
}
|
|
1423
1729
|
const cuando = (0, fs_js_1.now)();
|
|
1424
1730
|
const vaciar = content.trim() === '';
|
|
1425
|
-
const actualizada = await
|
|
1426
|
-
|
|
1731
|
+
const actualizada = await this.updateNeuron(neuron.id, current => {
|
|
1732
|
+
if (!current)
|
|
1733
|
+
return null; // deleted meanwhile: never resurrect it (2.11)
|
|
1734
|
+
const n = current;
|
|
1427
1735
|
// An empty map is not a map: writing '' clears it outright, so the
|
|
1428
1736
|
// reader never meets a document that exists but says nothing.
|
|
1429
1737
|
if (vaciar)
|
|
@@ -1434,7 +1742,9 @@ class Cortex {
|
|
|
1434
1742
|
n.access_count = (n.access_count || 0) + 1;
|
|
1435
1743
|
return n;
|
|
1436
1744
|
});
|
|
1437
|
-
|
|
1745
|
+
if (!actualizada)
|
|
1746
|
+
throw this.deletedMeanwhile(neuron.id);
|
|
1747
|
+
const final = actualizada;
|
|
1438
1748
|
await this.reindex(final);
|
|
1439
1749
|
// Clearing emits too: an empty map op is the tombstone that wins the LWW
|
|
1440
1750
|
// on every machine, so a stale copy cannot resurrect the cleared map.
|
|
@@ -1450,36 +1760,48 @@ class Cortex {
|
|
|
1450
1760
|
* slice, which is the only number a pager can trust.
|
|
1451
1761
|
*/
|
|
1452
1762
|
async listWithTotal(options) {
|
|
1453
|
-
const
|
|
1763
|
+
const unreadable = [];
|
|
1764
|
+
const all = await this.list({ ...options, limit: Number.MAX_SAFE_INTEGER, offset: 0 }, unreadable);
|
|
1454
1765
|
const limit = options?.limit || 50;
|
|
1455
1766
|
const offset = Math.max(0, options?.offset || 0);
|
|
1456
|
-
return { total: all.length, rows: all.slice(offset, offset + limit) };
|
|
1767
|
+
return { total: all.length, rows: all.slice(offset, offset + limit), unreadable };
|
|
1457
1768
|
}
|
|
1458
1769
|
/**
|
|
1459
1770
|
* List all neurons with optional filters.
|
|
1460
1771
|
*/
|
|
1461
|
-
async list(options) {
|
|
1772
|
+
async list(options, unreadable) {
|
|
1462
1773
|
const ids = await (0, fs_js_1.listJSONFiles)(this.brain.paths.cortex);
|
|
1463
1774
|
const neurons = [];
|
|
1775
|
+
const calor = await this.heatMap();
|
|
1464
1776
|
for (const id of ids) {
|
|
1465
|
-
|
|
1777
|
+
let neuron;
|
|
1778
|
+
try {
|
|
1779
|
+
neuron = await (0, fs_js_1.readJSON)(this.brain.paths.neuron(id));
|
|
1780
|
+
}
|
|
1781
|
+
catch (err) {
|
|
1782
|
+
// One unreadable file is listed apart, never the end of the listing.
|
|
1783
|
+
const info = await this.noteUnreadable(id, err);
|
|
1784
|
+
unreadable?.push(info);
|
|
1785
|
+
continue;
|
|
1786
|
+
}
|
|
1466
1787
|
if (!neuron)
|
|
1467
1788
|
continue;
|
|
1789
|
+
const heat = calor.get(neuron.id) ?? neuron.heat;
|
|
1468
1790
|
// Apply filters
|
|
1469
1791
|
if (options?.domain && neuron.domain !== options.domain)
|
|
1470
1792
|
continue;
|
|
1471
1793
|
if (options?.type && neuron.type !== options.type)
|
|
1472
1794
|
continue;
|
|
1473
|
-
if (options?.min_heat &&
|
|
1795
|
+
if (options?.min_heat && heat < options.min_heat)
|
|
1474
1796
|
continue;
|
|
1475
1797
|
neurons.push({
|
|
1476
1798
|
id: neuron.id,
|
|
1477
1799
|
name: neuron.name,
|
|
1478
1800
|
domain: neuron.domain,
|
|
1479
1801
|
type: neuron.type,
|
|
1480
|
-
heat
|
|
1802
|
+
heat,
|
|
1481
1803
|
last_accessed: neuron.last_accessed,
|
|
1482
|
-
facts_count: neuron.facts.length,
|
|
1804
|
+
facts_count: (neuron.facts || []).length,
|
|
1483
1805
|
});
|
|
1484
1806
|
}
|
|
1485
1807
|
// Sort by heat (descending)
|
|
@@ -1493,7 +1815,7 @@ class Cortex {
|
|
|
1493
1815
|
* Update a neuron's summary.
|
|
1494
1816
|
*/
|
|
1495
1817
|
async updateSummary(id, summary) {
|
|
1496
|
-
const neuron = await
|
|
1818
|
+
const neuron = await this.updateNeuron(id, current => {
|
|
1497
1819
|
if (!current)
|
|
1498
1820
|
return null;
|
|
1499
1821
|
current.summary = summary;
|
|
@@ -1509,7 +1831,7 @@ class Cortex {
|
|
|
1509
1831
|
* Add tags to a neuron.
|
|
1510
1832
|
*/
|
|
1511
1833
|
async addTags(id, tags) {
|
|
1512
|
-
const neuron = await
|
|
1834
|
+
const neuron = await this.updateNeuron(id, current => {
|
|
1513
1835
|
if (!current)
|
|
1514
1836
|
return null;
|
|
1515
1837
|
for (const tag of tags) {
|
|
@@ -1532,10 +1854,10 @@ class Cortex {
|
|
|
1532
1854
|
* not exist at all — a credential, someone's personal data.
|
|
1533
1855
|
*/
|
|
1534
1856
|
async forget(neuronRef, targets) {
|
|
1535
|
-
const found =
|
|
1857
|
+
const found = await this.resolveForWrite(neuronRef);
|
|
1536
1858
|
if (!found)
|
|
1537
1859
|
return { neuron_id: null, removed: 0, backup: null };
|
|
1538
|
-
|
|
1860
|
+
let backup = null;
|
|
1539
1861
|
let removed = 0;
|
|
1540
1862
|
const wanted = targets.map(t => t.trim().toLowerCase());
|
|
1541
1863
|
// An entry goes when a target names its text or its id: the entryId the
|
|
@@ -1549,42 +1871,83 @@ class Cortex {
|
|
|
1549
1871
|
return wanted.includes(t) || wanted.includes((0, ops_js_1.entryId)(text).toLowerCase())
|
|
1550
1872
|
|| (!!ownId && wanted.includes(ownId.toLowerCase()));
|
|
1551
1873
|
};
|
|
1552
|
-
const
|
|
1874
|
+
const factMatches = (f) => {
|
|
1875
|
+
const fid = (f.id || (0, hash_js_1.factId)(f.text)).toLowerCase();
|
|
1876
|
+
return wanted.includes(fid) || wanted.includes(f.text.trim().toLowerCase());
|
|
1877
|
+
};
|
|
1878
|
+
// What left, read inside the lock: the quarantine copy and the emitted
|
|
1879
|
+
// removals describe exactly what this call took out, not a snapshot read
|
|
1880
|
+
// before somebody else wrote.
|
|
1881
|
+
let quitado = null;
|
|
1882
|
+
const after = await this.updateNeuron(found.id, async (current) => {
|
|
1553
1883
|
if (!current)
|
|
1554
1884
|
return null;
|
|
1555
|
-
const
|
|
1556
|
-
|
|
1885
|
+
const q = {
|
|
1886
|
+
quarantine_format: 2, kind: 'entries',
|
|
1887
|
+
neuron_id: current.id, name: current.name, type: current.type, domain: current.domain, taken_at: (0, fs_js_1.now)(),
|
|
1888
|
+
facts: (current.facts || []).filter(factMatches).map(copyFact),
|
|
1889
|
+
decisions: (current.decisions || []).filter(d => matches(d.text, d.id)).map(d => ({ ...d })),
|
|
1890
|
+
patterns: (current.patterns || []).filter(p => matches(p)),
|
|
1891
|
+
preferences: (current.preferences || []).filter(p => matches(p)),
|
|
1892
|
+
errors: (current.errors || []).filter(e => matches(e)),
|
|
1893
|
+
debts: (current.debts || []).filter(d => matches(d)),
|
|
1894
|
+
...(current.map && wanted.includes(current.map.text.trim().toLowerCase()) ? { map: { ...current.map } } : {}),
|
|
1895
|
+
};
|
|
1896
|
+
removed = q.facts.length + q.decisions.length + q.patterns.length + q.preferences.length
|
|
1897
|
+
+ q.errors.length + q.debts.length + (q.map ? 1 : 0);
|
|
1898
|
+
// Nothing matched: nothing is written, and no copy is kept either.
|
|
1899
|
+
if (removed === 0)
|
|
1900
|
+
return null;
|
|
1901
|
+
// The sidecars of what leaves travel with it, so restore puts each entry
|
|
1902
|
+
// back with its date, retirement, provenance and check.
|
|
1903
|
+
const llaves = new Set([...q.decisions.map(d => d.text), ...q.patterns, ...q.preferences, ...q.errors, ...q.debts].map(ops_js_1.entryId));
|
|
1904
|
+
const parte = (rec) => {
|
|
1905
|
+
if (!rec)
|
|
1906
|
+
return undefined;
|
|
1907
|
+
const out = {};
|
|
1908
|
+
for (const k of Object.keys(rec))
|
|
1909
|
+
if (llaves.has(k))
|
|
1910
|
+
out[k] = rec[k];
|
|
1911
|
+
return Object.keys(out).length ? out : undefined;
|
|
1912
|
+
};
|
|
1913
|
+
const fechas = parte(current.entry_dates);
|
|
1914
|
+
if (fechas)
|
|
1915
|
+
q.entry_dates = fechas;
|
|
1916
|
+
const estados = parte(current.entry_status);
|
|
1917
|
+
if (estados)
|
|
1918
|
+
q.entry_status = estados;
|
|
1919
|
+
const origenes = parte(current.entry_source);
|
|
1920
|
+
if (origenes)
|
|
1921
|
+
q.entry_source = origenes;
|
|
1922
|
+
const verificadas = parte(current.entry_verified);
|
|
1923
|
+
if (verificadas)
|
|
1924
|
+
q.entry_verified = verificadas;
|
|
1925
|
+
const evidencias = parte(current.entry_verification);
|
|
1926
|
+
if (evidencias)
|
|
1927
|
+
q.entry_verification = evidencias;
|
|
1928
|
+
// The copy first, inside the lock: if it cannot be written (a full disk,
|
|
1929
|
+
// encryption on without its key) nothing is removed.
|
|
1930
|
+
backup = await (0, quarantine_js_1.writeQuarantine)(this.brain.paths.quarantine, current.id, q, 'entries');
|
|
1931
|
+
quitado = q;
|
|
1557
1932
|
// Dated entries before the removal, so their dates can go with them.
|
|
1558
1933
|
const fechadasAntes = [
|
|
1559
1934
|
...current.patterns, ...current.preferences,
|
|
1560
1935
|
...(current.errors || []), ...(current.debts || []),
|
|
1561
1936
|
];
|
|
1562
|
-
current.facts = current.facts.filter(f =>
|
|
1563
|
-
const fid = (f.id || (0, hash_js_1.factId)(f.text)).toLowerCase();
|
|
1564
|
-
return !(wanted.includes(fid) || wanted.includes(f.text.trim().toLowerCase()));
|
|
1565
|
-
});
|
|
1937
|
+
current.facts = current.facts.filter(f => !factMatches(f));
|
|
1566
1938
|
// Decisions, patterns and preferences too. A credential is no less
|
|
1567
1939
|
// exposed for sitting in one of those, and nothing else could remove it.
|
|
1568
1940
|
current.decisions = current.decisions.filter(d => !matches(d.text, d.id));
|
|
1569
1941
|
current.patterns = current.patterns.filter(p => !matches(p));
|
|
1570
1942
|
current.preferences = current.preferences.filter(p => !matches(p));
|
|
1571
|
-
const erroresAntes = (current.errors || []).length;
|
|
1572
1943
|
if (current.errors) {
|
|
1573
1944
|
current.errors = current.errors.filter(e => !matches(e));
|
|
1574
1945
|
}
|
|
1575
|
-
const deudasAntes = (current.debts || []).length;
|
|
1576
1946
|
if (current.debts) {
|
|
1577
1947
|
current.debts = current.debts.filter(d => !matches(d));
|
|
1578
1948
|
}
|
|
1579
|
-
|
|
1580
|
-
if (current.map && wanted.includes(current.map.text.trim().toLowerCase())) {
|
|
1949
|
+
if (q.map)
|
|
1581
1950
|
delete current.map;
|
|
1582
|
-
mapaBorrado = 1;
|
|
1583
|
-
}
|
|
1584
|
-
removed = antes - (current.facts.length + current.decisions.length +
|
|
1585
|
-
current.patterns.length + current.preferences.length) + (erroresAntes - (current.errors || []).length) + (deudasAntes - (current.debts || []).length) + mapaBorrado;
|
|
1586
|
-
if (removed === 0)
|
|
1587
|
-
return null;
|
|
1588
1951
|
// Drop the dates of what left, so the sidecar never outlives its entry.
|
|
1589
1952
|
if (current.entry_dates) {
|
|
1590
1953
|
const quedan = new Set([
|
|
@@ -1641,7 +2004,8 @@ class Cortex {
|
|
|
1641
2004
|
current.last_accessed = (0, fs_js_1.now)();
|
|
1642
2005
|
return current;
|
|
1643
2006
|
});
|
|
1644
|
-
|
|
2007
|
+
const q = quitado;
|
|
2008
|
+
if (removed > 0 && after && q) {
|
|
1645
2009
|
await this.reindex(after);
|
|
1646
2010
|
// The removal must travel, or the next sync resurrects from the team
|
|
1647
2011
|
// log exactly what the user asked to destroy. Facts retract (status
|
|
@@ -1649,33 +2013,22 @@ class Cortex {
|
|
|
1649
2013
|
// a purge op keyed by content hash; a cleared map emits the empty-map
|
|
1650
2014
|
// tombstone that wins the LWW. The emitter no-ops on unshared neurons.
|
|
1651
2015
|
const cuando = (0, fs_js_1.now)();
|
|
1652
|
-
for (const f of
|
|
1653
|
-
|
|
1654
|
-
if (wanted.includes(fid) || wanted.includes(f.text.trim().toLowerCase())) {
|
|
1655
|
-
await this.emit(found.id, { kind: 'status', fid: f.id || (0, hash_js_1.factId)(f.text), to: 'retracted', at: cuando, why: 'forgotten' });
|
|
1656
|
-
}
|
|
2016
|
+
for (const f of q.facts) {
|
|
2017
|
+
await this.emit(found.id, { kind: 'status', fid: f.id || (0, hash_js_1.factId)(f.text), to: 'retracted', at: cuando, why: 'forgotten' });
|
|
1657
2018
|
}
|
|
1658
|
-
for (const d of
|
|
1659
|
-
|
|
1660
|
-
await this.emit(found.id, { kind: 'decision_purge', key: (0, ops_js_1.entryId)(d.text), at: cuando });
|
|
1661
|
-
}
|
|
2019
|
+
for (const d of q.decisions) {
|
|
2020
|
+
await this.emit(found.id, { kind: 'decision_purge', key: (0, ops_js_1.entryId)(d.text), at: cuando });
|
|
1662
2021
|
}
|
|
1663
|
-
for (const p of
|
|
1664
|
-
|
|
1665
|
-
await this.emit(found.id, { kind: 'pattern_purge', key: (0, ops_js_1.entryId)(p), at: cuando });
|
|
1666
|
-
}
|
|
2022
|
+
for (const p of q.patterns) {
|
|
2023
|
+
await this.emit(found.id, { kind: 'pattern_purge', key: (0, ops_js_1.entryId)(p), at: cuando });
|
|
1667
2024
|
}
|
|
1668
|
-
for (const e of
|
|
1669
|
-
|
|
1670
|
-
await this.emit(found.id, { kind: 'error_purge', key: (0, ops_js_1.entryId)(e), at: cuando });
|
|
1671
|
-
}
|
|
2025
|
+
for (const e of q.errors) {
|
|
2026
|
+
await this.emit(found.id, { kind: 'error_purge', key: (0, ops_js_1.entryId)(e), at: cuando });
|
|
1672
2027
|
}
|
|
1673
|
-
for (const d of
|
|
1674
|
-
|
|
1675
|
-
await this.emit(found.id, { kind: 'debt_purge', key: (0, ops_js_1.entryId)(d), at: cuando });
|
|
1676
|
-
}
|
|
2028
|
+
for (const d of q.debts) {
|
|
2029
|
+
await this.emit(found.id, { kind: 'debt_purge', key: (0, ops_js_1.entryId)(d), at: cuando });
|
|
1677
2030
|
}
|
|
1678
|
-
if (
|
|
2031
|
+
if (q.map) {
|
|
1679
2032
|
await this.emit(found.id, { kind: 'map', text: '', at: cuando });
|
|
1680
2033
|
}
|
|
1681
2034
|
}
|
|
@@ -1692,37 +2045,60 @@ class Cortex {
|
|
|
1692
2045
|
* emitted: there is no neuron-delete op.
|
|
1693
2046
|
*/
|
|
1694
2047
|
async forgetNeuron(neuronRef) {
|
|
1695
|
-
const
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
2048
|
+
const vacio = { facts: 0, decisions: 0, patterns: 0, preferences: 0, errors: 0, debts: 0, connections: 0 };
|
|
2049
|
+
const found = await this.resolveForWrite(neuronRef);
|
|
2050
|
+
if (!found)
|
|
2051
|
+
return { neuron_id: null, backup: null, counts: vacio };
|
|
2052
|
+
// Copy and delete under the neuron's lock (2.11): a learn landing between
|
|
2053
|
+
// the copy and the unlink used to be deleted without being in the copy.
|
|
2054
|
+
const r = await this.quarantineAndDelete(found.id);
|
|
2055
|
+
if (!r)
|
|
2056
|
+
return { neuron_id: null, backup: null, counts: vacio };
|
|
2057
|
+
const n = r.neuron;
|
|
1704
2058
|
return {
|
|
1705
|
-
neuron_id:
|
|
1706
|
-
backup,
|
|
2059
|
+
neuron_id: n.id,
|
|
2060
|
+
backup: r.backup,
|
|
1707
2061
|
counts: {
|
|
1708
|
-
facts: (
|
|
1709
|
-
decisions: (
|
|
1710
|
-
patterns: (
|
|
1711
|
-
preferences: (
|
|
1712
|
-
errors: (
|
|
1713
|
-
debts: (
|
|
1714
|
-
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,
|
|
1715
2069
|
},
|
|
1716
2070
|
};
|
|
1717
2071
|
}
|
|
1718
2072
|
/**
|
|
1719
|
-
*
|
|
1720
|
-
* 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.
|
|
1721
2099
|
*/
|
|
1722
2100
|
async quarantineCopies(neuronId) {
|
|
1723
|
-
|
|
1724
|
-
const files = await (0, fs_js_1.listJSONFiles)(this.brain.paths.quarantine);
|
|
1725
|
-
return files.filter(f => f.startsWith(prefijo)).sort().reverse();
|
|
2101
|
+
return (await (0, quarantine_js_1.listQuarantine)(this.brain.paths.quarantine, neuronId)).map(c => ({ base: c.base, file: c.file }));
|
|
1726
2102
|
}
|
|
1727
2103
|
/**
|
|
1728
2104
|
* Bring a neuron back from quarantine.
|
|
@@ -1732,30 +2108,40 @@ class Cortex {
|
|
|
1732
2108
|
* (a forget of facts, or the topic was re-created since) the copy is
|
|
1733
2109
|
* unioned into it. The quarantine file stays where it is, so a restore is
|
|
1734
2110
|
* repeatable. Nothing is emitted.
|
|
2111
|
+
*
|
|
2112
|
+
* A copy of forgotten entries (2.11) holds those entries and their
|
|
2113
|
+
* sidecars, not the neuron: unioned into the neuron when it exists, or
|
|
2114
|
+
* written as a neuron of its own name, type and domain when it does not.
|
|
2115
|
+
* Sealed copies are opened with the at-rest key.
|
|
1735
2116
|
*/
|
|
1736
2117
|
async restoreNeuron(neuronId, options) {
|
|
1737
2118
|
const nada = { neuron: null, restored_from: null, merged_into_existing: false, moved: null };
|
|
1738
|
-
|
|
2119
|
+
const copias = await this.quarantineCopies(neuronId);
|
|
2120
|
+
let elegida;
|
|
1739
2121
|
if (options?.file) {
|
|
1740
|
-
const pedido = options.file.replace(/\.json
|
|
1741
|
-
|
|
1742
|
-
basename = copias.includes(pedido) ? pedido : null;
|
|
2122
|
+
const pedido = options.file.replace(/\.json(\.enc)?$/i, '');
|
|
2123
|
+
elegida = copias.find(c => c.base === pedido);
|
|
1743
2124
|
}
|
|
1744
2125
|
else {
|
|
1745
|
-
|
|
2126
|
+
elegida = copias[0];
|
|
1746
2127
|
}
|
|
1747
|
-
if (!
|
|
2128
|
+
if (!elegida)
|
|
1748
2129
|
return nada;
|
|
1749
|
-
const ruta =
|
|
1750
|
-
const
|
|
1751
|
-
|
|
2130
|
+
const ruta = node_path_1.default.join(this.brain.paths.quarantine, elegida.file);
|
|
2131
|
+
const leida = await (0, quarantine_js_1.readQuarantine)(ruta);
|
|
2132
|
+
let copia = null;
|
|
2133
|
+
if ((0, quarantine_js_1.isEntriesCopy)(leida))
|
|
2134
|
+
copia = (0, quarantine_js_1.entriesAsNeuron)(leida);
|
|
2135
|
+
else if (leida && typeof leida === 'object' && leida.id)
|
|
2136
|
+
copia = leida;
|
|
2137
|
+
if (!copia)
|
|
1752
2138
|
return nada;
|
|
1753
2139
|
copia.id = neuronId;
|
|
1754
2140
|
// Decide inside the lock, like create: whether the neuron exists again is
|
|
1755
2141
|
// only known for sure while nobody else can write it.
|
|
1756
2142
|
let escrita = false;
|
|
1757
2143
|
let moved = null;
|
|
1758
|
-
const saved = await
|
|
2144
|
+
const saved = await this.updateNeuron(neuronId, current => {
|
|
1759
2145
|
if (!current) {
|
|
1760
2146
|
escrita = true;
|
|
1761
2147
|
return copia;
|
|
@@ -1784,25 +2170,48 @@ class Cortex {
|
|
|
1784
2170
|
* re-creates it).
|
|
1785
2171
|
*/
|
|
1786
2172
|
async mergeNeurons(fromRef, intoRef) {
|
|
1787
|
-
const from =
|
|
1788
|
-
const into =
|
|
2173
|
+
const from = await this.resolveForWrite(fromRef);
|
|
2174
|
+
const into = await this.resolveForWrite(intoRef);
|
|
1789
2175
|
if (!from || !into || from.id === into.id) {
|
|
1790
2176
|
return { from: from?.id ?? null, into: into?.id ?? null, backup: null, moved: null };
|
|
1791
2177
|
}
|
|
1792
|
-
|
|
2178
|
+
// Under the source's lock (2.11): what is copied, unioned and deleted is
|
|
2179
|
+
// the source as it is at that moment, so a learn into it meanwhile is
|
|
2180
|
+
// either in all three or waits and then finds it gone.
|
|
1793
2181
|
let antes = into;
|
|
1794
2182
|
let moved = null;
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
const
|
|
1798
|
-
|
|
1799
|
-
|
|
2183
|
+
let intoGone = false;
|
|
2184
|
+
const hecho = await (0, lock_js_1.withLock)(this.brain.paths.neuron(from.id), async () => {
|
|
2185
|
+
const fuente = await this.readStrict(from.id);
|
|
2186
|
+
if (!fuente)
|
|
2187
|
+
return null;
|
|
2188
|
+
const backup = await this.quarantine(fuente);
|
|
2189
|
+
const saved = await this.updateNeuron(into.id, current => {
|
|
2190
|
+
if (!current) {
|
|
2191
|
+
intoGone = true;
|
|
2192
|
+
return null;
|
|
2193
|
+
}
|
|
2194
|
+
antes = current;
|
|
2195
|
+
const r = unionNeuron(current, fuente);
|
|
2196
|
+
moved = r.moved;
|
|
2197
|
+
return r.neuron;
|
|
2198
|
+
});
|
|
2199
|
+
if (!saved || intoGone)
|
|
2200
|
+
return { backup, final: null };
|
|
2201
|
+
await (0, fs_js_1.deleteJSON)(this.brain.paths.neuron(from.id));
|
|
2202
|
+
return { backup, final: saved };
|
|
1800
2203
|
});
|
|
1801
|
-
|
|
2204
|
+
if (!hecho)
|
|
2205
|
+
return { from: null, into: into.id, backup: null, moved: null };
|
|
2206
|
+
if (!hecho.final)
|
|
2207
|
+
throw this.deletedMeanwhile(into.id);
|
|
2208
|
+
const final = hecho.final;
|
|
1802
2209
|
await this.reindex(final);
|
|
1803
2210
|
await this.emitAdded(antes, final);
|
|
1804
|
-
await this.
|
|
1805
|
-
|
|
2211
|
+
await this.unindex(from.id);
|
|
2212
|
+
const manifest = await this.brain.getManifest();
|
|
2213
|
+
await this.brain.updateManifest({ total_neurons: Math.max(0, manifest.total_neurons - 1) });
|
|
2214
|
+
return { from: from.id, into: into.id, backup: hecho.backup, moved };
|
|
1806
2215
|
}
|
|
1807
2216
|
/**
|
|
1808
2217
|
* Emit what a union added, by diffing the result against what the target
|
|
@@ -1877,7 +2286,7 @@ class Cortex {
|
|
|
1877
2286
|
* moves: it describes the system, not an entry.
|
|
1878
2287
|
*/
|
|
1879
2288
|
async moveEntries(fromRef, targets, intoRef, options) {
|
|
1880
|
-
const from =
|
|
2289
|
+
const from = await this.resolveForWrite(fromRef);
|
|
1881
2290
|
if (!from)
|
|
1882
2291
|
return { from: null, into: null, created: false, moved: 0, unmatched: targets, backup: null };
|
|
1883
2292
|
const wanted = new Set(targets.map(t => t.trim().toLowerCase()).filter(Boolean));
|
|
@@ -1917,7 +2326,7 @@ class Cortex {
|
|
|
1917
2326
|
const unmatched = targets.filter(t => !usados.has(t.trim().toLowerCase()));
|
|
1918
2327
|
if (textos.length === 0)
|
|
1919
2328
|
return { from: from.id, into: null, created: false, moved: 0, unmatched, backup: null };
|
|
1920
|
-
let into =
|
|
2329
|
+
let into = await this.resolveForWrite(intoRef);
|
|
1921
2330
|
let created = false;
|
|
1922
2331
|
if (!into) {
|
|
1923
2332
|
into = await this.create(intoRef, from.type, options?.domain || from.domain);
|
|
@@ -1926,7 +2335,7 @@ class Cortex {
|
|
|
1926
2335
|
if (into.id === from.id)
|
|
1927
2336
|
return { from: from.id, into: into.id, created: false, moved: 0, unmatched, backup: null };
|
|
1928
2337
|
let antes = into;
|
|
1929
|
-
const saved = await
|
|
2338
|
+
const saved = await this.updateNeuron(into.id, current => {
|
|
1930
2339
|
antes = current || into;
|
|
1931
2340
|
return unionNeuron(antes, parte).neuron;
|
|
1932
2341
|
});
|
|
@@ -1970,8 +2379,10 @@ class Cortex {
|
|
|
1970
2379
|
const targetId = target.id;
|
|
1971
2380
|
for (const n of sources)
|
|
1972
2381
|
await this.quarantine(n);
|
|
1973
|
-
const saved = await
|
|
1974
|
-
|
|
2382
|
+
const saved = await this.updateNeuron(targetId, current => {
|
|
2383
|
+
if (!current)
|
|
2384
|
+
return null; // deleted meanwhile: never resurrect it (2.11)
|
|
2385
|
+
let acc = current;
|
|
1975
2386
|
for (const n of sources) {
|
|
1976
2387
|
if (n.id === targetId)
|
|
1977
2388
|
continue;
|
|
@@ -1985,13 +2396,36 @@ class Cortex {
|
|
|
1985
2396
|
}
|
|
1986
2397
|
return acc;
|
|
1987
2398
|
});
|
|
1988
|
-
|
|
2399
|
+
if (!saved)
|
|
2400
|
+
throw this.deletedMeanwhile(targetId);
|
|
2401
|
+
const final = saved;
|
|
1989
2402
|
await this.reindex(final);
|
|
1990
2403
|
const folded = [];
|
|
1991
2404
|
for (const n of sources) {
|
|
1992
2405
|
if (n.id === targetId)
|
|
1993
2406
|
continue;
|
|
1994
|
-
|
|
2407
|
+
// Delete only the source as it was read: one written since keeps its
|
|
2408
|
+
// file (its old line is in the digest too — a duplicate, never a loss).
|
|
2409
|
+
const borrada = await (0, lock_js_1.withLock)(this.brain.paths.neuron(n.id), async () => {
|
|
2410
|
+
let ahora = null;
|
|
2411
|
+
try {
|
|
2412
|
+
ahora = await (0, fs_js_1.readJSON)(this.brain.paths.neuron(n.id));
|
|
2413
|
+
}
|
|
2414
|
+
catch {
|
|
2415
|
+
return false;
|
|
2416
|
+
}
|
|
2417
|
+
if (!ahora || JSON.stringify(ahora) !== JSON.stringify(n))
|
|
2418
|
+
return false;
|
|
2419
|
+
await (0, fs_js_1.deleteJSON)(this.brain.paths.neuron(n.id));
|
|
2420
|
+
return true;
|
|
2421
|
+
});
|
|
2422
|
+
if (!borrada) {
|
|
2423
|
+
skipped.push(n.id);
|
|
2424
|
+
continue;
|
|
2425
|
+
}
|
|
2426
|
+
await this.unindex(n.id);
|
|
2427
|
+
const manifest = await this.brain.getManifest();
|
|
2428
|
+
await this.brain.updateManifest({ total_neurons: Math.max(0, manifest.total_neurons - 1) });
|
|
1995
2429
|
folded.push(n.id);
|
|
1996
2430
|
}
|
|
1997
2431
|
const entries = final.facts.length + final.decisions.length + final.patterns.length
|
|
@@ -2005,7 +2439,7 @@ class Cortex {
|
|
|
2005
2439
|
async auditSecrets() {
|
|
2006
2440
|
const out = [];
|
|
2007
2441
|
for (const id of await (0, fs_js_1.listJSONFiles)(this.brain.paths.cortex)) {
|
|
2008
|
-
const n = await
|
|
2442
|
+
const n = await this.peek(id); // an unreadable file is skipped and listed in unreadable()
|
|
2009
2443
|
if (!n)
|
|
2010
2444
|
continue;
|
|
2011
2445
|
const kinds = new Set();
|
|
@@ -2023,8 +2457,9 @@ class Cortex {
|
|
|
2023
2457
|
// Every field, not just facts. A credential is just as exposed sitting in
|
|
2024
2458
|
// a decision or a preference, and those were invisible here — an audit
|
|
2025
2459
|
// could report a neuron clean while a key sat in preferences[0].
|
|
2026
|
-
|
|
2027
|
-
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 || ''}`));
|
|
2028
2463
|
const patterns = contar(n.patterns || []);
|
|
2029
2464
|
const preferences = contar(n.preferences || []);
|
|
2030
2465
|
const errors = contar(n.errors || []);
|