crbro-memory 2.9.1 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +256 -456
  3. package/SECURITY.md +135 -8
  4. package/bin/crbro.mjs +555 -299
  5. package/dist/build-id.json +10 -0
  6. package/dist/cli/common.js +211 -0
  7. package/dist/cli/export.js +522 -0
  8. package/dist/cli/import.js +683 -0
  9. package/dist/cli/markdown.js +313 -0
  10. package/dist/cli/mcpclient.js +107 -0
  11. package/dist/cli/serve.js +453 -0
  12. package/dist/cli/view.js +358 -0
  13. package/dist/cli/viewer-page.js +378 -0
  14. package/dist/daemon/daemon.js +119 -11
  15. package/dist/daemon/endpoint.js +222 -21
  16. package/dist/daemon/lines.js +102 -13
  17. package/dist/daemon/proxy.js +480 -125
  18. package/dist/daemon/surface.js +116 -0
  19. package/dist/daemon/trace.js +73 -0
  20. package/dist/engine/atrest.js +132 -0
  21. package/dist/engine/backup.js +143 -19
  22. package/dist/engine/brain.js +22 -6
  23. package/dist/engine/codexconfig.js +476 -0
  24. package/dist/engine/cortex.js +762 -311
  25. package/dist/engine/doctor.js +676 -0
  26. package/dist/engine/evidence.js +171 -0
  27. package/dist/engine/heat.js +81 -37
  28. package/dist/engine/hippocampus.js +50 -36
  29. package/dist/engine/hookrefresh.js +296 -0
  30. package/dist/engine/hookverify.js +15 -3
  31. package/dist/engine/installer.js +1022 -0
  32. package/dist/engine/installio.js +279 -0
  33. package/dist/engine/keychain.js +735 -144
  34. package/dist/engine/maintenance.js +355 -87
  35. package/dist/engine/modinstall.js +98 -45
  36. package/dist/engine/prefrontal.js +126 -69
  37. package/dist/engine/projects.js +80 -0
  38. package/dist/engine/quarantine.js +204 -0
  39. package/dist/engine/secrets.js +41 -5
  40. package/dist/engine/shelf.js +34 -11
  41. package/dist/engine/synapses.js +183 -80
  42. package/dist/engine/triggers.js +141 -11
  43. package/dist/index.js +37 -3
  44. package/dist/mcp-surface.json +1 -0
  45. package/dist/miner/extractor.js +64 -17
  46. package/dist/miner/scheduler.js +144 -68
  47. package/dist/search/index.js +810 -176
  48. package/dist/search/quality.js +31 -0
  49. package/dist/search/semantic.js +208 -18
  50. package/dist/search/tokenize.js +44 -0
  51. package/dist/server.js +649 -343
  52. package/dist/sync/git.js +203 -72
  53. package/dist/sync/materialize.js +33 -8
  54. package/dist/sync/space.js +286 -101
  55. package/dist/sync/validate.js +204 -0
  56. package/dist/utils/budget.js +15 -2
  57. package/dist/utils/fs.js +40 -6
  58. package/dist/utils/ids.js +128 -14
  59. package/dist/utils/lock.js +219 -52
  60. package/hooks/crbro-codex.mjs +284 -0
  61. package/hooks/crbro-guard.mjs +281 -173
  62. package/hooks/crbro-hooks.shipped.json +20 -0
  63. package/hooks/crbro-lifecycle.mjs +16 -4
  64. package/hooks/crbro-subagent.mjs +180 -180
  65. package/package.json +12 -8
  66. package/dist/daemon/daemon.d.ts +0 -25
  67. package/dist/daemon/daemon.d.ts.map +0 -1
  68. package/dist/daemon/daemon.js.map +0 -1
  69. package/dist/daemon/endpoint.d.ts +0 -66
  70. package/dist/daemon/endpoint.d.ts.map +0 -1
  71. package/dist/daemon/endpoint.js.map +0 -1
  72. package/dist/daemon/lines.d.ts +0 -55
  73. package/dist/daemon/lines.d.ts.map +0 -1
  74. package/dist/daemon/lines.js.map +0 -1
  75. package/dist/daemon/proxy.d.ts +0 -55
  76. package/dist/daemon/proxy.d.ts.map +0 -1
  77. package/dist/daemon/proxy.js.map +0 -1
  78. package/dist/engine/backup.d.ts +0 -68
  79. package/dist/engine/backup.d.ts.map +0 -1
  80. package/dist/engine/backup.js.map +0 -1
  81. package/dist/engine/brain.d.ts +0 -79
  82. package/dist/engine/brain.d.ts.map +0 -1
  83. package/dist/engine/brain.js.map +0 -1
  84. package/dist/engine/cortex.d.ts +0 -670
  85. package/dist/engine/cortex.d.ts.map +0 -1
  86. package/dist/engine/cortex.js.map +0 -1
  87. package/dist/engine/dates.d.ts +0 -28
  88. package/dist/engine/dates.d.ts.map +0 -1
  89. package/dist/engine/dates.js.map +0 -1
  90. package/dist/engine/heat.d.ts +0 -28
  91. package/dist/engine/heat.d.ts.map +0 -1
  92. package/dist/engine/heat.js.map +0 -1
  93. package/dist/engine/hippocampus.d.ts +0 -60
  94. package/dist/engine/hippocampus.d.ts.map +0 -1
  95. package/dist/engine/hippocampus.js.map +0 -1
  96. package/dist/engine/hookverify.d.ts +0 -42
  97. package/dist/engine/hookverify.d.ts.map +0 -1
  98. package/dist/engine/hookverify.js.map +0 -1
  99. package/dist/engine/keychain.d.ts +0 -32
  100. package/dist/engine/keychain.d.ts.map +0 -1
  101. package/dist/engine/keychain.js.map +0 -1
  102. package/dist/engine/maintenance.d.ts +0 -218
  103. package/dist/engine/maintenance.d.ts.map +0 -1
  104. package/dist/engine/maintenance.js.map +0 -1
  105. package/dist/engine/modinstall.d.ts +0 -215
  106. package/dist/engine/modinstall.d.ts.map +0 -1
  107. package/dist/engine/modinstall.js.map +0 -1
  108. package/dist/engine/postmortem.d.ts +0 -81
  109. package/dist/engine/postmortem.d.ts.map +0 -1
  110. package/dist/engine/postmortem.js.map +0 -1
  111. package/dist/engine/prefrontal.d.ts +0 -61
  112. package/dist/engine/prefrontal.d.ts.map +0 -1
  113. package/dist/engine/prefrontal.js.map +0 -1
  114. package/dist/engine/secrets.d.ts +0 -30
  115. package/dist/engine/secrets.d.ts.map +0 -1
  116. package/dist/engine/secrets.js.map +0 -1
  117. package/dist/engine/shelf.d.ts +0 -156
  118. package/dist/engine/shelf.d.ts.map +0 -1
  119. package/dist/engine/shelf.js.map +0 -1
  120. package/dist/engine/source.d.ts +0 -6
  121. package/dist/engine/source.d.ts.map +0 -1
  122. package/dist/engine/source.js.map +0 -1
  123. package/dist/engine/synapses.d.ts +0 -82
  124. package/dist/engine/synapses.d.ts.map +0 -1
  125. package/dist/engine/synapses.js.map +0 -1
  126. package/dist/engine/triggers.d.ts +0 -53
  127. package/dist/engine/triggers.d.ts.map +0 -1
  128. package/dist/engine/triggers.js.map +0 -1
  129. package/dist/engine/usage.d.ts +0 -61
  130. package/dist/engine/usage.d.ts.map +0 -1
  131. package/dist/engine/usage.js.map +0 -1
  132. package/dist/index.d.ts +0 -2
  133. package/dist/index.d.ts.map +0 -1
  134. package/dist/index.js.map +0 -1
  135. package/dist/miner/extractor.d.ts +0 -12
  136. package/dist/miner/extractor.d.ts.map +0 -1
  137. package/dist/miner/extractor.js.map +0 -1
  138. package/dist/miner/index.d.ts +0 -61
  139. package/dist/miner/index.d.ts.map +0 -1
  140. package/dist/miner/index.js.map +0 -1
  141. package/dist/miner/scheduler.d.ts +0 -30
  142. package/dist/miner/scheduler.d.ts.map +0 -1
  143. package/dist/miner/scheduler.js.map +0 -1
  144. package/dist/miner/types.d.ts +0 -11
  145. package/dist/miner/types.d.ts.map +0 -1
  146. package/dist/miner/types.js.map +0 -1
  147. package/dist/search/index.d.ts +0 -323
  148. package/dist/search/index.d.ts.map +0 -1
  149. package/dist/search/index.js.map +0 -1
  150. package/dist/search/semantic.d.ts +0 -80
  151. package/dist/search/semantic.d.ts.map +0 -1
  152. package/dist/search/semantic.js.map +0 -1
  153. package/dist/search/synonyms.d.ts +0 -2
  154. package/dist/search/synonyms.d.ts.map +0 -1
  155. package/dist/search/synonyms.js.map +0 -1
  156. package/dist/search/tokenize.d.ts +0 -39
  157. package/dist/search/tokenize.d.ts.map +0 -1
  158. package/dist/search/tokenize.js.map +0 -1
  159. package/dist/server.d.ts +0 -41
  160. package/dist/server.d.ts.map +0 -1
  161. package/dist/server.js.map +0 -1
  162. package/dist/sync/git.d.ts +0 -36
  163. package/dist/sync/git.d.ts.map +0 -1
  164. package/dist/sync/git.js.map +0 -1
  165. package/dist/sync/materialize.d.ts +0 -35
  166. package/dist/sync/materialize.d.ts.map +0 -1
  167. package/dist/sync/materialize.js.map +0 -1
  168. package/dist/sync/ops.d.ts +0 -142
  169. package/dist/sync/ops.d.ts.map +0 -1
  170. package/dist/sync/ops.js.map +0 -1
  171. package/dist/sync/space.d.ts +0 -116
  172. package/dist/sync/space.d.ts.map +0 -1
  173. package/dist/sync/space.js.map +0 -1
  174. package/dist/types/index.d.ts +0 -346
  175. package/dist/types/index.d.ts.map +0 -1
  176. package/dist/types/index.js.map +0 -1
  177. package/dist/utils/budget.d.ts +0 -44
  178. package/dist/utils/budget.d.ts.map +0 -1
  179. package/dist/utils/budget.js.map +0 -1
  180. package/dist/utils/fs.d.ts +0 -59
  181. package/dist/utils/fs.d.ts.map +0 -1
  182. package/dist/utils/fs.js.map +0 -1
  183. package/dist/utils/hash.d.ts +0 -20
  184. package/dist/utils/hash.d.ts.map +0 -1
  185. package/dist/utils/hash.js.map +0 -1
  186. package/dist/utils/ids.d.ts +0 -47
  187. package/dist/utils/ids.d.ts.map +0 -1
  188. package/dist/utils/ids.js.map +0 -1
  189. package/dist/utils/lock.d.ts +0 -11
  190. package/dist/utils/lock.d.ts.map +0 -1
  191. package/dist/utils/lock.js.map +0 -1
  192. package/dist/utils/transcripts.d.ts +0 -63
  193. package/dist/utils/transcripts.d.ts.map +0 -1
  194. package/dist/utils/transcripts.js.map +0 -1
  195. package/dist/version.d.ts +0 -26
  196. package/dist/version.d.ts.map +0 -1
  197. package/dist/version.js.map +0 -1
