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
@@ -3,13 +3,40 @@
3
3
  // Connection management between neurons with strength decay
4
4
  Object.defineProperty(exports, "__esModule", { value: true });
5
5
  exports.Synapses = void 0;
6
+ exports.weeklyDecayFactor = weeklyDecayFactor;
7
+ exports.decayedStrength = decayedStrength;
6
8
  const fs_js_1 = require("../utils/fs.js");
9
+ const lock_js_1 = require("../utils/lock.js");
7
10
  const ids_js_1 = require("../utils/ids.js");
11
+ const quarantine_js_1 = require("./quarantine.js");
8
12
  /** Strength is stored in [0, 1] with three decimals, like decay writes it. */
9
13
  function clampStrength(value) {
10
14
  const v = Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0;
11
15
  return Math.round(v * 1000) / 1000;
12
16
  }
17
+ const DAY_MS = 86_400_000;
18
+ /**
19
+ * Weekly decay factor by how long the link has been idle. Applied for the
20
+ * time actually elapsed since the last decay (2.11): until then the same
21
+ * factor hit on EVERY maintenance run, so a brain maintained daily pruned an
22
+ * idle link in a week and one maintained monthly in eight months.
23
+ */
24
+ function weeklyDecayFactor(idleDays) {
25
+ if (idleDays > 90)
26
+ return 0.8;
27
+ if (idleDays > 30)
28
+ return 0.9;
29
+ if (idleDays > 7)
30
+ return 0.95;
31
+ return 1;
32
+ }
33
+ /** Strength after `elapsedDays` of decay at the rate an `idleDays` idle link gets. */
34
+ function decayedStrength(strength, idleDays, elapsedDays) {
35
+ const f = weeklyDecayFactor(idleDays);
36
+ if (f === 1 || elapsedDays <= 0)
37
+ return strength;
38
+ return Math.round(strength * Math.pow(f, elapsedDays / 7) * 1000) / 1000;
39
+ }
13
40
  class Synapses {
14
41
  brain;
15
42
  constructor(brain) {
@@ -17,43 +44,51 @@ class Synapses {
17
44
  }
18
45
  /**
19
46
  * Create or strengthen a synapse between two neurons.
47
+ *
48
+ * Every write goes through the same locked read-modify-write as the rest of
49
+ * the brain (2.11). Before, the synapse file and BOTH neurons were read and
50
+ * rewritten without a lock: a learn landing on a 1 MB neuron while
51
+ * consolidate linked it lost its fact, silently.
20
52
  */
21
53
  async connect(fromId, toId, type, context, options) {
22
54
  const id = (0, ids_js_1.synapseId)(fromId, toId);
23
- let synapse = await (0, fs_js_1.readJSON)(this.brain.paths.synapse(id));
24
- let action;
55
+ let action = 'strengthened';
25
56
  const absoluta = typeof options?.strength === 'number' ? clampStrength(options.strength) : undefined;
26
- if (synapse) {
27
- // Strengthen existing synapse
28
- synapse.strength = absoluta !== undefined ? absoluta : Math.min(1.0, synapse.strength + 0.1);
29
- synapse.co_access_count += 1;
30
- synapse.last_co_access = (0, fs_js_1.now)();
31
- if (context && !(options?.keepExistingContext && synapse.context)) {
32
- synapse.context = context;
57
+ const synapse = await (0, fs_js_1.updateJSON)(this.brain.paths.synapse(id), existing => {
58
+ const cuando = (0, fs_js_1.now)();
59
+ if (existing) {
60
+ // Strengthen existing synapse
61
+ existing.strength = absoluta !== undefined ? absoluta : Math.min(1.0, existing.strength + 0.1);
62
+ existing.co_access_count += 1;
63
+ existing.last_co_access = cuando;
64
+ // A fresh co-access restarts the decay clock.
65
+ existing.decayed_at = cuando;
66
+ if (context && !(options?.keepExistingContext && existing.context)) {
67
+ existing.context = context;
68
+ }
69
+ action = 'strengthened';
70
+ return existing;
33
71
  }
34
- action = 'strengthened';
35
- }
36
- else {
37
- // Create new synapse
38
- synapse = {
72
+ action = 'created';
73
+ return {
39
74
  id,
40
75
  nodes: [fromId, toId],
41
76
  strength: absoluta !== undefined ? absoluta : (options?.initialStrength ?? 0.5),
42
77
  type,
43
78
  context: context || '',
44
79
  co_access_count: 1,
45
- last_co_access: (0, fs_js_1.now)(),
80
+ last_co_access: cuando,
81
+ decayed_at: cuando,
46
82
  };
47
- action = 'created';
48
- // Update manifest
83
+ });
84
+ if (action === 'created') {
49
85
  const manifest = await this.brain.getManifest();
50
86
  await this.brain.updateManifest({ total_synapses: manifest.total_synapses + 1 });
51
87
  }
52
- await (0, fs_js_1.writeJSON)(this.brain.paths.synapse(id), synapse);
53
88
  // Update neuron connection lists
54
89
  await this.addConnectionToNeuron(fromId, toId);
55
90
  await this.addConnectionToNeuron(toId, fromId);
56
- return { synapse, action };
91
+ return { synapse: synapse, action };
57
92
  }
58
93
  /**
59
94
  * Remove one synapse and unlink both neurons. Absent → removed:false, no
@@ -61,10 +96,16 @@ class Synapses {
61
96
  */
62
97
  async disconnect(fromId, toId) {
63
98
  const id = (0, ids_js_1.synapseId)(fromId, toId);
64
- const synapse = await (0, fs_js_1.readJSON)(this.brain.paths.synapse(id));
99
+ const file = this.brain.paths.synapse(id);
100
+ const synapse = await (0, lock_js_1.withLock)(file, async () => {
101
+ const s = await (0, fs_js_1.readJSON)(file).catch(() => null);
102
+ if (!s)
103
+ return null;
104
+ await (0, fs_js_1.deleteJSON)(file);
105
+ return s;
106
+ });
65
107
  if (!synapse)
66
108
  return { synapse_id: id, removed: false };
67
- await (0, fs_js_1.deleteJSON)(this.brain.paths.synapse(id));
68
109
  // Remove from neuron connection lists. Use the nodes the file recorded,
69
110
  // not the ids the caller passed: the id is order-free and prefix-free, so
70
111
  // the caller's spelling may differ from what the neurons hold.
@@ -81,12 +122,15 @@ class Synapses {
81
122
  */
82
123
  async setStrength(fromId, toId, strength) {
83
124
  const id = (0, ids_js_1.synapseId)(fromId, toId);
84
- const synapse = await (0, fs_js_1.readJSON)(this.brain.paths.synapse(id));
85
- if (!synapse)
86
- return null;
87
- synapse.strength = clampStrength(strength);
88
- await (0, fs_js_1.writeJSON)(this.brain.paths.synapse(id), synapse);
89
- return synapse;
125
+ let found = false;
126
+ const s = await (0, fs_js_1.updateJSON)(this.brain.paths.synapse(id), current => {
127
+ if (!current)
128
+ return null;
129
+ found = true;
130
+ current.strength = clampStrength(strength);
131
+ return current;
132
+ });
133
+ return found ? s : null;
90
134
  }
91
135
  /**
92
136
  * Remove every synapse touching a neuron. Returns how many went. Used when
@@ -95,7 +139,7 @@ class Synapses {
95
139
  async removeAllFor(neuronId) {
96
140
  let removed = 0;
97
141
  for (const id of await (0, fs_js_1.listJSONFiles)(this.brain.paths.synapses)) {
98
- const synapse = await (0, fs_js_1.readJSON)(this.brain.paths.synapse(id));
142
+ const synapse = await this.readSynapse(id);
99
143
  if (!synapse || !synapse.nodes.includes(neuronId))
100
144
  continue;
101
145
  const r = await this.disconnect(synapse.nodes[0], synapse.nodes[1]);
@@ -117,7 +161,7 @@ class Synapses {
117
161
  if (fromId === intoId)
118
162
  return out;
119
163
  for (const id of await (0, fs_js_1.listJSONFiles)(this.brain.paths.synapses)) {
120
- const synapse = await (0, fs_js_1.readJSON)(this.brain.paths.synapse(id));
164
+ const synapse = await this.readSynapse(id);
121
165
  if (!synapse || !synapse.nodes.includes(fromId))
122
166
  continue;
123
167
  const other = synapse.nodes[0] === fromId ? synapse.nodes[1] : synapse.nodes[0];
@@ -130,24 +174,25 @@ class Synapses {
130
174
  const nuevoId = (0, ids_js_1.synapseId)(intoId, other);
131
175
  // Ids drop the type prefix, so two neurons differing only by prefix
132
176
  // yield the same synapse id: then the file stays, only the nodes move.
133
- const existente = nuevoId === id ? null : await (0, fs_js_1.readJSON)(this.brain.paths.synapse(nuevoId));
134
- if (existente) {
135
- existente.strength = clampStrength(Math.max(existente.strength, synapse.strength));
136
- existente.co_access_count = (existente.co_access_count || 0) + (synapse.co_access_count || 0);
137
- if ((synapse.last_co_access || '') > (existente.last_co_access || '')) {
138
- existente.last_co_access = synapse.last_co_access;
177
+ let fusion = false;
178
+ await (0, fs_js_1.updateJSON)(this.brain.paths.synapse(nuevoId), existente => {
179
+ if (existente && nuevoId !== id) {
180
+ fusion = true;
181
+ existente.strength = clampStrength(Math.max(existente.strength, synapse.strength));
182
+ existente.co_access_count = (existente.co_access_count || 0) + (synapse.co_access_count || 0);
183
+ if ((synapse.last_co_access || '') > (existente.last_co_access || '')) {
184
+ existente.last_co_access = synapse.last_co_access;
185
+ }
186
+ return existente;
139
187
  }
140
- await (0, fs_js_1.writeJSON)(this.brain.paths.synapse(nuevoId), existente);
188
+ return { ...synapse, id: nuevoId, nodes: [intoId, other] };
189
+ });
190
+ if (nuevoId !== id)
141
191
  await (0, fs_js_1.deleteJSON)(this.brain.paths.synapse(id));
192
+ if (fusion)
142
193
  out.merged++;
143
- }
144
- else {
145
- const movido = { ...synapse, id: nuevoId, nodes: [intoId, other] };
146
- await (0, fs_js_1.writeJSON)(this.brain.paths.synapse(nuevoId), movido);
147
- if (nuevoId !== id)
148
- await (0, fs_js_1.deleteJSON)(this.brain.paths.synapse(id));
194
+ else
149
195
  out.moved++;
150
- }
151
196
  await this.removeConnectionFromNeuron(other, fromId);
152
197
  await this.addConnectionToNeuron(other, intoId);
153
198
  await this.addConnectionToNeuron(intoId, other);
@@ -166,7 +211,7 @@ class Synapses {
166
211
  const ids = await (0, fs_js_1.listJSONFiles)(this.brain.paths.synapses);
167
212
  const connections = [];
168
213
  for (const id of ids) {
169
- const synapse = await (0, fs_js_1.readJSON)(this.brain.paths.synapse(id));
214
+ const synapse = await this.readSynapse(id);
170
215
  if (!synapse)
171
216
  continue;
172
217
  if (!synapse.nodes.includes(neuronId))
@@ -174,8 +219,12 @@ class Synapses {
174
219
  if (minStrength && synapse.strength < minStrength)
175
220
  continue;
176
221
  const targetId = synapse.nodes[0] === neuronId ? synapse.nodes[1] : synapse.nodes[0];
177
- // Get target neuron name
178
- const targetNeuron = await (0, fs_js_1.readJSON)(this.brain.paths.neuron(targetId));
222
+ // Get target neuron name. An unreadable neighbour shows by its id.
223
+ let targetNeuron = null;
224
+ try {
225
+ targetNeuron = await (0, fs_js_1.readJSON)(this.brain.paths.neuron(targetId));
226
+ }
227
+ catch { /* corrupt */ }
179
228
  const targetName = targetNeuron?.name || targetId;
180
229
  connections.push({
181
230
  target_id: targetId,
@@ -197,36 +246,63 @@ class Synapses {
197
246
  return (0, fs_js_1.readJSON)(this.brain.paths.synapse(id));
198
247
  }
199
248
  /**
200
- * Decay all synapses based on time since last co-access.
201
- * Returns number of pruned synapses (strength fell below threshold).
249
+ * Decay all synapses by the time elapsed since each was last decayed, and
250
+ * prune the ones that fall below the threshold (a quarantine copy first).
251
+ * Returns how many were pruned — or, with dryRun, how many would be, with
252
+ * nothing written.
253
+ *
254
+ * A synapse written before 2.11 has no decayed_at: the first pass only
255
+ * stamps it, so upgrading never applies a backlog of decay at once.
202
256
  */
203
- async decay(pruneThreshold = 0.05) {
257
+ async decay(pruneThreshold = 0.05, options) {
204
258
  const ids = await (0, fs_js_1.listJSONFiles)(this.brain.paths.synapses);
259
+ const ahora = options?.now ?? new Date();
260
+ const ahoraIso = ahora.toISOString();
205
261
  let pruned = 0;
206
262
  for (const id of ids) {
207
- const synapse = await (0, fs_js_1.readJSON)(this.brain.paths.synapse(id));
208
- if (!synapse)
263
+ const file = this.brain.paths.synapse(id);
264
+ const leida = await this.readSynapse(id);
265
+ if (!leida)
266
+ continue;
267
+ // An archived neuron's links wait for it, untouched (2.11).
268
+ if (options?.skipNodes && leida.nodes.some(n => options.skipNodes.has(n)))
209
269
  continue;
210
- // Calculate decay
211
- const lastAccess = new Date(synapse.last_co_access).getTime();
212
- const diffDays = (Date.now() - lastAccess) / (1000 * 60 * 60 * 24);
213
- let decayFactor = 1.0;
214
- if (diffDays > 90)
215
- decayFactor = 0.8;
216
- else if (diffDays > 30)
217
- decayFactor = 0.9;
218
- else if (diffDays > 7)
219
- decayFactor = 0.95;
220
- synapse.strength = Math.round(synapse.strength * decayFactor * 1000) / 1000;
221
- if (synapse.strength < pruneThreshold) {
222
- // Prune weak synapse: same path as an explicit disconnect.
223
- const r = await this.disconnect(synapse.nodes[0], synapse.nodes[1]);
270
+ const idle = (ahora.getTime() - new Date(leida.last_co_access).getTime()) / DAY_MS;
271
+ const desde = leida.decayed_at ? Date.parse(leida.decayed_at) : NaN;
272
+ const elapsed = Number.isFinite(desde) ? (ahora.getTime() - desde) / DAY_MS : 0;
273
+ const nueva = decayedStrength(leida.strength, idle, elapsed);
274
+ if (nueva < pruneThreshold) {
275
+ if (options?.dryRun) {
276
+ pruned++;
277
+ continue;
278
+ }
279
+ // Prune weak synapse: same path as an explicit disconnect, after a
280
+ // copy — a link that took months to build is not deleted outright.
281
+ await (0, quarantine_js_1.writeQuarantine)(this.brain.paths.quarantine, leida.id || id, { ...leida, strength: nueva, pruned_at: ahoraIso }, 'whole');
282
+ const r = await this.disconnect(leida.nodes[0], leida.nodes[1]);
224
283
  if (r.removed)
225
284
  pruned++;
285
+ continue;
226
286
  }
227
- else {
228
- await (0, fs_js_1.writeJSON)(this.brain.paths.synapse(id), synapse);
229
- }
287
+ if (options?.dryRun)
288
+ continue;
289
+ // Write only when something changes: the strength, or the first stamp.
290
+ // Unchanged, decayed_at stays where it was and the elapsed time keeps
291
+ // accumulating, so slow decay is not rounded away run after run.
292
+ if (nueva === leida.strength && leida.decayed_at)
293
+ continue;
294
+ await (0, fs_js_1.updateJSON)(file, current => {
295
+ if (!current)
296
+ return null;
297
+ // Re-derived from the fresh read under the lock: a strengthen that
298
+ // landed meanwhile restarted the clock and is not undone.
299
+ const idleNow = (ahora.getTime() - new Date(current.last_co_access).getTime()) / DAY_MS;
300
+ const d = current.decayed_at ? Date.parse(current.decayed_at) : NaN;
301
+ const el = Number.isFinite(d) ? (ahora.getTime() - d) / DAY_MS : 0;
302
+ current.strength = Math.max(pruneThreshold, decayedStrength(current.strength, idleNow, el));
303
+ current.decayed_at = ahoraIso;
304
+ return current;
305
+ });
230
306
  }
231
307
  return pruned;
232
308
  }
@@ -238,21 +314,48 @@ class Synapses {
238
314
  return ids.length;
239
315
  }
240
316
  // ─── Private helpers ────────────────────────────────────────────
241
- async addConnectionToNeuron(neuronId, connectedId) {
242
- const neuron = await (0, fs_js_1.readJSON)(this.brain.paths.neuron(neuronId));
243
- if (!neuron)
244
- return;
245
- if (!neuron.connections.includes(connectedId)) {
246
- neuron.connections.push(connectedId);
247
- await (0, fs_js_1.writeJSON)(this.brain.paths.neuron(neuronId), neuron);
317
+ /** A synapse file, or null when absent or unreadable (the integrity check names those). */
318
+ async readSynapse(id) {
319
+ try {
320
+ const s = await (0, fs_js_1.readJSON)(this.brain.paths.synapse(id));
321
+ return s && Array.isArray(s.nodes) && s.nodes.length === 2 ? s : null;
322
+ }
323
+ catch {
324
+ return null;
248
325
  }
249
326
  }
327
+ /**
328
+ * Locked, and never creating: a neuron deleted meanwhile is not brought
329
+ * back by a connection list. A corrupt neuron file throws — linking to it
330
+ * would mean rewriting a file nobody can read.
331
+ */
332
+ async addConnectionToNeuron(neuronId, connectedId) {
333
+ await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(neuronId), current => {
334
+ if (!current)
335
+ return null;
336
+ if (!Array.isArray(current.connections))
337
+ current.connections = [];
338
+ if (current.connections.includes(connectedId))
339
+ return null;
340
+ current.connections.push(connectedId);
341
+ return current;
342
+ });
343
+ }
344
+ /** Locked; a missing or unreadable neuron is left alone, never a reason to stop a disconnect. */
250
345
  async removeConnectionFromNeuron(neuronId, connectedId) {
251
- const neuron = await (0, fs_js_1.readJSON)(this.brain.paths.neuron(neuronId));
252
- if (!neuron)
253
- return;
254
- neuron.connections = neuron.connections.filter(c => c !== connectedId);
255
- await (0, fs_js_1.writeJSON)(this.brain.paths.neuron(neuronId), neuron);
346
+ try {
347
+ await (0, fs_js_1.updateJSON)(this.brain.paths.neuron(neuronId), current => {
348
+ if (!current)
349
+ return null;
350
+ const antes = (current.connections || []).length;
351
+ current.connections = (current.connections || []).filter(c => c !== connectedId);
352
+ return current.connections.length === antes ? null : current;
353
+ });
354
+ }
355
+ catch (err) {
356
+ if (!(err instanceof fs_js_1.CorruptJSONError))
357
+ throw err;
358
+ }
256
359
  }
257
360
  }
258
361
  exports.Synapses = Synapses;
@@ -21,19 +21,36 @@ Object.defineProperty(exports, "__esModule", { value: true });
21
21
  exports.MAX_LESSONS = exports.TRIGGER_FILE = exports.TRIGGER_INDEX_VERSION = void 0;
22
22
  exports.keysOfCommand = keysOfCommand;
23
23
  exports.keysOfEntry = keysOfEntry;
24
+ exports.projectMaps = projectMaps;
24
25
  exports.buildTriggerIndex = buildTriggerIndex;
26
+ exports.projectOf = projectOf;
25
27
  exports.lessonsFor = lessonsFor;
26
28
  exports.triggerIndexPath = triggerIndexPath;
27
29
  exports.loadAllNeurons = loadAllNeurons;
28
30
  exports.writeTriggerIndex = writeTriggerIndex;
29
31
  const node_path_1 = __importDefault(require("node:path"));
32
+ const node_fs_1 = require("node:fs");
30
33
  const fs_js_1 = require("../utils/fs.js");
31
34
  const ops_js_1 = require("../sync/ops.js");
32
- exports.TRIGGER_INDEX_VERSION = 2; // 2: path: keys, so exploring a project wakes its lessons
35
+ // 2: path: keys, so exploring a project wakes its lessons. 3 (2.11): each entry
36
+ // says which projects it belongs to (p) and, when it is not the user's own,
37
+ // where it came from (o); `projects` maps a folder or alias to its project.
38
+ exports.TRIGGER_INDEX_VERSION = 3;
33
39
  exports.TRIGGER_FILE = 'triggers.json';
34
40
  const PREVIEW = 320;
35
41
  /** Lessons handed over per command. More than this is a lecture, and lectures get skimmed. */
36
42
  exports.MAX_LESSONS = 3;
43
+ /**
44
+ * Subcommands that only look: `git status`, `gh repo view`… A lesson that
45
+ * mentions one in passing ("after git status showed…") is not about running it,
46
+ * so prose never makes a key of these — only a command in backticks does. They
47
+ * were most of the guard's noise: `git status` in one repo woke three lessons
48
+ * about another project because each said "git status" somewhere.
49
+ */
50
+ const READ_ONLY = new Set(('git status|git log|git branch|git diff|git show|git remote|git config|git rev-parse|git ls-files|git blame|'
51
+ + 'gh repo view|gh pr view|gh pr list|gh pr status|gh pr checks|gh issue view|gh issue list|gh run list|gh run view|gh release view|gh release list|gh api|'
52
+ + 'npm ls|npm view|npm list|npm outdated|docker ps|docker images|docker logs|kubectl get|kubectl describe|kubectl logs|'
53
+ + 'gcloud config|aws sts|node --version|npm --version|python --version').split('|'));
37
54
  /** Programs whose next word names the action. Anything else needs backticks or a script name to count. */
38
55
  const HEADS = new Set(('git npm npx pnpm yarn bun bunx node deno python python3 py pip pipx uv firebase gcloud gsutil gh docker '
39
56
  + 'kubectl helm terraform wp composer php supabase vercel netlify wrangler aws az ssh scp rsync curl wget robocopy '
@@ -163,16 +180,71 @@ function keysOfEntry(text) {
163
180
  if (!next || !SUBCOMMAND.test(next) || PROSE.has(next))
164
181
  continue;
165
182
  const third = word(words[i + 2] || '');
166
- keys.add(DEEP.has(`${w} ${next}`) && third && SUBCOMMAND.test(third) && !third.startsWith('-') && !PROSE.has(third) ? `${w} ${next} ${third}` : `${w} ${next}`);
183
+ const key = DEEP.has(`${w} ${next}`) && third && SUBCOMMAND.test(third) && !third.startsWith('-') && !PROSE.has(third) ? `${w} ${next} ${third}` : `${w} ${next}`;
184
+ if (READ_ONLY.has(key))
185
+ continue; // from backticks only
186
+ keys.add(key);
167
187
  }
168
188
  // A script is only a trigger as the thing that runs; a program pair needs a known program.
169
189
  return [...keys].filter(k => k.startsWith('path:') || (k.includes(' ') ? HEADS.has(k.split(' ')[0]) || WRAPPERS.has(k.split(' ')[0]) : SCRIPT.test(k)));
170
190
  }
171
- function buildTriggerIndex(neurons, builtAt = (0, fs_js_1.now)()) {
191
+ /** "C:\\Work\\Shop-Site" and "shop_site" alike: lower case, every run of other characters one dash. */
192
+ function slug(s) {
193
+ return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
194
+ }
195
+ /**
196
+ * Alias → project id, and neuron id → its projects. A neuron belongs to a
197
+ * project when the registry binds it (crbro_boot project_neurons), or when
198
+ * its id or name spells the project's folder name as whole words
199
+ * (`project_shop_site` and the alias `shop-site`). Aliases shorter than four
200
+ * characters name too much to count by name.
201
+ */
202
+ function projectMaps(registry, neurons) {
203
+ const aliases = {};
204
+ const byNeuron = new Map();
205
+ const named = [];
206
+ for (const [key, p] of Object.entries(registry?.projects ?? {})) {
207
+ const id = typeof p?.id === 'string' ? p.id : key;
208
+ for (const a of [id, ...(Array.isArray(p?.aliases) ? p.aliases : [])]) {
209
+ if (typeof a !== 'string' || !a)
210
+ continue;
211
+ const lower = a.toLowerCase().replace(/\\/g, '/').replace(/\/+$/, '');
212
+ aliases[lower] = id;
213
+ const base = lower.split('/').pop() || lower;
214
+ aliases[base] ??= id;
215
+ const token = slug(base);
216
+ if (token.length >= 4 && !token.startsWith('project-'))
217
+ named.push({ id, token });
218
+ }
219
+ for (const n of Array.isArray(p?.neurons) ? p.neurons : []) {
220
+ if (!byNeuron.has(n))
221
+ byNeuron.set(n, new Set());
222
+ byNeuron.get(n).add(id);
223
+ }
224
+ }
225
+ for (const n of neurons) {
226
+ if (!n?.id)
227
+ continue;
228
+ const words = `-${slug(n.id)}-${slug(n.name || '')}-`;
229
+ for (const { id, token } of named) {
230
+ if (words.includes(`-${token}-`)) {
231
+ if (!byNeuron.has(n.id))
232
+ byNeuron.set(n.id, new Set());
233
+ byNeuron.get(n.id).add(id);
234
+ }
235
+ }
236
+ }
237
+ return { aliases, byNeuron };
238
+ }
239
+ function buildTriggerIndex(neurons, builtAt = (0, fs_js_1.now)(), registry) {
172
240
  const index = { v: exports.TRIGGER_INDEX_VERSION, built: builtAt, keys: {}, entries: [] };
241
+ const { aliases, byNeuron } = projectMaps(registry, neurons);
242
+ if (Object.keys(aliases).length)
243
+ index.projects = aliases;
173
244
  for (const n of neurons) {
174
245
  if (!n || n.type === 'protocol')
175
246
  continue;
247
+ const projects = [...(byNeuron.get(n.id) ?? [])].sort();
176
248
  const lists = [['error', n.errors], ['debt', n.debts], ['pattern', n.patterns]];
177
249
  for (const [k, list] of lists) {
178
250
  for (const text of list || []) {
@@ -182,10 +254,13 @@ function buildTriggerIndex(neurons, builtAt = (0, fs_js_1.now)()) {
182
254
  if (keys.length === 0)
183
255
  continue;
184
256
  const at = index.entries.length;
257
+ const origin = n.entry_source?.[(0, ops_js_1.entryId)(text)];
185
258
  index.entries.push({
186
259
  n: n.id, name: n.name, e: (0, ops_js_1.entryId)(text), k,
187
260
  d: (n.entry_dates?.[(0, ops_js_1.entryId)(text)] || '').slice(0, 10),
188
261
  t: text.length > PREVIEW ? `${text.slice(0, PREVIEW).trimEnd()}…` : text,
262
+ ...(projects.length ? { p: projects } : {}),
263
+ ...(origin ? { o: origin } : {}),
189
264
  });
190
265
  for (const key of keys)
191
266
  (index.keys[key] ||= []).push(at);
@@ -194,15 +269,65 @@ function buildTriggerIndex(neurons, builtAt = (0, fs_js_1.now)()) {
194
269
  }
195
270
  return index;
196
271
  }
197
- /** What the hook would say for this command: errors first, then newest first, capped. */
198
- function lessonsFor(index, command, max = exports.MAX_LESSONS) {
199
- const hits = new Set();
200
- for (const k of keysOfCommand(command))
272
+ /**
273
+ * The project a working folder belongs to, by the index's alias map: the
274
+ * folder itself, the repository root it is in, or their names. Mirrored in
275
+ * hooks/crbro-guard.mjs (tested).
276
+ */
277
+ function projectOf(index, cwd) {
278
+ const map = index.projects;
279
+ if (!map || !cwd)
280
+ return null;
281
+ const candidates = [];
282
+ let dir = node_path_1.default.resolve(cwd);
283
+ for (let i = 0; i < 12; i++) {
284
+ candidates.push(dir);
285
+ if ((0, node_fs_1.existsSync)(node_path_1.default.join(dir, '.git')))
286
+ break;
287
+ const up = node_path_1.default.dirname(dir);
288
+ if (up === dir)
289
+ break;
290
+ dir = up;
291
+ }
292
+ // The folder and the repository root, by full path first, then by name.
293
+ const firstAndRoot = candidates.length > 1 ? [candidates[0], candidates[candidates.length - 1]] : candidates;
294
+ for (const c of firstAndRoot) {
295
+ const full = c.toLowerCase().replace(/\\/g, '/').replace(/\/+$/, '');
296
+ if (map[full])
297
+ return map[full];
298
+ }
299
+ for (const c of firstAndRoot) {
300
+ const name = node_path_1.default.basename(c).toLowerCase();
301
+ if (map[name])
302
+ return map[name];
303
+ }
304
+ return null;
305
+ }
306
+ /**
307
+ * What the hook would say for this command: the lessons whose keys it hits,
308
+ * from this project or from no project — another project's lesson speaks only
309
+ * when the command walks into that project's folder (a path: key) — never a
310
+ * teammate's or the miner's unless asked; this project's first, then errors,
311
+ * debts, patterns, newest first, capped.
312
+ */
313
+ function lessonsFor(index, command, max = exports.MAX_LESSONS, opts = {}) {
314
+ const hits = new Map(); // entry → reached through a path: key
315
+ const walked = new Set(); // projects whose folder the command walks into
316
+ for (const k of keysOfCommand(command)) {
317
+ if (k.startsWith('path:') && index.projects?.[k.slice(5)])
318
+ walked.add(index.projects[k.slice(5)]);
201
319
  for (const i of index.keys[k] || [])
202
- hits.add(i);
320
+ hits.set(i, hits.get(i) || k.startsWith('path:'));
321
+ }
322
+ const here = projectOf(index, opts.cwd);
203
323
  const rank = { error: 0, debt: 1, pattern: 2 };
204
- return [...hits].map(i => index.entries[i])
205
- .sort((a, b) => rank[a.k] - rank[b.k] || (a.d < b.d ? 1 : a.d > b.d ? -1 : a.e < b.e ? -1 : 1))
324
+ const mine = (l) => (here && l.p?.includes(here) ? 0 : 1);
325
+ return [...hits].map(([i, viaPath]) => ({ l: index.entries[i], viaPath }))
326
+ .filter(({ l, viaPath }) => !!l
327
+ && (!l.o || !!opts.allOrigins)
328
+ && (!l.p?.length || (here !== null && l.p.includes(here)) || viaPath || l.p.some(x => walked.has(x))))
329
+ .map(({ l }) => l)
330
+ .sort((a, b) => mine(a) - mine(b) || rank[a.k] - rank[b.k] || (a.d < b.d ? 1 : a.d > b.d ? -1 : a.e < b.e ? -1 : 1))
206
331
  .slice(0, max);
207
332
  }
208
333
  function triggerIndexPath(brain) {
@@ -229,7 +354,12 @@ async function loadAllNeurons(brain) {
229
354
  async function writeTriggerIndex(brain, preloaded) {
230
355
  try {
231
356
  const neurons = preloaded ?? await loadAllNeurons(brain);
232
- const index = buildTriggerIndex(neurons);
357
+ let registry = null;
358
+ try {
359
+ registry = await (0, fs_js_1.readJSON)(node_path_1.default.join(brain.paths.root, 'projects.json'));
360
+ }
361
+ catch { /* no projects yet */ }
362
+ const index = buildTriggerIndex(neurons, (0, fs_js_1.now)(), registry);
233
363
  await (0, fs_js_1.writeJSON)(triggerIndexPath(brain), index, { pretty: false });
234
364
  return { entries: index.entries.length, keys: Object.keys(index.keys).length };
235
365
  }
package/dist/index.js CHANGED
@@ -16,26 +16,60 @@ async function main() {
16
16
  return;
17
17
  }
18
18
  const { resolveBrainDir } = await import('./engine/brain.js');
19
- const { daemonEnabled } = await import('./daemon/endpoint.js');
19
+ const { daemonEnabled, buildId } = await import('./daemon/endpoint.js');
20
+ const { trace, traceStart, TRACE_ON } = await import('./daemon/trace.js');
20
21
  const brainRoot = resolveBrainDir();
21
22
  if (daemonEnabled(brainRoot)) {
23
+ traceStart(brainRoot, 'proxy');
22
24
  const { runProxy, spawnDetachedDaemon } = await import('./daemon/proxy.js');
25
+ const { loadSurface } = await import('./daemon/surface.js');
26
+ const build = buildId();
27
+ // The answers to initialize and tools/list, from the snapshot the build
28
+ // wrote; without one (a bare tsc build) the proxy asks a real server here.
29
+ const surface = loadSurface(build) ?? undefined;
23
30
  const proxy = runProxy({
24
31
  input: process.stdin,
25
32
  output: process.stdout,
26
33
  brainRoot,
34
+ build,
35
+ surface,
27
36
  spawnDaemon: () => spawnDetachedDaemon(brainRoot),
28
37
  isEnabled: () => daemonEnabled(brainRoot),
29
38
  log: line => process.stderr.write(`[crbro] ${line}\n`), // stdout is the MCP wire
39
+ trace,
30
40
  });
31
41
  await proxy.closed;
32
42
  process.exit(0);
33
43
  }
44
+ traceStart(brainRoot, 'classic');
34
45
  const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
35
- const { createServer } = await import('./server.js');
36
- const server = createServer();
46
+ const { createServer, createEngines } = await import('./server.js');
47
+ const engines = createEngines();
48
+ const server = createServer(engines);
37
49
  const transport = new StdioServerTransport();
38
50
  await server.connect(transport);
51
+ // Classic mode only: write a dirty index before leaving (2.11).
52
+ const { flushOnExit } = await import('./search/index.js');
53
+ flushOnExit(engines.searchEngine);
54
+ trace('engine ready', 'server connected (the index loads at crbro_boot)');
55
+ if (TRACE_ON) {
56
+ // Only with the trace on: watch the wire for the two moments that matter.
57
+ const toolCalls = new Set();
58
+ const onmessage = transport.onmessage;
59
+ transport.onmessage = (m) => {
60
+ if (m?.method === 'tools/call' && m.id !== undefined)
61
+ toolCalls.add(m.id);
62
+ onmessage?.(m);
63
+ };
64
+ const send = transport.send.bind(transport);
65
+ transport.send = (m) => {
66
+ if (m?.result?.serverInfo)
67
+ trace('initialize answered', 'by the server', true);
68
+ else if (m?.id !== undefined && toolCalls.delete(m.id))
69
+ trace('first tool result', '', true);
70
+ return send(m);
71
+ };
72
+ }
39
73
  }
40
74
  main().catch((err) => {
41
75
  console.error('CRBRO Fatal Error:', err);