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
@@ -42,6 +42,7 @@ exports.modPaths = modPaths;
42
42
  exports.pathListSeparator = pathListSeparator;
43
43
  exports.sha256 = sha256;
44
44
  exports.modFiles = modFiles;
45
+ exports.staleStaging = staleStaging;
45
46
  exports.readModState = readModState;
46
47
  exports.compareVersions = compareVersions;
47
48
  exports.installMod = installMod;
@@ -51,13 +52,17 @@ exports.formatModVerify = formatModVerify;
51
52
  exports.modSwitchedOff = modSwitchedOff;
52
53
  exports.takeModNotice = takeModNotice;
53
54
  exports.autoInstallMod = autoInstallMod;
55
+ exports.hookRefreshSwitchedOff = hookRefreshSwitchedOff;
54
56
  exports.startModOnBoot = startModOnBoot;
57
+ exports.takeHooksNotice = takeHooksNotice;
55
58
  exports.modNoticeOnBoot = modNoticeOnBoot;
56
59
  exports.resetModOnBoot = resetModOnBoot;
57
60
  const crypto_1 = require("crypto");
58
61
  const fs_1 = require("fs");
59
62
  const os_1 = require("os");
60
63
  const path_1 = __importDefault(require("path"));
64
+ const installio_js_1 = require("./installio.js");
65
+ const hookrefresh_js_1 = require("./hookrefresh.js");
61
66
  exports.MOD_NAME = 'crbro-pending';
62
67
  /** The local copy this mod grew out of: a folder in the list holding it is replaced. */
63
68
  exports.LEGACY_NAME = 'crbro-pendientes';
@@ -69,8 +74,9 @@ exports.PLUGIN_DIRS_VAR = 'CLAUDE_CODE_PLUGIN_DIRS';
69
74
  /** What the package ships but the install leaves out: tests and editor config. */
70
75
  const NOT_SHIPPED_TOP = new Set(['test', 'tsconfig.json', '.gitignore', '.npmignore', 'node_modules']);
71
76
  const NOT_SHIPPED_DIRS = new Set(['.claude-plugin/types']);