@@ -29,12 +29,58 @@ exports.syncAll = syncAll;
29
29
  exports.attachSync = attachSync;
30
30
  const node_fs_1 = require("node:fs");
31
31
  const node_path_1 = __importDefault(require("node:path"));
32
+ const node_crypto_1 = require("node:crypto");
32
33
  const fs_js_1 = require("../utils/fs.js");
34
+ const lock_js_1 = require("../utils/lock.js");
33
35
  const hash_js_1 = require("../utils/hash.js");
34
36
  const secrets_js_1 = require("../engine/secrets.js");
35
- const materialize_js_1 = require("./materialize.js");
37
+ const evidence_js_1 = require("../engine/evidence.js");
36
38
  const ops_js_1 = require("./ops.js");
37
39
  const git_js_1 = require("./git.js");
40
+ const validate_js_1 = require("./validate.js");
41
+ // Fuera de shared/: el bloqueo debe sobrevivir al traslado de un espacio.
42
+ // Orden: shared-state -> shared-outbox-state -> JSON. Nunca a la inversa.
43
+ // Git retiene shared-state, pero NUNCA shared-outbox-state. Los emisores de
44
+ // Cortex llegan después de soltar la neurona y solo toman el segundo.
45
+ function withSharedState(brain, fn) {
46
+ return (0, lock_js_1.withLock)(node_path_1.default.join(brain.paths.root, 'shared-state'), fn);
47
+ }
48
+ function withOutboxState(brain, fn) {
49
+ return (0, lock_js_1.withLock)(node_path_1.default.join(brain.paths.root, 'shared-outbox-state'), fn);
50
+ }
51
+ function outboxDir(brain, space) {
52
+ return node_path_1.default.join(brain.paths.root, 'shared-outbox', space);
53
+ }
54
+ /** Con outbox-state adquirido y sin Git concurrente sobre este repositorio. */
55
+ async function drainOutboxUnlocked(brain, space) {
56
+ const pendingDir = outboxDir(brain, space);
57
+ let files;
58
+ try {
59
+ files = await node_fs_1.promises.readdir(pendingDir);
60
+ }
61
+ catch (err) {
62
+ if (err.code === 'ENOENT')
63
+ return 0;
64
+ throw err;
65
+ }
66
+ let drained = 0;
67
+ for (const file of files.filter(f => f.endsWith('.json')).sort()) {
68
+ const pending = node_path_1.default.join(pendingDir, file);
69
+ const record = await (0, fs_js_1.readJSON)(pending);
70
+ if (!record)
71
+ continue;
72
+ if (!(await readSpace(brain, space)))
73
+ throw new Error('Cannot deliver pending notes: space is missing.');
74
+ const abs = node_path_1.default.join(spaceDir(brain, space), (0, ops_js_1.opsRelPath)(record.neuronId, record.identity.author, record.identity.device));
75
+ await node_fs_1.promises.mkdir(node_path_1.default.dirname(abs), { recursive: true });
76
+ // La línea inicial aísla un posible append truncado por caída del proceso.
77
+ // Si cayó tras append pero antes de unlink se repite: applyOps es idempotente.
78
+ await node_fs_1.promises.appendFile(abs, '\n' + record.ops.map(ops_js_1.encodeOp).join('\n') + '\n', 'utf-8');
79
+ await node_fs_1.promises.unlink(pending);
80
+ drained += record.ops.length;
81
+ }
82
+ return drained;
83
+ }
38
84
  /**
39
85
  * Who this machine is. Not a security claim — anyone with push access to the
40
86
  * repository can write any name — just a label so a fact says where it came
@@ -52,16 +98,24 @@ async function getIdentity(brain, author) {
52
98
  await (0, fs_js_1.writeJSON)(p, id);
53
99
  return id;
54
100
  }
101
+ /**
102
+ * The folder of a space. Throws for a name that is not a plain slug or that
103
+ * would resolve outside shared/: with "..", create used to run `git add -A`
104
+ * and push the whole brain to whatever remote it was given (G1).
105
+ */
55
106
  function spaceDir(brain, name) {
56
- return node_path_1.default.join(brain.paths.shared, name);
107
+ return (0, validate_js_1.safeSpaceDir)(brain.paths.shared, name);
57
108
  }
