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.
Files changed (205) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +256 -465
  3. package/SECURITY.md +114 -12
  4. package/bin/crbro.mjs +539 -300
  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 +139 -19
  22. package/dist/engine/brain.js +22 -6
  23. package/dist/engine/codexconfig.js +476 -0
  24. package/dist/engine/cortex.js +667 -232
  25. package/dist/engine/doctor.js +312 -14
  26. package/dist/engine/heat.js +81 -37
  27. package/dist/engine/hippocampus.js +18 -7
  28. package/dist/engine/hookrefresh.js +296 -0
  29. package/dist/engine/hookverify.js +15 -3
  30. package/dist/engine/installer.js +1022 -0
  31. package/dist/engine/installio.js +279 -0
  32. package/dist/engine/keychain.js +721 -162
  33. package/dist/engine/maintenance.js +355 -87
  34. package/dist/engine/modinstall.js +98 -45
  35. package/dist/engine/prefrontal.js +29 -6
  36. package/dist/engine/quarantine.js +204 -0
  37. package/dist/engine/secrets.js +41 -5
  38. package/dist/engine/shelf.js +25 -3
  39. package/dist/engine/synapses.js +183 -80
  40. package/dist/engine/triggers.js +141 -11
  41. package/dist/index.js +37 -3
  42. package/dist/mcp-surface.json +1 -0
  43. package/dist/miner/extractor.js +64 -17
  44. package/dist/miner/scheduler.js +144 -68
  45. package/dist/search/index.js +797 -176
  46. package/dist/search/semantic.js +208 -18
  47. package/dist/search/tokenize.js +44 -0
  48. package/dist/server.js +583 -373
  49. package/dist/sync/git.js +203 -72
  50. package/dist/sync/space.js +100 -25
  51. package/dist/sync/validate.js +204 -0
  52. package/dist/utils/budget.js +15 -2
  53. package/dist/utils/fs.js +40 -6
  54. package/dist/utils/ids.js +128 -14
  55. package/dist/utils/lock.js +94 -7
  56. package/hooks/crbro-codex.mjs +284 -0
  57. package/hooks/crbro-guard.mjs +281 -173
  58. package/hooks/crbro-hooks.shipped.json +20 -0
  59. package/hooks/crbro-lifecycle.mjs +16 -4
  60. package/hooks/crbro-subagent.mjs +180 -180
  61. package/package.json +9 -5
  62. package/dist/daemon/daemon.d.ts +0 -25
  63. package/dist/daemon/daemon.d.ts.map +0 -1
  64. package/dist/daemon/daemon.js.map +0 -1
  65. package/dist/daemon/endpoint.d.ts +0 -66
  66. package/dist/daemon/endpoint.d.ts.map +0 -1
  67. package/dist/daemon/endpoint.js.map +0 -1
  68. package/dist/daemon/lines.d.ts +0 -55
  69. package/dist/daemon/lines.d.ts.map +0 -1
  70. package/dist/daemon/lines.js.map +0 -1
  71. package/dist/daemon/proxy.d.ts +0 -55
  72. package/dist/daemon/proxy.d.ts.map +0 -1
  73. package/dist/daemon/proxy.js.map +0 -1
  74. package/dist/engine/backup.d.ts +0 -68
  75. package/dist/engine/backup.d.ts.map +0 -1
  76. package/dist/engine/backup.js.map +0 -1
  77. package/dist/engine/brain.d.ts +0 -79
  78. package/dist/engine/brain.d.ts.map +0 -1
  79. package/dist/engine/brain.js.map +0 -1
  80. package/dist/engine/cortex.d.ts +0 -682
  81. package/dist/engine/cortex.d.ts.map +0 -1
  82. package/dist/engine/cortex.js.map +0 -1
  83. package/dist/engine/dates.d.ts +0 -28
  84. package/dist/engine/dates.d.ts.map +0 -1
  85. package/dist/engine/dates.js.map +0 -1
  86. package/dist/engine/doctor.d.ts +0 -93
  87. package/dist/engine/doctor.d.ts.map +0 -1
  88. package/dist/engine/doctor.js.map +0 -1
  89. package/dist/engine/evidence.d.ts +0 -26
  90. package/dist/engine/evidence.d.ts.map +0 -1
  91. package/dist/engine/evidence.js.map +0 -1
  92. package/dist/engine/heat.d.ts +0 -28
  93. package/dist/engine/heat.d.ts.map +0 -1
  94. package/dist/engine/heat.js.map +0 -1
  95. package/dist/engine/hippocampus.d.ts +0 -60
  96. package/dist/engine/hippocampus.d.ts.map +0 -1
  97. package/dist/engine/hippocampus.js.map +0 -1
  98. package/dist/engine/hookverify.d.ts +0 -42
  99. package/dist/engine/hookverify.d.ts.map +0 -1
  100. package/dist/engine/hookverify.js.map +0 -1
  101. package/dist/engine/keychain.d.ts +0 -32
  102. package/dist/engine/keychain.d.ts.map +0 -1
  103. package/dist/engine/keychain.js.map +0 -1
  104. package/dist/engine/maintenance.d.ts +0 -218
  105. package/dist/engine/maintenance.d.ts.map +0 -1
  106. package/dist/engine/maintenance.js.map +0 -1
  107. package/dist/engine/modinstall.d.ts +0 -215
  108. package/dist/engine/modinstall.d.ts.map +0 -1
  109. package/dist/engine/modinstall.js.map +0 -1
  110. package/dist/engine/postmortem.d.ts +0 -81
  111. package/dist/engine/postmortem.d.ts.map +0 -1
  112. package/dist/engine/postmortem.js.map +0 -1
  113. package/dist/engine/prefrontal.d.ts +0 -64
  114. package/dist/engine/prefrontal.d.ts.map +0 -1
  115. package/dist/engine/prefrontal.js.map +0 -1
  116. package/dist/engine/projects.d.ts +0 -20
  117. package/dist/engine/projects.d.ts.map +0 -1
  118. package/dist/engine/projects.js.map +0 -1
  119. package/dist/engine/secrets.d.ts +0 -30
  120. package/dist/engine/secrets.d.ts.map +0 -1
  121. package/dist/engine/secrets.js.map +0 -1
  122. package/dist/engine/shelf.d.ts +0 -155
  123. package/dist/engine/shelf.d.ts.map +0 -1
  124. package/dist/engine/shelf.js.map +0 -1
  125. package/dist/engine/source.d.ts +0 -6
  126. package/dist/engine/source.d.ts.map +0 -1
  127. package/dist/engine/source.js.map +0 -1
  128. package/dist/engine/synapses.d.ts +0 -82
  129. package/dist/engine/synapses.d.ts.map +0 -1
  130. package/dist/engine/synapses.js.map +0 -1
  131. package/dist/engine/triggers.d.ts +0 -53
  132. package/dist/engine/triggers.d.ts.map +0 -1
  133. package/dist/engine/triggers.js.map +0 -1
  134. package/dist/engine/usage.d.ts +0 -61
  135. package/dist/engine/usage.d.ts.map +0 -1
  136. package/dist/engine/usage.js.map +0 -1
  137. package/dist/index.d.ts +0 -2
  138. package/dist/index.d.ts.map +0 -1
  139. package/dist/index.js.map +0 -1
  140. package/dist/miner/extractor.d.ts +0 -12
  141. package/dist/miner/extractor.d.ts.map +0 -1
  142. package/dist/miner/extractor.js.map +0 -1
  143. package/dist/miner/index.d.ts +0 -61
  144. package/dist/miner/index.d.ts.map +0 -1
  145. package/dist/miner/index.js.map +0 -1
  146. package/dist/miner/scheduler.d.ts +0 -30
  147. package/dist/miner/scheduler.d.ts.map +0 -1
  148. package/dist/miner/scheduler.js.map +0 -1
  149. package/dist/miner/types.d.ts +0 -11
  150. package/dist/miner/types.d.ts.map +0 -1
  151. package/dist/miner/types.js.map +0 -1
  152. package/dist/search/index.d.ts +0 -328
  153. package/dist/search/index.d.ts.map +0 -1
  154. package/dist/search/index.js.map +0 -1
  155. package/dist/search/quality.d.ts +0 -18
  156. package/dist/search/quality.d.ts.map +0 -1
  157. package/dist/search/quality.js.map +0 -1
  158. package/dist/search/semantic.d.ts +0 -80
  159. package/dist/search/semantic.d.ts.map +0 -1
  160. package/dist/search/semantic.js.map +0 -1
  161. package/dist/search/synonyms.d.ts +0 -2
  162. package/dist/search/synonyms.d.ts.map +0 -1
  163. package/dist/search/synonyms.js.map +0 -1
  164. package/dist/search/tokenize.d.ts +0 -39
  165. package/dist/search/tokenize.d.ts.map +0 -1
  166. package/dist/search/tokenize.js.map +0 -1
  167. package/dist/server.d.ts +0 -41
  168. package/dist/server.d.ts.map +0 -1
  169. package/dist/server.js.map +0 -1
  170. package/dist/sync/git.d.ts +0 -36
  171. package/dist/sync/git.d.ts.map +0 -1
  172. package/dist/sync/git.js.map +0 -1
  173. package/dist/sync/materialize.d.ts +0 -36
  174. package/dist/sync/materialize.d.ts.map +0 -1
  175. package/dist/sync/materialize.js.map +0 -1
  176. package/dist/sync/ops.d.ts +0 -147
  177. package/dist/sync/ops.d.ts.map +0 -1
  178. package/dist/sync/ops.js.map +0 -1
  179. package/dist/sync/space.d.ts +0 -118
  180. package/dist/sync/space.d.ts.map +0 -1
  181. package/dist/sync/space.js.map +0 -1
  182. package/dist/types/index.d.ts +0 -369
  183. package/dist/types/index.d.ts.map +0 -1
  184. package/dist/types/index.js.map +0 -1
  185. package/dist/utils/budget.d.ts +0 -44
  186. package/dist/utils/budget.d.ts.map +0 -1
  187. package/dist/utils/budget.js.map +0 -1
  188. package/dist/utils/fs.d.ts +0 -59
  189. package/dist/utils/fs.d.ts.map +0 -1
  190. package/dist/utils/fs.js.map +0 -1
  191. package/dist/utils/hash.d.ts +0 -20
  192. package/dist/utils/hash.d.ts.map +0 -1
  193. package/dist/utils/hash.js.map +0 -1
  194. package/dist/utils/ids.d.ts +0 -47
  195. package/dist/utils/ids.d.ts.map +0 -1
  196. package/dist/utils/ids.js.map +0 -1
  197. package/dist/utils/lock.d.ts +0 -13
  198. package/dist/utils/lock.d.ts.map +0 -1
  199. package/dist/utils/lock.js.map +0 -1
  200. package/dist/utils/transcripts.d.ts +0 -63
  201. package/dist/utils/transcripts.d.ts.map +0 -1
  202. package/dist/utils/transcripts.js.map +0 -1
  203. package/dist/version.d.ts +0 -26
  204. package/dist/version.d.ts.map +0 -1
  205. package/dist/version.js.map +0 -1