72
- function modPaths(home) {
73
- const claudeDir = path_1.default.join(home, '.claude');
77
+ /** Claude Code's folder is CLAUDE_CONFIG_DIR when set, as Claude Code itself reads it; ~/.claude otherwise. */
78
+ function modPaths(home, env = process.env) {
79
+ const claudeDir = (0, installio_js_1.claudeDir)(home, env);
74
80
  return {
75
81
  claudeDir,
76
82
  settingsPath: path_1.default.join(claudeDir, 'settings.json'),
@@ -212,7 +218,7 @@ function readSettings(settingsPath) {
212
218
  return { ok: true, settings, indent, eol, hash };
213
219
  }
214
220
  /** Thrown by writeAtomic when settings.json changed after it was read: the caller merges again. */
215
- const SETTINGS_CHANGED = 'ESETTINGSCHANGED';
221
+ const SETTINGS_CHANGED = installio_js_1.FILE_CHANGED;
216
222
  /** Errors a later start may well not meet (a file held open, an antivirus scan): never written down as a failure. */
217
223
  const TRANSIENT = new Set(['EBUSY', 'EPERM', 'EACCES', 'ENOTEMPTY', 'EEXIST', SETTINGS_CHANGED]);
218
224
  /**
@@ -226,43 +232,37 @@ const TRANSIENT = new Set(['EBUSY', 'EPERM', 'EACCES', 'ENOTEMPTY', 'EEXIST', SE
226
232
  * inherited ACL, and a hard link to settings.json is not kept.
227
233
  */
228
234
  function writeAtomic(settingsPath, settings, indent = 2, eol = '\n', expectHash) {
229
- let target = settingsPath;
230
- try {
231
- if ((0, fs_1.lstatSync)(settingsPath).isSymbolicLink())
232
- target = (0, fs_1.realpathSync)(settingsPath);
233
- }
234
- catch {
235
- // Not there yet: written as a plain file.
236
- }
237
- let mode;
238
- try {
239
- mode = (0, fs_1.statSync)(target).mode & 0o777;
240
- }
241
- catch {
242
- mode = undefined;
243
- }
244
- const tmp = `${target}.${process.pid}.tmp`;
235
+ // installio.writeGuarded: the same steps (symlink target, mode, line
236
+ // endings, hash checked again right before the rename), plus a timestamped
237
+ // copy of the file as it was, kept beside it (settings.json.crbro-bak-<stamp>).
238
+ (0, installio_js_1.writeGuarded)(settingsPath, (0, installio_js_1.jsonText)(settings, indent, eol), { expectHash });
239
+ }
240
+ function splitList(value, sep) {
241
+ return (value ?? '').split(sep).map(s => s.trim()).filter(Boolean);
242
+ }
243
+ const STAGING = /^crbro-pending\.(\d+)\.(tmp|old)$/;
244
+ /**
245
+ * A staging or set-aside folder another run left behind, safe to remove: its
246
+ * owner's pid is gone (or it is this process's own). A live pid is another
247
+ * install under way — install-mod by hand beside a boot — and its folder is
248
+ * left alone, or that install would fail half way on its rename.
249
+ */
250
+ function staleStaging(name, isAlive = pidAlive) {
251
+ const m = STAGING.exec(name);
252
+ if (!m)
253
+ return false;
254
+ const pid = Number(m[1]);
255
+ return pid === process.pid || !isAlive(pid);
256
+ }
257
+ function pidAlive(pid) {
245
258
  try {
246
- let text = JSON.stringify(settings, null, indent) + '\n';
247
- if (eol !== '\n')
248
- text = text.replace(/\n/g, eol);
249
- (0, fs_1.writeFileSync)(tmp, text, mode === undefined ? 'utf8' : { encoding: 'utf8', mode });
250
- if (expectHash !== undefined && settingsFingerprint(target) !== expectHash) {
251
- const changed = new Error(`${settingsPath} changed while it was being written.`);
252
- changed.code = SETTINGS_CHANGED;
253
- throw changed;
254
- }
255
- (0, fs_1.renameSync)(tmp, target);
259
+ process.kill(pid, 0);
260
+ return true;
256
261
  }
257
262
  catch (e) {
258
- (0, fs_1.rmSync)(tmp, { force: true });
259
- throw e;
263
+ return e.code === 'EPERM';
260
264
  }
261
265
  }
262
- function splitList(value, sep) {
263
- return (value ?? '').split(sep).map(s => s.trim()).filter(Boolean);
264
- }
265
- const STAGING = /^crbro-pending\.\d+\.(tmp|old)$/;
266
266
  function copyFailed(installedDir, e) {
267
267
  const err = new Error(`could not replace ${installedDir} (${e.message}).\n` +
268
268
  ' Close the sessions and terminals using that folder and run it again.');
@@ -280,7 +280,7 @@ function copyMod(packageDir, installedDir) {
280
280
  (0, fs_1.mkdirSync)(parent, { recursive: true });
281
281
  // A staging or set-aside folder left by a run that failed half way: its pid is gone.
282
282
  for (const name of (0, fs_1.readdirSync)(parent)) {
283
- if (STAGING.test(name))
283
+ if (staleStaging(name))
284
284
  (0, fs_1.rmSync)(path_1.default.join(parent, name), { recursive: true, force: true, maxRetries: 3 });
285
285
  }
286
286
  const staging = `${installedDir}.${process.pid}.tmp`;
@@ -326,7 +326,7 @@ async function copyModAsync(packageDir, installedDir) {
326
326
  const parent = path_1.default.dirname(installedDir);
327
327
  await fs_1.promises.mkdir(parent, { recursive: true });
328
328
  for (const name of await fs_1.promises.readdir(parent)) {
329
- if (STAGING.test(name))
329
+ if (staleStaging(name))
330
330
  await fs_1.promises.rm(path_1.default.join(parent, name), { recursive: true, force: true, maxRetries: 3 });
331
331
  }
332
332
  const staging = `${installedDir}.${process.pid}.tmp`;
@@ -1290,33 +1290,67 @@ async function autoInstallMod(opts) {
1290
1290
  }
1291
1291
  }
1292
1292
  let onBoot = null;
1293
+ /** The refresh of installed hook copies (hookrefresh.ts), started beside the mod's install, once per process. */
1294
+ let hooksOnBoot = null;
1295
+ let hooksResult = null;
1296
+ let hooksNoticeTaken = false;
1297
+ /** CRBRO_HOOK_REFRESH=0 (off, false, no) keeps installed hook copies as they are. */
1298
+ function hookRefreshSwitchedOff(env = process.env) {
1299
+ return exports.MOD_OFF_VALUES.has(String(env.CRBRO_HOOK_REFRESH ?? '').trim().toLowerCase());
1300
+ }
1293
1301
  /**
1294
1302
  * What crbro_boot calls first: starts the automatic install the first time in
1295
1303
  * this process and returns the function to call once the boot has its answer
1296
1304
  * ready. That function waits for the install until `budgetMs` after the start
1297
1305
  * and takes the notice waiting to be said, or null. Neither throws. A boot
1298
1306
  * that fails never calls it, so it never takes a notice it cannot hand over.
1307
+ *
1308
+ * It also starts, once per process, the refresh of the hook copies CRBRO
1309
+ * installed (hookrefresh.ts): unmodified copies of an older release are
1310
+ * replaced by this one's. That part does not depend on CRBRO_MOD (it never
1311
+ * adds anything, only keeps current what is there); CRBRO_HOOK_REFRESH=0
1312
+ * turns it off. Its notice is taken with takeHooksNotice().
1299
1313
  */
1300
1314
  function startModOnBoot(opts = {}) {
1301
1315
  try {
1302
1316
  const home = opts.home ?? (0, os_1.homedir)();
1303
1317
  const env = opts.env ?? process.env;
1304
- if (modSwitchedOff(env) || !(0, fs_1.existsSync)(modPaths(home).claudeDir))
1305
- return async () => null;
1306
- if (!onBoot) {
1307
- const packageDir = opts.packageDir ?? path_1.default.join(__dirname, '..', '..', 'mods', exports.MOD_NAME);
1308
- onBoot = autoInstallMod({ ...opts, home, env, packageDir })
1309
- .catch(e => ({ action: 'failed', reason: e.message }));
1318
+ if (!hooksOnBoot && !hookRefreshSwitchedOff(env)) {
1319
+ hooksOnBoot = new Promise(resolve => setImmediate(() => {
1320
+ try {
1321
+ resolve((0, hookrefresh_js_1.refreshHooks)({ home, env }));
1322
+ }
1323
+ catch {
1324
+ resolve([]);
1325
+ }
1326
+ })).then(copies => { hooksResult = copies; return copies; });
1310
1327
  }
1311
- const running = onBoot;
1328
+ const hooks = hooksOnBoot ?? Promise.resolve([]);
1312
1329
  let timer;
1313
1330
  const budget = new Promise(resolve => {
1314
1331
  timer = setTimeout(resolve, opts.budgetMs ?? exports.BOOT_BUDGET_MS);
1315
1332
  timer.unref?.();
1316
1333
  });
1334
+ if (modSwitchedOff(env) || !(0, fs_1.existsSync)(modPaths(home, env).claudeDir)) {
1335
+ return async () => {
1336
+ try {
1337
+ await Promise.race([hooks, budget]);
1338
+ }
1339
+ catch { /* never fails the boot */ }
1340
+ if (timer)
1341
+ clearTimeout(timer);
1342
+ return null;
1343
+ };
1344
+ }
1345
+ if (!onBoot) {
1346
+ const packageDir = opts.packageDir ?? path_1.default.join(__dirname, '..', '..', 'mods', exports.MOD_NAME);
1347
+ onBoot = autoInstallMod({ ...opts, home, env, packageDir })
1348
+ .catch(e => ({ action: 'failed', reason: e.message }));
1349
+ }
1350
+ const running = onBoot;
1317
1351
  return async () => {
1318
1352
  try {
1319
- await Promise.race([running, budget]);
1353
+ await Promise.race([Promise.all([running, hooks]), budget]);
1320
1354
  if (timer)
1321
1355
  clearTimeout(timer);
1322
1356
  return takeModNotice(home, opts.now);
@@ -1330,6 +1364,22 @@ function startModOnBoot(opts = {}) {
1330
1364
  return async () => null;
1331
1365
  }
1332
1366
  }
1367
+ /**
1368
+ * The boot's notice about installed hooks, once per process, if the refresh
1369
+ * has finished: copies left alone because somebody edited them, or a refresh
1370
+ * that failed. Each edited copy is told once (remembered in its manifest).
1371
+ */
1372
+ function takeHooksNotice() {
1373
+ if (hooksNoticeTaken || !hooksResult)
1374
+ return null;
1375
+ hooksNoticeTaken = true;
1376
+ try {
1377
+ return (0, hookrefresh_js_1.hooksNotice)(hooksResult);
1378
+ }
1379
+ catch {
1380
+ return null;
1381
+ }
1382
+ }
1333
1383
  /** startModOnBoot and its notice in one call. */
1334
1384
  async function modNoticeOnBoot(opts = {}) {
1335
1385
  return startModOnBoot(opts)();
@@ -1337,6 +1387,9 @@ async function modNoticeOnBoot(opts = {}) {
1337
1387
  /** Tests only: the next boot in this process behaves as the first boot of a new process. */
1338
1388
  function resetModOnBoot() {
1339
1389
  onBoot = null;
1390
+ hooksOnBoot = null;
1391
+ hooksResult = null;
1392
+ hooksNoticeTaken = false;
1340
1393
  handedOver.clear();
1341
1394
  }
1342
1395
  //# sourceMappingURL=modinstall.js.map
@@ -3,9 +3,11 @@
3
3
  // Active context, hot topics, and global map (clustering + bridges)
4
4
  Object.defineProperty(exports, "__esModule", { value: true });
5
5
  exports.Prefrontal = void 0;
6
+ exports.sortOpenItems = sortOpenItems;
6
7
  const fs_js_1 = require("../utils/fs.js");
7
8
  const hash_js_1 = require("../utils/hash.js");
8
9
  const secrets_js_1 = require("./secrets.js");
10
+ const heat_js_1 = require("./heat.js");
9
11
  /** How many resolved items to keep around as a reminder. */
10
12
  const RECENTLY_CLOSED_CAP = 15;
11
13
  /** Normalise a stored entry: v1 brains hold plain strings. */
@@ -22,6 +24,15 @@ function normalizeContext(ctx) {
22
24
  recently_closed: ctx?.recently_closed || [],
23
25
  };
24
26
  }
27
+ /**
28
+ * The one order open items are read in, whatever the scope: what is due
29
+ * first, then the newest. A cap applied after it keeps what matters now; the
30
+ * insertion order kept the oldest, so a boot without a project showed the
31
+ * twelve items nobody had touched in months.
32
+ */
33
+ function sortOpenItems(items) {
34
+ return [...items].sort((a, b) => (a.due || '9999').localeCompare(b.due || '9999') || String(b.added || '').localeCompare(String(a.added || '')));
35
+ }
25
36
  function projectView(ctx, project) {
26
37
  if (!project)
27
38
  return ctx;
@@ -29,8 +40,7 @@ function projectView(ctx, project) {
29
40
  return {
30
41
  ...ctx, active_topics: ctx.project_topics?.[project] || [],
31
42
  project_topics: ctx.project_topics?.[project] ? { [project]: ctx.project_topics[project] } : {},
32
- pending_tasks: ctx.pending_tasks.map(toTask).filter(visible)
33
- .sort((a, b) => (a.due || '9999').localeCompare(b.due || '9999') || b.added.localeCompare(a.added)),
43
+ pending_tasks: sortOpenItems(ctx.pending_tasks.map(toTask).filter(visible)),
34
44
  recently_closed: (ctx.recently_closed || []).filter(visible),
35
45
  };
36
46
  }
@@ -72,6 +82,7 @@ class Prefrontal {
72
82
  let resolved = [];
73
83
  let discarded = [];
74
84
  let redacted = [];
85
+ let added;
75
86
  const saved = await (0, fs_js_1.updateJSON)(this.brain.paths.activeContext(), current => {
76
87
  const ctx = normalizeContext(current);
77
88
  if (updates.clear) {
@@ -113,6 +124,7 @@ class Prefrontal {
113
124
  Object.assign(existing, metadata);
114
125
  else
115
126
  tasks.push({ id, text, added: (0, fs_js_1.now)(), status: 'open', ...metadata });
127
+ added = { ...(existing ?? tasks[tasks.length - 1]) };
116
128
  }
117
129
  // Id, exact text, or a case-insensitive substring of at least 8 chars in
118
130
  // either direction — the same predicate for resolving and discarding.
@@ -142,7 +154,8 @@ class Prefrontal {
142
154
  ctx.last_updated = (0, fs_js_1.now)();
143
155
  return ctx;
144
156
  });
145
- return { ...projectView(saved, updates.project), resolved, discarded, written: true, ...(redacted.length ? { redacted } : {}) };
157
+ return { ...projectView(saved, updates.project), resolved, discarded, written: true,
158
+ ...(redacted.length ? { redacted } : {}), ...(added ? { added } : {}) };
146
159
  }
147
160
  /**
148
161
  * Record which session was logged last. Nothing else in the context moves.
@@ -181,10 +194,20 @@ class Prefrontal {
181
194
  async getGlobalMap() {
182
195
  const ids = await (0, fs_js_1.listJSONFiles)(this.brain.paths.cortex);
183
196
  const neurons = [];
197
+ // Heat lives in the derived heat state since 2.11; a neuron file keeps
198
+ // the value it had when heat stopped being written into it.
199
+ const calor = (await (0, heat_js_1.readHeatState)(this.brain))?.neurons || {};
184
200
  for (const id of ids) {
185
- const neuron = await (0, fs_js_1.readJSON)(this.brain.paths.neuron(id));
186
- if (neuron)
187
- neurons.push(neuron);
201
+ // 2.11 (C1): an unreadable neuron is skipped, not the end of maintenance or the map.
202
+ let neuron = null;
203
+ try {
204
+ neuron = await (0, fs_js_1.readJSON)(this.brain.paths.neuron(id));
205
+ }
206
+ catch { /* corrupt: skipped */ }
207
+ if (!neuron)
208
+ continue;
209
+ const h = calor[neuron.id]?.heat;
210
+ neurons.push(typeof h === 'number' ? { ...neuron, heat: h } : neuron);
188
211
  }
189
212
  // Build clusters by domain
190
213
  const domainGroups = {};
@@ -0,0 +1,204 @@
1
+ "use strict";
2
+ // ─── Quarantine: the copy kept before anything is removed ────────
3
+ //
4
+ // Every destructive path (forget, forget entire, merge_into, compact, a day
5
+ // log forgotten) writes here first, so a removal can be undone. Until 2.11 it
6
+ // grew without limit and in clear: 824 files and 5.3 MB on the reference
7
+ // brain, a whole 1 MB neuron copied for every batch of a split, including the
8
+ // credentials somebody forgot on purpose. Now:
9
+ //
10
+ // - forgetting SOME entries keeps those entries and what restore needs to
11
+ // put them back (dates, retirements, provenance, checks), not the neuron;
12
+ // - copies older than CRBRO_QUARANTINE_DAYS (default 180; 0 keeps them
13
+ // forever) are pruned by consolidate and maintenance, which say how many;
14
+ // - with CRBRO_ENCRYPT_AT_REST=1 new copies are sealed (atrest.ts).
15
+ //
16
+ // File names: `<id>.<stamp>.json` (a whole neuron or log, the 2.x format a
17
+ // previous version can still restore), `<id>.<stamp>.entries.json` (some
18
+ // entries), each with `.enc` appended when sealed. The stamp sorts lexically,
19
+ // so the name alone orders the copies of one id.
20
+ var __importDefault = (this && this.__importDefault) || function (mod) {
21
+ return (mod && mod.__esModule) ? mod : { "default": mod };
22
+ };
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.DEFAULT_QUARANTINE_DAYS = void 0;
25
+ exports.quarantineDays = quarantineDays;
26
+ exports.stampNow = stampNow;
27
+ exports.writeQuarantine = writeQuarantine;
28
+ exports.readQuarantine = readQuarantine;
29
+ exports.listQuarantine = listQuarantine;
30
+ exports.pruneQuarantine = pruneQuarantine;
31
+ exports.quarantineStats = quarantineStats;
32
+ exports.entriesAsNeuron = entriesAsNeuron;
33
+ exports.isEntriesCopy = isEntriesCopy;
34
+ const node_fs_1 = require("node:fs");
35
+ const node_path_1 = __importDefault(require("node:path"));
36
+ const atrest_js_1 = require("./atrest.js");
37
+ const fs_js_1 = require("../utils/fs.js");
38
+ exports.DEFAULT_QUARANTINE_DAYS = 180;
39
+ /** Retention in days from the environment; 0 means forever. Bad values fall back to the default. */
40
+ function quarantineDays(env = process.env) {
41
+ const raw = (env['CRBRO_QUARANTINE_DAYS'] || '').trim();
42
+ if (!raw)
43
+ return exports.DEFAULT_QUARANTINE_DAYS;
44
+ const n = Number(raw);
45
+ return Number.isInteger(n) && n >= 0 ? n : exports.DEFAULT_QUARANTINE_DAYS;
46
+ }
47
+ function stampNow() {
48
+ return (0, fs_js_1.now)().replace(/[:.]/g, '-');
49
+ }
50
+ /** "2026-11-13T10-20-30-123Z" → ISO, or null. */
51
+ function stampToIso(stamp) {
52
+ const m = stamp.match(/^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/);
53
+ return m ? `${m[1]}T${m[2]}:${m[3]}:${m[4]}.${m[5]}Z` : null;
54
+ }
55
+ function parseName(file) {
56
+ const sealed = file.endsWith('.json.enc');
57
+ if (!sealed && !file.endsWith('.json'))
58
+ return null;
59
+ const base = file.slice(0, file.length - (sealed ? '.json.enc'.length : '.json'.length));
60
+ const entries = base.endsWith('.entries');
61
+ const core = entries ? base.slice(0, -'.entries'.length) : base;
62
+ const m = core.match(/^(.*)\.(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)$/);
63
+ if (!m)
64
+ return { base, id: core, stamp: '', kind: entries ? 'entries' : 'whole', sealed };
65
+ return { base, id: m[1], stamp: m[2], kind: entries ? 'entries' : 'whole', sealed };
66
+ }
67
+ /** Write one copy; sealed when the switch is on. Throws rather than write in clear without a key. */
68
+ async function writeQuarantine(dir, id, data, kind = 'whole') {
69
+ await node_fs_1.promises.mkdir(dir, { recursive: true });
70
+ const { data: bytes, sealed } = (0, atrest_js_1.maybeSeal)(Buffer.from(JSON.stringify(data, null, 2), 'utf8'));
71
+ const name = `${id}.${stampNow()}${kind === 'entries' ? '.entries' : ''}.json${sealed ? '.enc' : ''}`;
72
+ const target = node_path_1.default.join(dir, name);
73
+ const tmp = `${target}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`;
74
+ await node_fs_1.promises.writeFile(tmp, bytes);
75
+ try {
76
+ await node_fs_1.promises.rename(tmp, target);
77
+ }
78
+ catch (err) {
79
+ await node_fs_1.promises.unlink(tmp).catch(() => { });
80
+ throw err;
81
+ }
82
+ return target;
83
+ }
84
+ async function readQuarantine(file) {
85
+ let raw;
86
+ try {
87
+ raw = await node_fs_1.promises.readFile(file);
88
+ }
89
+ catch (err) {
90
+ if (err.code === 'ENOENT')
91
+ return null;
92
+ throw err;
93
+ }
94
+ return JSON.parse((0, fs_js_1.stripBOM)((0, atrest_js_1.unseal)(raw).toString('utf8')));
95
+ }
96
+ /** Copies in the folder, optionally of one id, newest first. */
97
+ async function listQuarantine(dir, id) {
98
+ let names;
99
+ try {
100
+ names = await node_fs_1.promises.readdir(dir);
101
+ }
102
+ catch {
103
+ return [];
104
+ }
105
+ const out = [];
106
+ for (const file of names) {
107
+ if (file.endsWith('.tmp'))
108
+ continue;
109
+ const p = parseName(file);
110
+ if (!p)
111
+ continue;
112
+ if (id !== undefined && p.id !== id)
113
+ continue;
114
+ let taken = p.stamp ? stampToIso(p.stamp) : null;
115
+ if (!taken) {
116
+ try {
117
+ taken = (await node_fs_1.promises.stat(node_path_1.default.join(dir, file))).mtime.toISOString();
118
+ }
119
+ catch {
120
+ continue;
121
+ }
122
+ }
123
+ out.push({ file, base: p.base, id: p.id, kind: p.kind, sealed: p.sealed, taken });
124
+ }
125
+ return out.sort((a, b) => (a.taken < b.taken ? 1 : a.taken > b.taken ? -1 : a.file < b.file ? 1 : -1));
126
+ }
127
+ /**
128
+ * Delete copies older than `days` (0 = keep forever). Returns what went, or
129
+ * would go with dryRun. Only files this module names are ever touched.
130
+ */
131
+ async function pruneQuarantine(dir, opts = {}) {
132
+ const days = opts.days ?? quarantineDays();
133
+ const res = { pruned: 0, bytes: 0, files: [], days };
134
+ if (days <= 0)
135
+ return res;
136
+ const limite = (opts.now ?? new Date()).getTime() - days * 86_400_000;
137
+ for (const c of await listQuarantine(dir)) {
138
+ if (Date.parse(c.taken) >= limite)
139
+ continue;
140
+ const f = node_path_1.default.join(dir, c.file);
141
+ try {
142
+ const st = await node_fs_1.promises.stat(f);
143
+ if (!opts.dryRun)
144
+ await node_fs_1.promises.unlink(f);
145
+ res.pruned++;
146
+ res.bytes += st.size;
147
+ res.files.push(c.file);
148
+ }
149
+ catch { /* gone meanwhile */ }
150
+ }
151
+ return res;
152
+ }
153
+ /** Files, bytes and the oldest copy: what status and doctor show. */
154
+ async function quarantineStats(dir) {
155
+ const all = await listQuarantine(dir);
156
+ let bytes = 0;
157
+ for (const c of all) {
158
+ try {
159
+ bytes += (await node_fs_1.promises.stat(node_path_1.default.join(dir, c.file))).size;
160
+ }
161
+ catch { /* gone */ }
162
+ }
163
+ return {
164
+ files: all.length,
165
+ bytes,
166
+ oldest: all.length ? all[all.length - 1].taken : null,
167
+ sealed: all.filter(c => c.sealed).length,
168
+ };
169
+ }
170
+ /**
171
+ * The neuron-shaped part of a partial copy, ready for unionNeuron: the
172
+ * entries with their sidecars and the identity of the neuron they left.
173
+ */
174
+ function entriesAsNeuron(q) {
175
+ return {
176
+ id: q.neuron_id,
177
+ name: q.name,
178
+ type: q.type,
179
+ domain: q.domain,
180
+ created: q.taken_at,
181
+ last_accessed: q.taken_at,
182
+ access_count: 0,
183
+ heat: 0,
184
+ summary: '',
185
+ facts: q.facts || [],
186
+ decisions: q.decisions || [],
187
+ patterns: q.patterns || [],
188
+ preferences: q.preferences || [],
189
+ connections: [],
190
+ tags: [],
191
+ errors: q.errors || [],
192
+ debts: q.debts || [],
193
+ entry_dates: q.entry_dates || {},
194
+ ...(q.entry_status ? { entry_status: q.entry_status } : {}),
195
+ ...(q.entry_source ? { entry_source: q.entry_source } : {}),
196
+ ...(q.entry_verified ? { entry_verified: q.entry_verified } : {}),
197
+ ...(q.entry_verification ? { entry_verification: q.entry_verification } : {}),
198
+ ...(q.map ? { map: q.map } : {}),
199
+ };
200
+ }
201
+ function isEntriesCopy(x) {
202
+ return !!x && typeof x === 'object' && x.kind === 'entries' && x.quarantine_format === 2;
203
+ }
204
+ //# sourceMappingURL=quarantine.js.map
@@ -45,7 +45,15 @@ const PATTERNS = [
45
45
  kind: 'auth token (hex)',
46
46
  re: /\bauth[ _-]?token\b[^\S\n]{0,3}(?:[:=]|es|is)?[^\S\n]{0,3}["'`]?([0-9a-fA-F]{30,34})\b/gi,
47
47
  },
48
- { kind: 'private key block', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g },
48
+ // The whole block, not just its header: the body IS the key. Through the
49
+ // END line when there is one (16 KB is more than any real key); without
50
+ // it, every base64 token that follows (16+ characters, or a padded tail);
51
+ // the first ordinary word stops it. Matching only the header used to
52
+ // leave the body on disk while the benchmark counted it as caught.
53
+ {
54
+ kind: 'private key block',
55
+ re: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----(?:[\s\S]{0,16384}?-----END [A-Z0-9 ]*PRIVATE KEY-----|(?:\s{1,4}(?:[A-Za-z0-9+/]{16,}={0,2}|[A-Za-z0-9+/]{2,}={1,2})(?!\S)){0,256})/g,
56
+ },
49
57
  { kind: 'JSON Web Token', re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g },
50
58
  // WordPress application password: six groups of four, anchored to the label.
51
59
  //
@@ -65,8 +73,14 @@ const PATTERNS = [
65
73
  // copula ("la contraseña es X", "the password is X") with no separator,
66
74
  // requiring the value to mix classes so plain words do not match.
67
75
  {
76
+ // Three shapes after the separator are prose, not a value, and are left
77
+ // alone (seen on a real brain): a capitalised product name ("Reset
78
+ // contraseña = Supabase Auth built-in"), an assignment ("contraseña:
79
+ // key_filename=~/.ssh/id_x") and a path ("~/.ssh/…", "/etc/…").
68
80
  kind: 'password',
69
- re: /\b(?:password|passwd|contrase[nñ]a)\s*(?:es|is)?\s*[:=]\s*["'`]?([^\s"'`,;]{8,})/gi,
81
+ // No /i flag: the capitalised-word check needs case. The label is
82
+ // spelled in the three casings people write.
83
+ re: /\b(?:[Pp]assword|PASSWORD|[Pp]asswd|PASSWD|[Cc]ontrase[nñ]a|CONTRASE[NÑ]A)\s*(?:es|is|ES|IS)?\s*[:=]\s*["'`]?(?![A-Z][a-z]+(?![^\s"'`,;]))(?![A-Za-z_]+=)(?!~?\/)([^\s"'`,;]{8,})/g,
70
84
  },
71
85
  {
72
86
  // "clave" is also the Spanish adjective "key/important": "Decisión clave:
@@ -89,6 +103,26 @@ const PATTERNS = [
89
103
  kind: 'password (prose)',
90
104
  re: /\b(?:password|passwd|contrase[nñ]a|clave)\b(?:\s+(?:de|del|of|for)\s+[^\s]{1,30})?\s+(?:es|is)\s+["'`]?((?=[^\s"'`,;]*[A-Za-z])(?=[^\s"'`,;]*[0-9])[^\s"'`,;]{8,})/gi,
91
105
  },
106
+ {
107
+ // The label with no separator and no copula, as people really jot it:
108
+ // "PASS Xy7#kk22w", "la contraseña Xy7kk22W (la del panel)". Without a
109
+ // separator the value carries the whole burden of proof, so it must mix
110
+ // three of lower case, upper case, digit and symbol — the rule that keeps
111
+ // "clave" the adjective out (2.9.1). Case-sensitive labels for the same
112
+ // reason. "pass" in lower case is an English verb ("pass userId42 to…"),
113
+ // so it gets its own rule below; "clave" stays out of this one entirely.
114
+ kind: 'password',
115
+ re: /\b(?:PASS|PASSWORD|PASSWD|[Pp]assword|[Pp]asswd|[Cc]ontrase[nñ]a|CONTRASE[NÑ]A)\b[^\S\n]+["'`]?((?:(?=[^\s"'`,;()]*[a-z])(?=[^\s"'`,;()]*[A-Z])(?=[^\s"'`,;()]*[0-9])|(?=[^\s"'`,;()]*[a-z])(?=[^\s"'`,;()]*[A-Z])(?=[^\s"'`,;()]*[!@#$%^&*+=?~])|(?=[^\s"'`,;()]*[a-z])(?=[^\s"'`,;()]*[0-9])(?=[^\s"'`,;()]*[!@#$%^&*+=?~])|(?=[^\s"'`,;()]*[A-Z])(?=[^\s"'`,;()]*[0-9])(?=[^\s"'`,;()]*[!@#$%^&*+=?~]))[^\s"'`,;()]{8,})/g,
116
+ },
117
+ {
118
+ // "pass" as a label: "user admin pass: Xy7kk22w", "pass Xy7#kk22w".
119
+ // With ":"/"=" the value needs a letter and a digit or symbol ("pass:
120
+ // completed" is a test report). Without a separator it needs a letter, a
121
+ // digit AND a symbol, and may not start with $ or - — that is what keeps
122
+ // "pass userId42 to the helper", "pass user_id2" and "pass $TOKEN2" out.
123
+ kind: 'password',
124
+ re: /\b(?:PASS|[Pp]ass)\b(?:[^\S\n]*[:=][^\S\n]*["'`]?((?=[^\s"'`,;()]*[A-Za-z])(?=[^\s"'`,;()]*[0-9!@#$%^&*+=?~])[^\s"'`,;()]{8,})|[^\S\n]+["'`]?(?![$-])((?=[^\s"'`,;()]*[A-Za-z])(?=[^\s"'`,;()]*[0-9])(?=[^\s"'`,;()]*[!@#$%^&*+=?~])[^\s"'`,;()]{8,}))/g,
125
+ },
92
126
  {
93
127
  // A credential dictated in two pieces to slip past filters: "the token
94
128
  // starts with ghp_ and continues with <body>". Needs a credential noun,
@@ -133,9 +167,11 @@ function findSecrets(text) {
133
167
  re.lastIndex = 0;
134
168
  let m;
135
169
  while ((m = re.exec(text)) !== null) {
136
- // For the labelled-password pattern, redact only the value, not the label.
137
- const value = m[1] !== undefined ? m[1] : m[0];
138
- const at = m[1] !== undefined ? m.index + m[0].indexOf(m[1]) : m.index;
170
+ // For the labelled patterns, redact only the value, not the label. A
171
+ // pattern with alternatives captures the value in whichever group matched.
172
+ const group = m.slice(1).find(g => g !== undefined);
173
+ const value = group !== undefined ? group : m[0];
174
+ const at = group !== undefined ? m.index + m[0].lastIndexOf(group) : m.index;
139
175
  hits.push({ kind, index: at, length: value.length });
140
176
  if (m[0].length === 0)
141
177
  re.lastIndex++;
@@ -103,19 +103,39 @@ const VERSIONED = [
103
103
  ];
104
104
  const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
105
105
  const VERSIONED_RE = new RegExp(`(?:^|[^a-z0-9.])(?:${VERSIONED.map(escape).join('|')})[\\s-]?v?\\d+(?:\\.\\d+)*(?![\\d.]*\\d)`, 'i');
106
+ /**
107
+ * X.Y.Z as a version, but not a thousands-separated number: in Spanish
108
+ * "1.200.000 visitas" is a count, and it made the fact volatile (2.11).
109
+ * Every group after the first exactly three digits = a number, not a version.
110
+ */
111
+ function semverLike(t) {
112
+ const re = /(?:^|[^\d.])(\d{1,3}(?:\.\d{1,3}){2,})(?:-[0-9a-z.]+)?(?![\d.]*\d)/g;
113
+ for (const m of t.matchAll(re)) {
114
+ const grupos = m[1].split('.');
115
+ if (grupos.length !== 3)
116
+ continue;
117
+ if (grupos.slice(1).every(g => g.length === 3))
118
+ continue; // 1.200.000
119
+ return true;
120
+ }
121
+ return false;
122
+ }
106
123
  const RULES = [
107
124
  {
108
125
  reason: 'version',
109
126
  test: (_raw, t) => /\bv\d+(?:\.\d+)+\b/.test(t) // v2.3
110
127
  || /\b(?:version|release)\s*:?\s*v?\d+(?:\.\d+)*/.test(t) // version 3.6, versión 5
111
- || /(?:^|[^\d.])\d{1,3}\.\d{1,3}\.\d{1,3}(?:-[0-9a-z.]+)?(?![\d.]*\d)/.test(t) // 2.8.0, 1.4.2-beta (not a dd.mm.yyyy day)
128
+ || semverLike(t) // 2.8.0, 1.4.2-beta (not a dd.mm.yyyy day, not 1.200.000)
112
129
  || VERSIONED_RE.test(t), // PostgreSQL 14, Node 22
113
130
  },
114
131
  {
115
132
  reason: 'price',
116
133
  test: (_raw, t) => /[€$£¥]\s?\d|\d(?:[.,]\d+)?\s?[€$£¥]/.test(t) // $49, 29 €
117
134
  || /\b\d+(?:[.,]\d+)?\s*(?:euros?|eur|usd|dollars?|dolares|libras|pounds?|gbp|mxn|pesos)\b/.test(t)
118
- || /\d[^.\n]{0,40}(?:\bal mes\b|\bpor mes\b|\/mes\b|\bper month\b|\ba month\b|\/month\b|\/mo\b|\bal ano\b|\bpor ano\b|\bper year\b|\/year\b|\/yr\b|\bmensuales?\b|\banuales?\b)/.test(t)
135
+ // A number right before the period ("29 al mes", "29/mes", "9.99 per month"). Not
136
+ // "3 informes mensuales" or "4 informes al mes" (2.11): a count of things is no
137
+ // price, and with money in the sentence the currency rules above already fire.
138
+ || /\d(?:[.,]\d+)?\s?(?:k\s?)?(?:al mes\b|por mes\b|\/mes\b|per month\b|a month\b|\/month\b|\/mo\b|al ano\b|por ano\b|per year\b|\/year\b|\/yr\b)/.test(t)
119
139
  || /\b(?:price|pricing|precio|precios|tarifa|tarifas|cuesta|cuestan|cost|costs|fee|cuota)\b[^.\n]{0,30}\d/.test(t),
120
140
  },
121
141
  {
@@ -145,7 +165,9 @@ const RULES = [
145
165
  test: (raw, t) => /\b[A-Z][A-Z0-9]*_[A-Z0-9_]+\s*=\s*\S+/.test(raw) // KEY_NAME=value
146
166
  || /(?:^|\s)--[a-z][\w-]*=\S+/.test(raw) // --max-old-space-size=4096
147
167
  || /\b[a-z][a-z0-9]*_[a-z0-9_]+\b\s*(?:=|:|de|a|en|to|at|is|es)?\s*\d/.test(t) // memory_limit de 512M
148
- || /\b(?:set to|configured (?:to|as|at)|configurad[oa]s? (?:a|en|como)|fijad[oa]s? (?:a|en)|establecid[oa]s? (?:a|en)|timeout|time-out|ttl|rate limit|limit|limite|max|maximo|maximum|minimo|minimum|retries|reintentos|threshold|umbral|batch size|pool size|flag)\b[^.\n]{0,25}?\d/.test(t),
168
+ || /\b(?:set to|configured (?:to|as|at)|configurad[oa]s? (?:a|en|como)|fijad[oa]s? (?:a|en)|establecid[oa]s? (?:a|en)|timeout|time-out|ttl|rate limit|limit|limite|maximo|maximum|minimo|minimum|retries|reintentos|threshold|umbral|batch size|pool size|flag)\b[^.\n]{0,25}?\d/.test(t)
169
+ // "max" only as written in lower case: "Max Planck nacio en 1858" is a name (2.11).
170
+ || /\b(?:max|MAX)\b[^.\n]{0,25}?\d/.test(raw),
149
171
  },
150
172
  ];
151
173
  /** Role words, as written (first letter either case, rest lower) or as acronyms. */