109
+ /** The space's config, or null — also for a name that cannot be a space. */
58
110
  async function readSpace(brain, name) {
111
+ if ((0, validate_js_1.spaceNameProblem)(name))
112
+ return null;
59
113
  return (0, fs_js_1.readJSON)(node_path_1.default.join(spaceDir(brain, name), 'space.json'));
60
114
  }
61
115
  async function listSpaces(brain) {
62
116
  try {
63
117
  const entries = await node_fs_1.promises.readdir(brain.paths.shared, { withFileTypes: true });
64
- return entries.filter(e => e.isDirectory()).map(e => e.name);
118
+ return entries.filter(e => e.isDirectory() && !(0, validate_js_1.spaceNameProblem)(e.name)).map(e => e.name);
65
119
  }
66
120
  catch {
67
121
  return [];
@@ -69,7 +123,18 @@ async function listSpaces(brain) {
69
123
  }
70
124
  // ─── Creating and joining ────────────────────────────────────────
71
125
  async function createSpace(brain, name, remote, author, branch = 'main') {
72
- if (!(0, git_js_1.gitAvailable)()) {
126
+ return withSharedState(brain, () => createSpaceUnlocked(brain, name, remote, author, branch));
127
+ }
128
+ /** One message for a name, remote or branch that cannot be used. */
129
+ function invalidInput(name, remote, branch) {
130
+ const problem = (0, validate_js_1.spaceNameProblem)(name) || (0, validate_js_1.remoteProblem)(remote) || (0, validate_js_1.branchProblem)(branch);
131
+ return problem ? { ok: false, message: `Nothing was created or cloned. ${problem}` } : null;
132
+ }
133
+ async function createSpaceUnlocked(brain, name, remote, author, branch) {
134
+ const invalido = invalidInput(name, remote, branch);
135
+ if (invalido)
136
+ return invalido;
137
+ if (!(await (0, git_js_1.gitAvailableAsync)())) {
73
138
  return { ok: false, message: 'git is not installed, and it is what carries the shared memory. Install it and try again.' };
74
139
  }
75
140
  const dir = spaceDir(brain, name);
@@ -77,27 +142,33 @@ async function createSpace(brain, name, remote, author, branch = 'main') {
77
142
  return { ok: false, message: `The space "${name}" already exists on this machine.` };
78
143
  }
79
144
  const id = await getIdentity(brain, author);
80
- const pushed = await (0, git_js_1.initSpace)(dir, remote, branch);
145
+ const pushed = await (0, git_js_1.initSpaceAsync)(dir, remote, branch);
81
146
  const cfg = { v: 1, name, created_by: id.author, created: (0, fs_js_1.now)(), branch };
82
147
  await (0, fs_js_1.writeJSON)(node_path_1.default.join(dir, 'space.json'), cfg);
83
148
  await node_fs_1.promises.mkdir(node_path_1.default.join(dir, '.local'), { recursive: true });
84
- (0, git_js_1.git)(['add', '-A', '.'], dir);
85
- (0, git_js_1.git)(['commit', '-q', '-m', `CRBRO space "${name}"`], dir);
86
- const again = (0, git_js_1.git)(['push', 'origin', `HEAD:${branch}`], dir, 30_000);
149
+ await (0, git_js_1.gitAsync)(['add', '-A', '.'], dir);
150
+ await (0, git_js_1.gitAsync)(['commit', '-q', '-m', `CRBRO space "${name}"`], dir);
151
+ const again = await (0, git_js_1.gitAsync)(['push', 'origin', `HEAD:${branch}`], dir, 30_000);
87
152
  if (!pushed.ok && !again.ok) {
88
153
  return {
89
154
  ok: false,
90
155
  message: 'The space was created locally but could not reach the remote. Check the URL and that you have push access.',
91
- detail: (again.stderr || pushed.stderr).slice(0, 300),
156
+ detail: (0, secrets_js_1.redact)((again.stderr || pushed.stderr).slice(0, 300)).text,
92
157
  };
93
158
  }
94
159
  return {
95
160
  ok: true,
96
- message: `Space "${name}" created and pushed. Anyone you invite to ${remote} can now join it.`,
161
+ message: (0, secrets_js_1.redact)(`Space "${name}" created and pushed. Anyone you invite to ${remote} can now join it.`).text,
97
162
  };
98
163
  }
99
164
  async function joinSpace(brain, name, remote, author, branch = 'main') {
100
- if (!(0, git_js_1.gitAvailable)()) {
165
+ return withSharedState(brain, () => joinSpaceUnlocked(brain, name, remote, author, branch));
166
+ }
167
+ async function joinSpaceUnlocked(brain, name, remote, author, branch) {
168
+ const invalido = invalidInput(name, remote, branch);
169
+ if (invalido)
170
+ return invalido;
171
+ if (!(await (0, git_js_1.gitAvailableAsync)())) {
101
172
  return { ok: false, message: 'git is not installed, and it is what carries the shared memory. Install it and try again.' };
102
173
  }
103
174
  const dir = spaceDir(brain, name);
@@ -106,14 +177,14 @@ async function joinSpace(brain, name, remote, author, branch = 'main') {
106
177
  }
107
178
  await getIdentity(brain, author);
108
179
  await node_fs_1.promises.mkdir(brain.paths.shared, { recursive: true });
109
- const r = (0, git_js_1.cloneSpace)(dir, remote, branch);
180
+ const r = await (0, git_js_1.cloneSpaceAsync)(dir, remote, branch);
110
181
  if (!r.ok) {
111
182
  return {
112
183
  ok: false,
113
184
  message: r.reason === 'auth'
114
185
  ? 'Cloned nothing: no access to that repository. Ask to be added as a collaborator.'
115
186
  : 'Could not clone the space. Check the URL and your connection.',
116
- detail: r.stderr.slice(0, 300),
187
+ detail: (0, secrets_js_1.redact)(r.stderr.slice(0, 300)).text,
117
188
  };
118
189
  }
119
190
  await node_fs_1.promises.mkdir(node_path_1.default.join(dir, '.local'), { recursive: true });
@@ -124,7 +195,7 @@ function neuronOps(neuron, id) {
124
195
  const at = (0, fs_js_1.now)();
125
196
  const ops = [
126
197
  { v: ops_js_1.OPS_VERSION, op: 'neuron', nid: neuron.id, by: id.author, at,
127
- name: neuron.name, ntype: neuron.type, domain: neuron.domain },
198
+ name: (0, secrets_js_1.redact)(neuron.name || '').text, ntype: neuron.type, domain: neuron.domain },
128
199
  ];
129
200
  for (const f of neuron.facts || []) {
130
201
  if (!f.text)
@@ -132,21 +203,25 @@ function neuronOps(neuron, id) {
132
203
  const fid = f.id || (0, ops_js_1.entryId)(f.text);
133
204
  ops.push({ v: ops_js_1.OPS_VERSION, op: 'fact', nid: neuron.id, by: id.author,
134
205
  at: f.added || at, fid, text: f.text, conf: f.confidence ?? 1, src: f.source,
135
- ...(f.keys?.length ? { keys: f.keys } : {}),
206
+ ...(f.reference ? { reference: f.reference } : {}),
207
+ ...(f.temporal ? { temporal: f.temporal } : {}),
208
+ ...(f.verification_invalidated_at ? { verification_invalidated_at: f.verification_invalidated_at } : {}),
209
+ ...(f.keys?.length ? { keys: f.keys.map(k => (0, secrets_js_1.redact)(k).text) } : {}),
136
210
  ...(f.shelf_life ? { shelf: f.shelf_life } : {}) });
137
211
  if (f.verified) {
138
- ops.push({ v: ops_js_1.OPS_VERSION, op: 'verify', nid: neuron.id, by: id.author, at: f.verified, eid: fid, ekind: 'fact' });
212
+ ops.push({ v: ops_js_1.OPS_VERSION, op: 'verify', nid: neuron.id, by: id.author, at: f.verified, eid: fid, ekind: 'fact',
213
+ ...(f.verification ? { verification: f.verification } : {}) });
139
214
  }
140
215
  if (f.status === 'superseded' || f.status === 'retracted') {
141
216
  ops.push({ v: ops_js_1.OPS_VERSION, op: 'status', nid: neuron.id, by: id.author,
142
- at: f.revised || at, fid, to: f.status, why: f.revision_note });
217
+ at: f.revised || at, fid, to: f.status, why: f.revision_note ? (0, secrets_js_1.redact)(f.revision_note).text : undefined });
143
218
  }
144
219
  }
145
220
  for (const d of neuron.decisions || []) {
146
221
  if (!d.text)
147
222
  continue;
148
223
  ops.push({ v: ops_js_1.OPS_VERSION, op: 'decision', nid: neuron.id, by: id.author,
149
- at: d.date || at, did: (0, ops_js_1.entryId)(d.text), text: d.text, why: d.rationale });
224
+ at: d.date || at, did: (0, ops_js_1.entryId)(d.text), text: d.text, why: d.rationale ? (0, secrets_js_1.redact)(d.rationale).text : d.rationale });
150
225
  }
151
226
  // Dated entries carry their own date in `at`, so the team sees when a
152
227
  // pattern, error or debt was really recorded, not when it was shared.
@@ -169,7 +244,8 @@ function neuronOps(neuron, id) {
169
244
  }
170
245
  for (const [k, when] of Object.entries(neuron.entry_verified || {})) {
171
246
  if (k && when)
172
- ops.push({ v: ops_js_1.OPS_VERSION, op: 'verify', nid: neuron.id, by: id.author, at: when, eid: k, ekind: 'entry' });
247
+ ops.push({ v: ops_js_1.OPS_VERSION, op: 'verify', nid: neuron.id, by: id.author, at: when, eid: k, ekind: 'entry',
248
+ ...(neuron.entry_verification?.[k] ? { verification: neuron.entry_verification[k] } : {}) });
173
249
  }
174
250
  if (neuron.map && neuron.map.text) {
175
251
  ops.push({ v: ops_js_1.OPS_VERSION, op: 'map', nid: neuron.id, by: id.author,
@@ -198,8 +274,40 @@ async function prepareShare(brain, cortex, neuronRef, spaceName) {
198
274
  for (const kind of (0, secrets_js_1.secretKinds)(text || ''))
199
275
  blocked.push({ where, kind });
200
276
  };
201
- (neuron.facts || []).forEach((f, i) => revisar(`facts[${i}]`, f.text));
202
- (neuron.decisions || []).forEach((d, i) => revisar(`decisions[${i}]`, d.text));
277
+ const revisarReferencia = (where, reference) => {
278
+ if (reference === undefined)
279
+ return;
280
+ try {
281
+ (0, evidence_js_1.sanitizeReference)(reference);
282
+ revisar(where, JSON.stringify(reference));
283
+ }
284
+ catch {
285
+ blocked.push({ where, kind: 'unsafe source reference' });
286
+ }
287
+ };
288
+ (neuron.facts || []).forEach((f, i) => {
289
+ revisar(`facts[${i}]`, f.text);
290
+ revisarReferencia(`facts[${i}].reference`, f.reference);
291
+ revisarReferencia(`facts[${i}].verification.reference`, f.verification?.reference);
292
+ });
293
+ for (const [k, v] of Object.entries(neuron.entry_verification || {})) {
294
+ revisarReferencia(`entry_verification[${k}].reference`, v.reference);
295
+ }
296
+ (neuron.decisions || []).forEach((d, i) => {
297
+ revisar(`decisions[${i}]`, d.text);
298
+ revisar(`decisions[${i}].rationale`, d.rationale);
299
+ });
300
+ // Every other field that travels in an op, not only the entry texts (G8):
301
+ // a credential in a rationale, an alias or a revision note went out while
302
+ // the dry run reported nothing blocked.
303
+ (neuron.facts || []).forEach((f, i) => {
304
+ (f.keys || []).forEach((k, j) => revisar(`facts[${i}].keys[${j}]`, k));
305
+ if (f.revision_note)
306
+ revisar(`facts[${i}].revision_note`, f.revision_note);
307
+ });
308
+ revisar('name', neuron.name);
309
+ revisar('summary', neuron.summary);
310
+ (neuron.tags || []).forEach((t, i) => revisar(`tags[${i}]`, t));
203
311
  (neuron.patterns || []).forEach((p, i) => revisar(`patterns[${i}]`, p));
204
312
  (neuron.errors || []).forEach((e, i) => revisar(`errors[${i}]`, e));
205
313
  (neuron.debts || []).forEach((d, i) => revisar(`debts[${i}]`, d));
@@ -219,6 +327,9 @@ async function prepareShare(brain, cortex, neuronRef, spaceName) {
219
327
  };
220
328
  }
221
329
  async function commitShare(brain, cortex, neuronRef, spaceName, token) {
330
+ return withSharedState(brain, () => withOutboxState(brain, () => commitShareUnlocked(brain, cortex, neuronRef, spaceName, token)));
331
+ }
332
+ async function commitShareUnlocked(brain, cortex, neuronRef, spaceName, token) {
222
333
  const prep = await prepareShare(brain, cortex, neuronRef, spaceName);
223
334
  if ('error' in prep)
224
335
  return { ok: false, message: prep.error };
@@ -240,7 +351,7 @@ async function commitShare(brain, cortex, neuronRef, spaceName, token) {
240
351
  // keep flowing without anyone having to share it again. A neuron that was
241
352
  // unshared earlier loses its tombstone here, so re-sharing works.
242
353
  await removeUnshared(brain, neuron.id);
243
- await markShared(brain, neuron.id, spaceName);
354
+ await markSharedUnlocked(brain, neuron.id, spaceName);
244
355
  return { ok: true, message: `"${neuron.id}" is now shared in "${spaceName}".`, ops: ops.length };
245
356
  }
246
357
  function sharedMapPath(brain) {
@@ -250,9 +361,10 @@ async function sharedMap(brain) {
250
361
  return (await (0, fs_js_1.readJSON)(sharedMapPath(brain))) || {};
251
362
  }
252
363
  async function markShared(brain, neuronId, space) {
253
- const m = await sharedMap(brain);
254
- m[neuronId] = space;
255
- await (0, fs_js_1.writeJSON)(sharedMapPath(brain), m);
364
+ await withSharedState(brain, () => withOutboxState(brain, () => markSharedUnlocked(brain, neuronId, space)));
365
+ }
366
+ async function markSharedUnlocked(brain, neuronId, space) {
367
+ await (0, fs_js_1.updateJSON)(sharedMapPath(brain), current => ({ ...current, [neuronId]: space }));
256
368
  }
257
369
  // ─── Unsharing ───────────────────────────────────────────────────
258
370
  //
@@ -268,16 +380,10 @@ async function unsharedList(brain) {
268
380
  return Array.isArray(raw) ? raw.filter((x) => typeof x === 'string') : [];
269
381
  }
270
382
  async function addUnshared(brain, neuronId) {
271
- const lista = await unsharedList(brain);
272
- if (!lista.includes(neuronId))
273
- lista.push(neuronId);
274
- await (0, fs_js_1.writeJSON)(unsharedPath(brain), lista);
383
+ await (0, fs_js_1.updateJSON)(unsharedPath(brain), current => [...new Set([...(current || []), neuronId])]);
275
384
  }
276
385
  async function removeUnshared(brain, neuronId) {
277
- const lista = await unsharedList(brain);
278
- if (!lista.includes(neuronId))
279
- return;
280
- await (0, fs_js_1.writeJSON)(unsharedPath(brain), lista.filter(id => id !== neuronId));
386
+ await (0, fs_js_1.updateJSON)(unsharedPath(brain), current => current?.filter(id => id !== neuronId) || null);
281
387
  }
282
388
  /**
283
389
  * Stop following a neuron in its space. Local only: no more notes go out and
@@ -287,6 +393,9 @@ async function removeUnshared(brain, neuronId) {
287
393
  * already been forgotten can still be unshared.
288
394
  */
289
395
  async function unshareNeuron(brain, cortex, neuronRef) {
396
+ return withSharedState(brain, () => withOutboxState(brain, () => unshareNeuronUnlocked(brain, cortex, neuronRef)));
397
+ }
398
+ async function unshareNeuronUnlocked(brain, cortex, neuronRef) {
290
399
  const neuron = (await cortex.peek(neuronRef)) || (await cortex.findByName(neuronRef));
291
400
  const id = neuron?.id || neuronRef;
292
401
  const m = await sharedMap(brain);
@@ -294,6 +403,8 @@ async function unshareNeuron(brain, cortex, neuronRef) {
294
403
  if (!space) {
295
404
  return { ok: false, neuron_id: id, space: null, message: `"${id}" is not shared in any space.` };
296
405
  }
406
+ // Lo ya confirmado debe llegar al log antes de dejar de seguir la neurona.
407
+ await drainOutboxUnlocked(brain, space);
297
408
  delete m[id];
298
409
  await (0, fs_js_1.writeJSON)(sharedMapPath(brain), m);
299
410
  await addUnshared(brain, id);
@@ -307,68 +418,94 @@ async function unshareNeuron(brain, cortex, neuronRef) {
307
418
  }
308
419
  /**
309
420
  * Leave a space: one last sync so pending notes go out, then the local copy
310
- * of the space is removed and its neurons stop being followed. The local
421
+ * of the space is archived and its neurons stop being followed. The local
311
422
  * neurons and the remote repository are untouched. The ids are NOT
312
423
  * tombstoned — re-joining should follow them again.
313
424
  */
314
425
  async function leaveSpace(brain, cortex, name) {
315
- const cfg = await readSpace(brain, name);
316
- if (!cfg) {
317
- return { ok: false, message: `You are not in a space called "${name}".`, synced_before_leaving: 'skipped', neurons_unshared: 0 };
318
- }
319
- // Offline or no access are not failures here: the point is to leave, and
320
- // what could not be pushed is reported, not blocking.
321
- let synced = 'skipped';
322
- try {
323
- synced = (await syncSpaceNow(brain, cortex, name, 10_000)).state;
324
- }
325
- catch {
326
- synced = 'offline';
327
- }
328
- // maxRetries: the space is a git repository, and on Windows its pack files
329
- // are read-only; Node retries after fixing the mode, but only if asked.
330
- await node_fs_1.promises.rm(spaceDir(brain, name), { recursive: true, force: true, maxRetries: 3 });
331
- const m = await sharedMap(brain);
332
- let neurons_unshared = 0;
333
- for (const [nid, space] of Object.entries(m)) {
334
- if (space === name) {
335
- delete m[nid];
336
- neurons_unshared++;
426
+ return withSharedState(brain, async () => {
427
+ const cfg = await readSpace(brain, name);
428
+ if (!cfg) {
429
+ return { ok: false, message: `You are not in a space called "${name}".`, synced_before_leaving: 'skipped', pushed_before_leaving: false, neurons_unshared: 0 };
337
430
  }
338
- }
339
- if (neurons_unshared > 0)
340
- await (0, fs_js_1.writeJSON)(sharedMapPath(brain), m);
341
- const aviso = synced === 'ok'
342
- ? 'Your pending notes went out first.'
343
- : synced === 'offline' || synced === 'auth'
344
- ? `Your pending notes could not be pushed (${synced}); they are gone with the local copy.`
345
- : 'No final sync was possible.';
346
- return {
347
- ok: true,
348
- message: `Left "${name}". ${aviso} ${neurons_unshared} neuron(s) are no longer followed; ` +
349
- 'your local copies are untouched and the remote repository was not modified.',
350
- synced_before_leaving: synced,
351
- neurons_unshared,
352
- };
431
+ // Offline or no access are not failures here: the point is to leave, and
432
+ // what could not be pushed is reported, not blocking.
433
+ let synced = 'skipped';
434
+ let pushed = false;
435
+ try {
436
+ const result = await syncSpaceUnlocked(brain, cortex, name, 10_000);
437
+ synced = result.state;
438
+ pushed = result.pushed;
439
+ }
440
+ catch {
441
+ synced = 'offline';
442
+ }
443
+ return withOutboxState(brain, async () => {
444
+ // Siempre se conserva TODO el repositorio (incluido .git), incluso tras
445
+ // enviar: las operaciones y su historial siguen siendo recuperables.
446
+ const archive = node_path_1.default.join(brain.paths.root, 'shared-archives', `${name}-${(0, node_crypto_1.randomUUID)()}`);
447
+ try {
448
+ // También lo confirmado mientras Git o la materialización estaban ocupados.
449
+ if (await drainOutboxUnlocked(brain, name))
450
+ pushed = false;
451
+ await node_fs_1.promises.mkdir(node_path_1.default.dirname(archive), { recursive: true });
452
+ await node_fs_1.promises.rename(spaceDir(brain, name), archive);
453
+ }
454
+ catch {
455
+ return {
456
+ ok: false, message: `Could not archive "${name}". The space and its logs were kept; you have not left.`,
457
+ synced_before_leaving: synced, pushed_before_leaving: pushed, neurons_unshared: 0,
458
+ };
459
+ }
460
+ const m = await sharedMap(brain);
461
+ let neurons_unshared = 0;
462
+ for (const [nid, space] of Object.entries(m)) {
463
+ if (space === name) {
464
+ delete m[nid];
465
+ neurons_unshared++;
466
+ }
467
+ }
468
+ if (neurons_unshared > 0)
469
+ await (0, fs_js_1.writeJSON)(sharedMapPath(brain), m);
470
+ const aviso = pushed
471
+ ? 'Your pending notes went out first.'
472
+ : 'The final push was not confirmed; pending notes remain recoverable in the archive.';
473
+ return {
474
+ ok: true,
475
+ message: `Left "${name}". ${aviso} ${neurons_unshared} neuron(s) are no longer followed; ` +
476
+ `your local neurons are kept. Recoverable space archive: ${archive}`,
477
+ synced_before_leaving: synced,
478
+ pushed_before_leaving: pushed,
479
+ neurons_unshared,
480
+ archive_path: archive,
481
+ };
482
+ });
483
+ });
353
484
  }
354
- /** Append one note for a neuron that is already shared. Silent if it is not. */
485
+ /** Registra antes de confirmar. Solo omite una neurona que ya no se comparte. */
355
486
  async function emitOps(brain, neuronId, make) {
356
- const m = await sharedMap(brain);
357
- const space = m[neuronId];
358
- if (!space)
359
- return;
360
- const dir = spaceDir(brain, space);
361
- if (!(await (0, fs_js_1.fileExists)(node_path_1.default.join(dir, 'space.json'))))
362
- return;
363
- const id = await getIdentity(brain);
364
- const abs = node_path_1.default.join(dir, (0, ops_js_1.opsRelPath)(neuronId, id.author, id.device));
365
- await node_fs_1.promises.mkdir(node_path_1.default.dirname(abs), { recursive: true });
366
- const ops = make(id);
367
- if (ops.length === 0)
368
- return;
369
- await node_fs_1.promises.appendFile(abs, ops.map(ops_js_1.encodeOp).join('\n') + '\n', 'utf-8');
487
+ return withOutboxState(brain, async () => {
488
+ const m = await sharedMap(brain);
489
+ const space = m[neuronId];
490
+ if (!space)
491
+ return;
492
+ const id = await getIdentity(brain);
493
+ const ops = make(id);
494
+ if (ops.length === 0)
495
+ return;
496
+ await (0, fs_js_1.writeJSON)(node_path_1.default.join(outboxDir(brain, space), `${(0, node_crypto_1.randomUUID)()}.json`), { neuronId, identity: id, ops });
497
+ // Si sync adquiere shared-state después de esta comprobación, debe esperar
498
+ // nuestro outbox-state antes de empezar Git. No hay ventana de append/Git.
499
+ if (!(await (0, fs_js_1.fileExists)(node_path_1.default.join(brain.paths.root, 'shared-state.lock')))) {
500
+ try {
501
+ await drainOutboxUnlocked(brain, space);
502
+ }
503
+ catch { /* Ya persistido: se reintenta en sync/leave, incluso tras reinicio. */ }
504
+ }
505
+ });
370
506
  }
371
507
  // ─── Sync ────────────────────────────────────────────────────────
508
+ const MAX_LOG_BYTES = 32 * 1024 * 1024;
372
509
  async function readAllOps(dir, neuronId) {
373
510
  const opsDir = node_path_1.default.join(dir, 'neurons', neuronId, 'ops');
374
511
  let files;
@@ -383,7 +520,11 @@ async function readAllOps(dir, neuronId) {
383
520
  if (!f.endsWith('.jsonl'))
384
521
  continue;
385
522
  try {
386
- const raw = await node_fs_1.promises.readFile(node_path_1.default.join(opsDir, f), 'utf-8');
523
+ const p = node_path_1.default.join(opsDir, f);
524
+ // A log that size is not notes; reading it whole would only cost memory.
525
+ if ((await node_fs_1.promises.stat(p)).size > MAX_LOG_BYTES)
526
+ continue;
527
+ const raw = await node_fs_1.promises.readFile(p, 'utf-8');
387
528
  all.push(...(0, ops_js_1.decodeOps)(raw).ops);
388
529
  }
389
530
  catch { /* unreadable log: skip it, keep the rest */ }
@@ -391,17 +532,26 @@ async function readAllOps(dir, neuronId) {
391
532
  return all;
392
533
  }
393
534
  async function syncSpaceNow(brain, cortex, spaceName, timeoutMs = 30_000) {
535
+ return withSharedState(brain, () => syncSpaceUnlocked(brain, cortex, spaceName, timeoutMs));
536
+ }
537
+ async function syncSpaceUnlocked(brain, cortex, spaceName, timeoutMs) {
394
538
  const base = {
395
539
  space: spaceName, state: 'ok', neurons_touched: [], merged: [], pushed: false, message: '',
396
540
  };
397
541
  const cfg = await readSpace(brain, spaceName);
398
542
  if (!cfg)
399
543
  return { ...base, state: 'not_joined', message: `You are not in a space called "${spaceName}".` };
400
- if (!(0, git_js_1.gitAvailable)())
544
+ await withOutboxState(brain, () => drainOutboxUnlocked(brain, spaceName));
545
+ if (!(await (0, git_js_1.gitAvailableAsync)()))
401
546
  return { ...base, state: 'no_git', message: 'git is not installed on this machine.' };
547
+ // space.json arrives from the remote: its branch is checked like any input.
548
+ const branch = cfg.branch || 'main';
549
+ if ((0, validate_js_1.branchProblem)(branch)) {
550
+ return { ...base, state: 'conflict', message: 'The space.json of this space names an invalid branch. Nothing was synced.' };
551
+ }
402
552
  const dir = spaceDir(brain, spaceName);
403
553
  const id = await getIdentity(brain);
404
- const { pulled, pushed } = (0, git_js_1.syncSpace)(dir, id.author, cfg.branch || 'main', timeoutMs);
554
+ const { pulled, pushed } = await (0, git_js_1.syncSpaceAsync)(dir, id.author, branch, timeoutMs);
405
555
  if (!pulled.ok) {
406
556
  const state = (pulled.reason === 'offline' || pulled.reason === 'timeout') ? 'offline'
407
557
  : pulled.reason === 'auth' ? 'auth'
@@ -425,27 +575,52 @@ async function syncSpaceNow(brain, cortex, spaceName, timeoutMs = 30_000) {
425
575
  catch { /* nothing shared yet */ }
426
576
  // Neurons this machine stopped following: no merge, no markShared.
427
577
  const fuera = new Set(await unsharedList(brain));
578
+ const mapa = await sharedMap(brain);
579
+ const intactas = [];
580
+ let descartadas = 0;
428
581
  for (const nid of neuronIds) {
429
582
  if (fuera.has(nid))
430
583
  continue;
431
- const ops = await readAllOps(dir, nid);
584
+ // A folder name is what becomes a file name in the cortex: only what
585
+ // CRBRO itself would have written.
586
+ if (!validate_js_1.NEURON_DIR_RE.test(nid))
587
+ continue;
588
+ const owner = mapa[nid];
589
+ // Followed in another space: that space's sync owns it.
590
+ if (owner && owner !== spaceName)
591
+ continue;
592
+ // A local neuron you never shared here stays yours. Until 2.10 a
593
+ // teammate could create neurons/<your id>/ and the next sync merged into
594
+ // it and marked it shared, so everything you learned there went out (G2).
595
+ if (!owner && await (0, fs_js_1.fileExists)(brain.paths.neuron(nid))) {
596
+ intactas.push(nid);
597
+ continue;
598
+ }
599
+ const leidas = await readAllOps(dir, nid);
600
+ const { ops, dropped } = (0, validate_js_1.sanitizeIncomingOps)(nid, leidas);
601
+ descartadas += dropped;
432
602
  if (ops.length === 0)
433
603
  continue;
434
- const local = await cortex.peek(nid);
435
- const { neuron, report } = (0, materialize_js_1.applyOps)(local, ops, { id: nid });
604
+ const { report, created } = await cortex.mergeFromSync(nid, ops);
436
605
  const cambio = report.facts_added + report.facts_retracted + report.facts_superseded +
437
606
  report.decisions_added + report.patterns_added + report.tags_added +
438
607
  report.errors_added + report.debts_added + report.entries_removed +
439
- report.verifications_updated + report.shelf_updated +
608
+ report.verifications_updated + report.shelf_updated + report.metadata_updated +
440
609
  (report.map_updated ? 1 : 0);
441
- if (cambio > 0 || !local) {
442
- await cortex.replaceFromSync(neuron);
610
+ if (cambio > 0 || created) {
443
611
  base.neurons_touched.push(nid);
444
612
  base.merged.push(report);
445
613
  }
446
- await markShared(brain, nid, spaceName);
614
+ if (!owner)
615
+ await markSharedUnlocked(brain, nid, spaceName);
447
616
  }
448
- base.pushed = !!pushed?.ok;
617
+ if (intactas.length)
618
+ base.left_alone = intactas;
619
+ if (descartadas)
620
+ base.ops_dropped = descartadas;
621
+ const pending = await withOutboxState(brain, () => drainOutboxUnlocked(brain, spaceName));
622
+ // Git solo envió lo que había antes: lo añadido después espera el próximo sync.
623
+ base.pushed = !!pushed?.ok && pending === 0;
449
624
  const nuevos = base.merged.reduce((n, m) => n + m.facts_added, 0);
450
625
  base.message = base.neurons_touched.length === 0
451
626
  ? 'Up to date. Nothing new from anyone.'
@@ -453,6 +628,12 @@ async function syncSpaceNow(brain, cortex, spaceName, timeoutMs = 30_000) {
453
628
  if (!base.pushed && pushed) {
454
629
  base.message += ' Your own notes could not be pushed this time; they will go with the next sync.';
455
630
  }
631
+ if (intactas.length) {
632
+ base.message += ` ${intactas.length} neuron(s) in the space have the id of a private neuron of yours and were left ` +
633
+ `alone (${intactas.slice(0, 5).join(', ')}${intactas.length > 5 ? ', …' : ''}). To merge one, crbro_share it into "${spaceName}".`;
634
+ }
635
+ if (descartadas)
636
+ base.message += ` ${descartadas} malformed incoming note(s) were ignored.`;
456
637
  return base;
457
638
  }
458
639
  /** Sync every space. Used at boot and at consolidation, with a short budget. */
@@ -484,18 +665,22 @@ function attachSync(brain, cortex) {
484
665
  if (change.kind === 'fact') {
485
666
  return [{ ...comun, op: 'fact', fid: change.fid, text: change.text,
486
667
  conf: change.conf, src: change.src,
487
- ...(change.keys?.length ? { keys: change.keys } : {}),
668
+ ...(change.reference ? { reference: change.reference } : {}),
669
+ ...(change.temporal ? { temporal: change.temporal } : {}),
670
+ ...(change.verification_invalidated_at ? { verification_invalidated_at: change.verification_invalidated_at } : {}),
671
+ ...(change.keys?.length ? { keys: change.keys.map(k => (0, secrets_js_1.redact)(k).text) } : {}),
488
672
  ...(change.shelf ? { shelf: change.shelf } : {}) }];
489
673
  }
490
674
  if (change.kind === 'verify') {
491
- return [{ ...comun, op: 'verify', eid: change.eid, ekind: change.ekind }];
675
+ return [{ ...comun, op: 'verify', eid: change.eid, ekind: change.ekind,
676
+ ...(change.verification ? { verification: change.verification } : {}) }];
492
677
  }
493
678
  if (change.kind === 'status') {
494
- return [{ ...comun, op: 'status', fid: change.fid, to: change.to, why: change.why }];
679
+ return [{ ...comun, op: 'status', fid: change.fid, to: change.to, why: change.why ? (0, secrets_js_1.redact)(change.why).text : change.why }];
495
680
  }
496
681
  if (change.kind === 'decision') {
497
682
  return [{ ...comun, op: 'decision', did: (0, ops_js_1.entryId)(change.text),
498
- text: change.text, why: change.why }];
683
+ text: change.text, why: change.why ? (0, secrets_js_1.redact)(change.why).text : change.why }];
499
684
  }
500
685
  if (change.kind === 'error') {
501
686
  return [{ ...comun, op: 'error', text: change.text }];