@@ -0,0 +1,204 @@
1
+ "use strict";
2
+ // ─── CRBRO Shared Memory: what comes from outside ────────────────
3
+ //
4
+ // Space names, remotes and branches come from a tool call (so possibly from
5
+ // text the model was shown); neuron directories and ops come from teammates'
6
+ // pushes. Each of them used to be joined into a path, passed to git or written
7
+ // into a neuron as it came. These checks are the one place that says what
8
+ // shapes are accepted (G1, G5, G16).
9
+ var __importDefault = (this && this.__importDefault) || function (mod) {
10
+ return (mod && mod.__esModule) ? mod : { "default": mod };
11
+ };
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.NEURON_DIR_RE = exports.SPACE_NAME_RE = void 0;
14
+ exports.spaceNameProblem = spaceNameProblem;
15
+ exports.safeSpaceDir = safeSpaceDir;
16
+ exports.remoteProblem = remoteProblem;
17
+ exports.branchProblem = branchProblem;
18
+ exports.cleanAuthor = cleanAuthor;
19
+ exports.sanitizeIncomingOps = sanitizeIncomingOps;
20
+ const node_path_1 = __importDefault(require("node:path"));
21
+ const secrets_js_1 = require("../engine/secrets.js");
22
+ const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
23
+ /** A space name is a folder under shared/: a short slug, nothing that walks. */
24
+ exports.SPACE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
25
+ function spaceNameProblem(name) {
26
+ if (typeof name !== 'string' || !exports.SPACE_NAME_RE.test(name)) {
27
+ return 'Space names are 1-64 letters, digits, "-" or "_", starting with a letter or digit (e.g. "equipo").';
28
+ }
29
+ if (WINDOWS_RESERVED.test(name))
30
+ return `"${name}" is a reserved device name on Windows.`;
31
+ return null;
32
+ }
33
+ /** Throws unless `name` is a valid space name whose folder stays inside `sharedRoot`. */
34
+ function safeSpaceDir(sharedRoot, name) {
35
+ const problem = spaceNameProblem(name);
36
+ if (problem)
37
+ throw new Error(`Invalid space name. ${problem}`);
38
+ const root = node_path_1.default.resolve(sharedRoot);
39
+ const dir = node_path_1.default.resolve(root, name);
40
+ const rel = node_path_1.default.relative(root, dir);
41
+ if (!rel || rel.startsWith('..') || node_path_1.default.isAbsolute(rel) || rel.includes(node_path_1.default.sep) || rel.includes('/')) {
42
+ throw new Error('Invalid space name: it must name a folder directly inside shared/.');
43
+ }
44
+ return dir;
45
+ }
46
+ /**
47
+ * A remote: a git URL or a local path. Never an option ("-…"), never with a
48
+ * password or token inside (it would be stored in .git/config in clear and
49
+ * echoed back in messages), never with control characters.
50
+ */
51
+ function remoteProblem(remote) {
52
+ if (typeof remote !== 'string' || !remote.trim())
53
+ return 'A remote is required.';
54
+ if (remote.length > 2048)
55
+ return 'That remote is too long.';
56
+ if (remote.startsWith('-'))
57
+ return 'A remote cannot start with "-".';
58
+ if (/[\u0000-\u001f\u007f]/.test(remote))
59
+ return 'A remote cannot contain control characters.';
60
+ const url = /^([a-z][a-z0-9+.-]*):\/\/([^/?#]*)/i.exec(remote);
61
+ if (url) {
62
+ const scheme = url[1].toLowerCase();
63
+ const authority = url[2];
64
+ const at = authority.lastIndexOf('@');
65
+ if (at !== -1) {
66
+ const userinfo = authority.slice(0, at);
67
+ if (scheme === 'http' || scheme === 'https' || userinfo.includes(':')) {
68
+ return 'The remote carries credentials (user:password@ or token@). Remove them: use an SSH URL or a credential helper. ' +
69
+ 'A URL with a credential would be written in clear to .git/config.';
70
+ }
71
+ }
72
+ }
73
+ if ((0, secrets_js_1.secretKinds)(remote).length > 0)
74
+ return 'The remote looks like it contains a credential. Remove it from the URL.';
75
+ return null;
76
+ }
77
+ /** git check-ref-format rules, the subset a branch name here needs. */
78
+ function branchProblem(branch) {
79
+ if (typeof branch !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._/-]{0,99}$/.test(branch) ||
80
+ branch.includes('..') || branch.includes('//') || branch.endsWith('/') || branch.endsWith('.') ||
81
+ branch.endsWith('.lock') || branch.includes('@{') || /\/\./.test(branch)) {
82
+ return 'Branch names are letters, digits, ".", "_", "-" or "/", starting with a letter or digit (e.g. "main").';
83
+ }
84
+ return null;
85
+ }
86
+ /** A neuron folder name in a space: what CRBRO itself would have written. */
87
+ exports.NEURON_DIR_RE = /^[a-z0-9][a-z0-9_-]{0,159}$/;
88
+ const KNOWN_OPS = new Set(['neuron', 'fact', 'status', 'decision', 'pattern', 'tag', 'error', 'map', 'purge', 'debt', 'verify']);
89
+ const NEURON_TYPES = new Set(['project', 'tech', 'lang', 'person', 'domain', 'process', 'protocol']);
90
+ const MAX_TEXT = 20_000;
91
+ const MAX_ID = 128;
92
+ function cleanText(v, max = MAX_TEXT) {
93
+ if (typeof v !== 'string')
94
+ return null;
95
+ return (0, secrets_js_1.redact)(v.length > max ? v.slice(0, max) : v).text;
96
+ }
97
+ function cleanId(v) {
98
+ return typeof v === 'string' && v.length > 0 && v.length <= MAX_ID ? v : null;
99
+ }
100
+ /** Same charset as getIdentity, 32 characters: `by` ends up in `origin` labels. */
101
+ function cleanAuthor(v) {
102
+ const s = typeof v === 'string' ? v.toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 32) : '';
103
+ return s || 'unknown';
104
+ }
105
+ /**
106
+ * Ops read from a teammate's log for the folder `nid`, made safe to merge:
107
+ * an op that names another neuron is dropped (the folder decides, so a planted
108
+ * `nid` cannot steer where a neuron is written); text is capped and passed
109
+ * through the secret filter; authors, ids, confidences and kinds are checked.
110
+ * Returns the ops kept and how many were dropped.
111
+ */
112
+ function sanitizeIncomingOps(nid, ops) {
113
+ const out = [];
114
+ let dropped = 0;
115
+ for (const raw of ops) {
116
+ const op = raw;
117
+ if (!op || typeof op !== 'object' || op['nid'] !== nid || typeof op['op'] !== 'string' || !KNOWN_OPS.has(op['op'])) {
118
+ dropped++;
119
+ continue;
120
+ }
121
+ const at = typeof op['at'] === 'string' && op['at'].length <= 40 ? op['at'] : '';
122
+ const base = { ...op, nid, by: cleanAuthor(op['by']), at };
123
+ let kept = null;
124
+ switch (op['op']) {
125
+ case 'neuron': {
126
+ const name = cleanText(op['name'], 200);
127
+ kept = {
128
+ ...base,
129
+ name: name ?? nid,
130
+ ntype: typeof op['ntype'] === 'string' && NEURON_TYPES.has(op['ntype']) ? op['ntype'] : 'project',
131
+ domain: typeof op['domain'] === 'string' ? op['domain'].toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 60) || 'general' : 'general',
132
+ };
133
+ break;
134
+ }
135
+ case 'fact': {
136
+ const text = cleanText(op['text']);
137
+ const fid = cleanId(op['fid']);
138
+ if (text === null || !fid)
139
+ break;
140
+ const conf = typeof op['conf'] === 'number' && Number.isFinite(op['conf']) ? Math.min(1, Math.max(0, op['conf'])) : 1;
141
+ const keys = Array.isArray(op['keys'])
142
+ ? op['keys'].filter((k) => typeof k === 'string').slice(0, 8).map(k => (0, secrets_js_1.redact)(k.slice(0, 120)).text)
143
+ : undefined;
144
+ kept = { ...base, text, fid, conf, ...(keys ? { keys } : {}),
145
+ ...(typeof op['src'] === 'string' ? { src: op['src'].slice(0, 60) } : {}) };
146
+ if (!keys)
147
+ delete kept['keys'];
148
+ break;
149
+ }
150
+ case 'status': {
151
+ const fid = cleanId(op['fid']);
152
+ if (!fid || (op['to'] !== 'superseded' && op['to'] !== 'retracted'))
153
+ break;
154
+ const why = op['why'] === undefined ? undefined : cleanText(op['why'], 2_000) ?? undefined;
155
+ kept = { ...base, fid, why };
156
+ if (why === undefined)
157
+ delete kept['why'];
158
+ break;
159
+ }
160
+ case 'decision': {
161
+ const text = cleanText(op['text']);
162
+ const did = cleanId(op['did']);
163
+ if (text === null || !did)
164
+ break;
165
+ const why = op['why'] === undefined ? undefined : cleanText(op['why'], 4_000) ?? undefined;
166
+ kept = { ...base, text, did, why };
167
+ if (why === undefined)
168
+ delete kept['why'];
169
+ break;
170
+ }
171
+ case 'pattern':
172
+ case 'tag':
173
+ case 'error':
174
+ case 'debt':
175
+ case 'map': {
176
+ const text = cleanText(op['text'], op['op'] === 'tag' ? 200 : MAX_TEXT);
177
+ if (text === null)
178
+ break;
179
+ kept = { ...base, text };
180
+ break;
181
+ }
182
+ case 'purge': {
183
+ const key = cleanId(op['key']);
184
+ if (!key)
185
+ break;
186
+ kept = { ...base, key };
187
+ break;
188
+ }
189
+ case 'verify': {
190
+ const eid = cleanId(op['eid']);
191
+ if (!eid || (op['ekind'] !== 'fact' && op['ekind'] !== 'entry'))
192
+ break;
193
+ kept = { ...base, eid };
194
+ break;
195
+ }
196
+ }
197
+ if (kept)
198
+ out.push(kept);
199
+ else
200
+ dropped++;
201
+ }
202
+ return { ops: out, dropped };
203
+ }
204
+ //# sourceMappingURL=validate.js.map
@@ -87,13 +87,20 @@ function fitToBudget(payload, opts = {}) {
87
87
  }
88
88
  }
89
89
  };
90
+ // The block a path belongs to: "open_items[3].text" → "open_items".
91
+ const topKey = (p) => p.split(/[.[]/)[0];
92
+ const order = opts.dropFirst ?? [];
93
+ const rank = (p) => {
94
+ const i = order.indexOf(topKey(p));
95
+ return i === -1 ? order.length : i;
96
+ };
90
97
  let guard = 0;
91
98
  while (size(copy) > budget && guard++ < 200) {
92
99
  const found = [];
93
100
  arraysOf(copy, '', found);
94
101
  const biggest = found
95
102
  .filter(a => a.arr.length > 1)
96
- .sort((a, b) => size(b.arr) - size(a.arr))[0];
103
+ .sort((a, b) => rank(a.path) - rank(b.path) || size(b.arr) - size(a.arr))[0];
97
104
  if (!biggest)
98
105
  break;
99
106
  const total = dropped[biggest.path]?.total ?? biggest.arr.length;
@@ -116,7 +123,13 @@ function fitToBudget(payload, opts = {}) {
116
123
  }
117
124
  if (Object.keys(dropped).length)
118
125
  report.entries_dropped = dropped;
119
- if (opts.howToGetMore)
126
+ if (report.returned_chars > budget)
127
+ report.over_budget_after_cuts = true;
128
+ const cutKeys = [...new Set([...Object.keys(shortened), ...Object.keys(dropped)].map(topKey))];
129
+ const doors = opts.moreByKey ? cutKeys.map(k => opts.moreByKey[k]).filter(Boolean) : [];
130
+ if (doors.length)
131
+ report.how_to_get_more = [...new Set(doors)].join(' ');
132
+ else if (opts.howToGetMore)
120
133
  report.how_to_get_more = opts.howToGetMore;
121
134
  copy.truncated = report;
122
135
  return copy;
package/dist/utils/fs.js CHANGED
@@ -5,6 +5,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  return (mod && mod.__esModule) ? mod : { "default": mod };
6
6
  };
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.CorruptJSONError = void 0;
9
+ exports.stripBOM = stripBOM;
8
10
  exports.readJSON = readJSON;
9
11
  exports.writeJSON = writeJSON;
10
12
  exports.fileExists = fileExists;
@@ -19,20 +21,52 @@ exports.now = now;
19
21
  const node_fs_1 = require("node:fs");
20
22
  const node_path_1 = __importDefault(require("node:path"));
21
23
  const lock_js_1 = require("./lock.js");
24
+ /**
25
+ * A file that exists and cannot be parsed. Its own class so a reader that
26
+ * walks the whole cortex can skip it, say which file and move on, instead of
27
+ * letting one bad neuron take recall, maintenance and consolidate down with it.
28
+ * The message keeps the 2.x wording ("Corrupted JSON at <path>").
29
+ */
30
+ class CorruptJSONError extends Error {
31
+ path;
32
+ reason;
33
+ constructor(path, reason) {
34
+ super(`CRBRO: Corrupted JSON at ${path}: ${reason}`);
35
+ this.path = path;
36
+ this.reason = reason;
37
+ this.name = 'CorruptJSONError';
38
+ }
39
+ }
40
+ exports.CorruptJSONError = CorruptJSONError;
41
+ /**
42
+ * Text of a JSON file without a leading byte-order mark. Windows PowerShell
43
+ * 5.1 (`Set-Content -Encoding utf8`, `Out-File`) writes one, and JSON.parse
44
+ * rejects it: a neuron edited by hand that way became "corrupted" for every
45
+ * reader although every byte of its content was fine.
46
+ */
47
+ function stripBOM(text) {
48
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
49
+ }
22
50
  /**
23
51
  * Safely read and parse a JSON file. Returns null if file doesn't exist.
24
- * Never throws on missing file — only on corrupted JSON.
52
+ * Never throws on missing file — only on corrupted JSON (CorruptJSONError).
25
53
  */
26
54
  async function readJSON(filePath) {
55
+ let content;
27
56
  try {
28
- const content = await node_fs_1.promises.readFile(filePath, 'utf-8');
29
- return JSON.parse(content);
57
+ content = await node_fs_1.promises.readFile(filePath, 'utf-8');
30
58
  }
31
59
  catch (err) {
32
60
  if (err instanceof Error && 'code' in err && err.code === 'ENOENT') {
33
61
  return null;
34
62
  }
35
- throw new Error(`CRBRO: Corrupted JSON at ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
63
+ throw new CorruptJSONError(filePath, err instanceof Error ? err.message : String(err));
64
+ }
65
+ try {
66
+ return JSON.parse(stripBOM(content));
67
+ }
68
+ catch (err) {
69
+ throw new CorruptJSONError(filePath, err instanceof Error ? err.message : String(err));
36
70
  }
37
71
  }
38
72
  /**
@@ -88,13 +122,13 @@ async function fileExists(filePath) {
88
122
  *
89
123
  * The mutator returns the value to write, or null to leave the file alone.
90
124
  */
91
- async function updateJSON(filePath, mutate) {
125
+ async function updateJSON(filePath, mutate, options) {
92
126
  return (0, lock_js_1.withLock)(filePath, async () => {
93
127
  const current = await readJSON(filePath);
94
128
  const next = await mutate(current);
95
129
  if (next === null)
96
130
  return current;
97
- await writeJSON(filePath, next);
131
+ await writeJSON(filePath, next, options);
98
132
  return next;
99
133
  });
100
134
  }
package/dist/utils/ids.js CHANGED
@@ -3,13 +3,16 @@
3
3
  // Generate and validate neuron/synapse IDs
4
4
  Object.defineProperty(exports, "__esModule", { value: true });
5
5
  exports.toSnakeCase = toSnakeCase;
6
+ exports.slugCandidates = slugCandidates;
6
7
  exports.legacySnakeCase = legacySnakeCase;
7
8
  exports.neuronId = neuronId;
9
+ exports.typeOfIdShape = typeOfIdShape;
8
10
  exports.synapseId = synapseId;
9
11
  exports.sessionId = sessionId;
10
12
  exports.isValidNeuronId = isValidNeuronId;
11
13
  exports.techKeywordIn = techKeywordIn;
12
14
  exports.inferNeuronType = inferNeuronType;
15
+ const node_crypto_1 = require("node:crypto");
13
16
  const TYPE_PREFIXES = {
14
17
  project: 'project_',
15
18
  tech: 'tech_',
@@ -20,10 +23,52 @@ const TYPE_PREFIXES = {
20
23
  protocol: 'protocol_',
21
24
  };
22
25
  /**
23
- * Convert a string to a valid snake_case ID.
24
- * "OctoChat Plugin" → "octochat_plugin"
26
+ * Names whose punctuation IS the identity. Without this, "C", "C++" and "C#"
27
+ * all slugged to "c" and shared one neuron. Applied before anything else, as
28
+ * whole words only, so "c++ tips" maps and "abc++" does not.
25
29
  */
26
- function toSnakeCase(str) {
30
+ const SYMBOL_WORDS = [
31
+ [/(^|[^\p{L}\p{N}])c\+\+(?![\p{L}\p{N}])/giu, '$1cpp'],
32
+ [/(^|[^\p{L}\p{N}])c#(?![\p{L}\p{N}])/giu, '$1csharp'],
33
+ [/(^|[^\p{L}\p{N}])f#(?![\p{L}\p{N}])/giu, '$1fsharp'],
34
+ ];
35
+ function mapSymbolWords(str) {
36
+ let out = str;
37
+ for (const [re, to] of SYMBOL_WORDS)
38
+ out = out.replace(re, to);
39
+ return out;
40
+ }
41
+ /** Latin letters NFD does not decompose: they used to vanish ("Straße" → "strae"). */
42
+ const LATIN_EXTRA = {
43
+ 'ß': 'ss', 'ẞ': 'ss', 'ø': 'o', 'Ø': 'o', 'ł': 'l', 'Ł': 'l', 'đ': 'd', 'Đ': 'd', 'ð': 'd', 'Ð': 'd',
44
+ 'þ': 'th', 'Þ': 'th', 'æ': 'ae', 'Æ': 'ae', 'œ': 'oe', 'Œ': 'oe', 'ı': 'i', 'ħ': 'h', 'Ħ': 'h',
45
+ };
46
+ /** Trivial one-to-one transliteration: Greek and Cyrillic. Anything else is hashed. */
47
+ const TRANSLIT = (() => {
48
+ const t = {};
49
+ const cyr = 'а:a б:b в:v г:g д:d е:e ё:e ж:zh з:z и:i й:y к:k л:l м:m н:n о:o п:p р:r с:s т:t у:u ф:f х:kh ц:ts ч:ch ш:sh щ:shch ъ: ы:y ь: э:e ю:yu я:ya і:i ї:yi є:ye ґ:g ў:u';
50
+ const gre = 'α:a β:v γ:g δ:d ε:e ζ:z η:i θ:th ι:i κ:k λ:l μ:m ν:n ξ:x ο:o π:p ρ:r σ:s ς:s τ:t υ:y φ:f χ:ch ψ:ps ω:o';
51
+ for (const pair of `${cyr} ${gre}`.split(' ')) {
52
+ const [k, v] = pair.split(':');
53
+ t[k] = v;
54
+ }
55
+ return t;
56
+ })();
57
+ function transliterate(str) {
58
+ let out = '';
59
+ for (const ch of str) {
60
+ const lower = ch.toLowerCase();
61
+ if (LATIN_EXTRA[ch] !== undefined)
62
+ out += LATIN_EXTRA[ch];
63
+ else if (TRANSLIT[lower] !== undefined)
64
+ out += TRANSLIT[lower];
65
+ else
66
+ out += ch;
67
+ }
68
+ return out;
69
+ }
70
+ /** The 2.10 algorithm, kept as a lookup fallback: neurons created by it live under its slugs. */
71
+ function slug210(str) {
27
72
  return str
28
73
  .normalize('NFD') // Split accents off their letters...
29
74
  .replace(/[\u0300-\u036f]/g, '') // ...and drop the accents, keeping the letter
@@ -34,6 +79,55 @@ function toSnakeCase(str) {
34
79
  .replace(/_+/g, '_') // Collapse multiple underscores
35
80
  .replace(/^_|_$/g, ''); // Trim leading/trailing underscores
36
81
  }
82
+ /**
83
+ * Convert a string to a valid snake_case ID.
84
+ * "OctoChat Plugin" → "octochat_plugin"
85
+ *
86
+ * Never empty for a name that has any character in it: a name whose letters
87
+ * are not Latin (and not trivially transliterable) gets a short stable hash of
88
+ * the whole name, so "東京" and "大阪" are two neurons, not one called
89
+ * "project_" that every such topic fell into.
90
+ */
91
+ function toSnakeCase(str) {
92
+ return slugOf(str).slug;
93
+ }
94
+ function slugOf(str) {
95
+ const raw = (str || '').trim();
96
+ if (!raw)
97
+ return { slug: '', hashed: false };
98
+ const folded = mapSymbolWords(raw).normalize('NFD').replace(/[\u0300-\u036f]/g, '').normalize('NFC');
99
+ const prepared = transliterate(folded);
100
+ const base = slug210(prepared);
101
+ // Letters or digits that are still not a-z/0-9 were dropped by slug210:
102
+ // their information must not be lost, or two names differing only there
103
+ // would collide.
104
+ const lost = /[^\x00-\x7f]/.test(prepared.replace(/[^\p{L}\p{N}]/gu, ''));
105
+ if (base && !lost)
106
+ return { slug: base, hashed: false };
107
+ const hash = (0, node_crypto_1.createHash)('sha1').update(raw.normalize('NFC').toLowerCase(), 'utf8').digest('hex').slice(0, 8);
108
+ return { slug: base ? `${base}_u${hash}` : `u${hash}`, hashed: true };
109
+ }
110
+ /**
111
+ * Every slug an existing neuron for this name may be filed under, newest rule
112
+ * first: the current one, the 2.10 one, and the pre-1.x accent-mangling one.
113
+ * The older two are computed on the symbol-mapped name, so "C++" never falls
114
+ * back to the "c" that 2.10 gave it — that fallback is exactly the merge the
115
+ * new rule exists to stop. Nor does a name the new rule had to hash: 2.10
116
+ * dropped its letters, so its old slug is shared by unrelated names.
117
+ */
118
+ function slugCandidates(name) {
119
+ const out = [];
120
+ const add = (s) => { if (s && !out.includes(s))
121
+ out.push(s); };
122
+ const actual = slugOf(name);
123
+ add(actual.slug);
124
+ if (actual.hashed)
125
+ return out;
126
+ const mapped = mapSymbolWords((name || '').trim());
127
+ add(slug210(mapped));
128
+ add(legacySnakeCase(mapped));
129
+ return out;
130
+ }
37
131
  /**
38
132
  * How ids were built before accents were folded: the accented letter was
39
133
  * deleted whole, so "búsqueda" became "bsqueda" and "técnico" became "tcnico".
@@ -57,9 +151,21 @@ function legacySnakeCase(str) {
57
151
  */
58
152
  function neuronId(name, type) {
59
153
  const prefix = TYPE_PREFIXES[type];
60
- const slug = toSnakeCase(name);
154
+ let slug = toSnakeCase(name);
155
+ // A name that already is an id of this type ("project_x") keeps its one
156
+ // prefix: it used to become "project_project_x".
157
+ if (slug.startsWith(prefix) && slug.length > prefix.length)
158
+ slug = slug.slice(prefix.length);
61
159
  return `${prefix}${slug}`;
62
160
  }
161
+ /** The type an id-shaped slug names ("tech_react" → tech), or null. */
162
+ function typeOfIdShape(slug) {
163
+ for (const [type, prefix] of Object.entries(TYPE_PREFIXES)) {
164
+ if (slug.startsWith(prefix) && slug.length > prefix.length)
165
+ return type;
166
+ }
167
+ return null;
168
+ }
63
169
  /**
64
170
  * Generate a synapse ID from two neuron IDs.
65
171
  * ("project_octochat", "tech_firebase") → "syn_octochat__firebase"
@@ -99,27 +205,35 @@ function techKeywordIn(text) {
99
205
  /**
100
206
  * Infer neuron type from topic name using simple heuristics.
101
207
  * Falls back to 'project' if unsure.
208
+ *
209
+ * Whole words only (accents folded). Substrings sent "Juego Onoris Lanista"
210
+ * to lang_ ("go "), "PAGO UNICO" too, and anything "digital" to tech_ ("git").
211
+ * The type is the id prefix, so a wrong guess is a wrong identity.
102
212
  */
103
213
  function inferNeuronType(topic) {
104
- const lower = topic.toLowerCase();
214
+ const folded = (topic || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim();
215
+ const words = new Set(folded.split(/[^a-z0-9]+/).filter(Boolean));
216
+ const has = (kw) => words.has(kw);
105
217
  // Tech keywords
106
- if (TECH_KEYWORDS.some(kw => lower.includes(kw)))
218
+ if (TECH_KEYWORDS.some(has))
107
219
  return 'tech';
108
220
  // Language keywords
109
- const langKeywords = ['python', 'javascript', 'typescript', 'php', 'rust', 'go', 'java', 'swift', 'kotlin', 'css', 'html', 'sql'];
110
- if (langKeywords.some(kw => lower === kw || lower.includes(` ${kw}`) || lower.includes(`${kw} `)))
221
+ const langKeywords = ['python', 'javascript', 'typescript', 'php', 'rust', 'go', 'golang', 'java', 'swift', 'kotlin', 'css', 'html', 'sql'];
222
+ if (langKeywords.some(has))
111
223
  return 'lang';
112
224
  // Process keywords
113
- const processKeywords = ['deploy', 'migration', 'workflow', 'pipeline', 'ci/cd', 'backup', 'setup', 'install'];
114
- if (processKeywords.some(kw => lower.includes(kw)))
225
+ const processKeywords = ['deploy', 'migration', 'workflow', 'pipeline', 'backup', 'setup', 'install'];
226
+ if (processKeywords.some(has) || /(^|[^a-z0-9])ci\/cd([^a-z0-9]|$)/.test(folded))
115
227
  return 'process';
116
228
  // Domain keywords
117
229
  const domainKeywords = ['seo', 'marketing', 'trading', 'crypto', 'design', 'security', 'devops', 'data', 'analytics'];
118
- if (domainKeywords.some(kw => lower === kw))
230
+ if (domainKeywords.some(kw => folded === kw))
119
231
  return 'domain';
120
- // Protocol keywords
121
- const protocolKeywords = ['protocol', 'directive', 'rule', 'enforcement', 'behavioral'];
122
- if (protocolKeywords.some(kw => lower.includes(kw)))
232
+ // Protocol keywords. "protocolo" keeps working: a Spanish protocol neuron
233
+ // is injected at boot, and the substring rule always caught it.
234
+ const protocolKeywords = ['protocol', 'protocols', 'protocolo', 'protocolos', 'directive', 'directives', 'directiva', 'directivas',
235
+ 'rule', 'rules', 'enforcement', 'behavioral'];
236
+ if (protocolKeywords.some(has))
123
237
  return 'protocol';
124
238
  // Default to project
125
239
  return 'project';