@animalabs/agent-framework 0.30.0 → 0.32.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 (189) hide show
  1. package/README.md +71 -8
  2. package/dist/src/agent.d.ts +29 -9
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +82 -17
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/mcp-server-core.d.ts.map +1 -1
  7. package/dist/src/api/mcp-server-core.js +10 -3
  8. package/dist/src/api/mcp-server-core.js.map +1 -1
  9. package/dist/src/code-execution/isolate/render.d.ts.map +1 -1
  10. package/dist/src/code-execution/isolate/render.js +7 -6
  11. package/dist/src/code-execution/isolate/render.js.map +1 -1
  12. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
  13. package/dist/src/code-execution/isolate/scripts.js +5 -4
  14. package/dist/src/code-execution/isolate/scripts.js.map +1 -1
  15. package/dist/src/framework.d.ts +138 -102
  16. package/dist/src/framework.d.ts.map +1 -1
  17. package/dist/src/framework.js +1133 -752
  18. package/dist/src/framework.js.map +1 -1
  19. package/dist/src/gate/event-gate.d.ts +24 -17
  20. package/dist/src/gate/event-gate.d.ts.map +1 -1
  21. package/dist/src/gate/event-gate.js +198 -144
  22. package/dist/src/gate/event-gate.js.map +1 -1
  23. package/dist/src/gate/recipe-reconcile.d.ts.map +1 -1
  24. package/dist/src/gate/recipe-reconcile.js +6 -5
  25. package/dist/src/gate/recipe-reconcile.js.map +1 -1
  26. package/dist/src/host-presentation.d.ts.map +1 -1
  27. package/dist/src/host-presentation.js +5 -7
  28. package/dist/src/host-presentation.js.map +1 -1
  29. package/dist/src/index.d.ts +1 -0
  30. package/dist/src/index.d.ts.map +1 -1
  31. package/dist/src/index.js +1 -0
  32. package/dist/src/index.js.map +1 -1
  33. package/dist/src/inference-bounds.d.ts +37 -0
  34. package/dist/src/inference-bounds.d.ts.map +1 -0
  35. package/dist/src/inference-bounds.js +84 -0
  36. package/dist/src/inference-bounds.js.map +1 -0
  37. package/dist/src/line-reader.d.ts +7 -1
  38. package/dist/src/line-reader.d.ts.map +1 -1
  39. package/dist/src/line-reader.js +17 -6
  40. package/dist/src/line-reader.js.map +1 -1
  41. package/dist/src/mcpl/errors.d.ts +1 -0
  42. package/dist/src/mcpl/errors.d.ts.map +1 -1
  43. package/dist/src/mcpl/errors.js +3 -0
  44. package/dist/src/mcpl/errors.js.map +1 -1
  45. package/dist/src/mcpl/inbound-source.d.ts +2 -2
  46. package/dist/src/mcpl/inbound-source.js +2 -2
  47. package/dist/src/mcpl/inference-router.d.ts +11 -1
  48. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  49. package/dist/src/mcpl/inference-router.js +65 -9
  50. package/dist/src/mcpl/inference-router.js.map +1 -1
  51. package/dist/src/mcpl/modern-connection.d.ts +10 -7
  52. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  53. package/dist/src/mcpl/modern-connection.js +85 -74
  54. package/dist/src/mcpl/modern-connection.js.map +1 -1
  55. package/dist/src/mcpl/protocol-family.d.ts +1 -1
  56. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  57. package/dist/src/mcpl/protocol-family.js +45 -3
  58. package/dist/src/mcpl/protocol-family.js.map +1 -1
  59. package/dist/src/mcpl/push-coalescer.d.ts +2 -7
  60. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  61. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  62. package/dist/src/mcpl/references.d.ts +1 -2
  63. package/dist/src/mcpl/references.d.ts.map +1 -1
  64. package/dist/src/mcpl/references.js +3 -3
  65. package/dist/src/mcpl/references.js.map +1 -1
  66. package/dist/src/mcpl/server-connection.d.ts +14 -0
  67. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  68. package/dist/src/mcpl/server-connection.js +26 -22
  69. package/dist/src/mcpl/server-connection.js.map +1 -1
  70. package/dist/src/mcpl/tool-signature.d.ts +1 -0
  71. package/dist/src/mcpl/tool-signature.d.ts.map +1 -1
  72. package/dist/src/mcpl/tool-signature.js +16 -11
  73. package/dist/src/mcpl/tool-signature.js.map +1 -1
  74. package/dist/src/mcpl/transport.d.ts +44 -21
  75. package/dist/src/mcpl/transport.d.ts.map +1 -1
  76. package/dist/src/mcpl/transport.js +283 -64
  77. package/dist/src/mcpl/transport.js.map +1 -1
  78. package/dist/src/mcpl/types.d.ts +17 -1
  79. package/dist/src/mcpl/types.d.ts.map +1 -1
  80. package/dist/src/mcpl/types.js.map +1 -1
  81. package/dist/src/module-registry.d.ts +32 -4
  82. package/dist/src/module-registry.d.ts.map +1 -1
  83. package/dist/src/module-registry.js +189 -25
  84. package/dist/src/module-registry.js.map +1 -1
  85. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
  86. package/dist/src/modules/discord/discord-js-client.js +24 -16
  87. package/dist/src/modules/discord/discord-js-client.js.map +1 -1
  88. package/dist/src/modules/sender-name.d.ts +5 -4
  89. package/dist/src/modules/sender-name.d.ts.map +1 -1
  90. package/dist/src/modules/sender-name.js +7 -5
  91. package/dist/src/modules/sender-name.js.map +1 -1
  92. package/dist/src/modules/workspace/disk-agreement.d.ts +16 -8
  93. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  94. package/dist/src/modules/workspace/disk-agreement.js +33 -12
  95. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  96. package/dist/src/modules/workspace/index.d.ts +6 -3
  97. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  98. package/dist/src/modules/workspace/index.js +61 -15
  99. package/dist/src/modules/workspace/index.js.map +1 -1
  100. package/dist/src/modules/workspace/observe.d.ts +9 -1
  101. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  102. package/dist/src/modules/workspace/observe.js +50 -10
  103. package/dist/src/modules/workspace/observe.js.map +1 -1
  104. package/dist/src/modules/workspace/reconcile.d.ts +11 -1
  105. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  106. package/dist/src/modules/workspace/reconcile.js +19 -8
  107. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  108. package/dist/src/modules/workspace/types.d.ts +8 -0
  109. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  110. package/dist/src/modules/workspace/types.js.map +1 -1
  111. package/dist/src/modules/workspace/watcher.d.ts +7 -0
  112. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  113. package/dist/src/modules/workspace/watcher.js +7 -5
  114. package/dist/src/modules/workspace/watcher.js.map +1 -1
  115. package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
  116. package/dist/src/recovery/discord-awareness-outbox.js +1 -9
  117. package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
  118. package/dist/src/supplied-text.d.ts +2 -0
  119. package/dist/src/supplied-text.d.ts.map +1 -1
  120. package/dist/src/supplied-text.js +16 -7
  121. package/dist/src/supplied-text.js.map +1 -1
  122. package/dist/src/tool-result-guard.js +1 -1
  123. package/dist/src/tool-result-guard.js.map +1 -1
  124. package/dist/src/tune-out/coordinator.d.ts +2 -20
  125. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  126. package/dist/src/tune-out/coordinator.js +175 -70
  127. package/dist/src/tune-out/coordinator.js.map +1 -1
  128. package/dist/src/tune-out/tools.d.ts +14 -2
  129. package/dist/src/tune-out/tools.d.ts.map +1 -1
  130. package/dist/src/tune-out/tools.js.map +1 -1
  131. package/dist/src/types/agent.d.ts +6 -12
  132. package/dist/src/types/agent.d.ts.map +1 -1
  133. package/dist/src/types/framework.d.ts +65 -24
  134. package/dist/src/types/framework.d.ts.map +1 -1
  135. package/dist/src/types/module.d.ts +42 -3
  136. package/dist/src/types/module.d.ts.map +1 -1
  137. package/dist/src/types/trace.d.ts +4 -3
  138. package/dist/src/types/trace.d.ts.map +1 -1
  139. package/package.json +3 -3
  140. package/src/agent.ts +90 -25
  141. package/src/api/mcp-server-core.ts +10 -3
  142. package/src/code-execution/isolate/render.ts +7 -6
  143. package/src/code-execution/isolate/scripts.ts +5 -4
  144. package/src/framework.ts +1163 -770
  145. package/src/gate/event-gate.ts +220 -145
  146. package/src/gate/recipe-reconcile.ts +7 -5
  147. package/src/host-presentation.ts +4 -7
  148. package/src/index.ts +1 -0
  149. package/src/inference-bounds.ts +111 -0
  150. package/src/line-reader.ts +22 -5
  151. package/src/mcpl/errors.ts +4 -0
  152. package/src/mcpl/inbound-source.ts +2 -2
  153. package/src/mcpl/inference-router.ts +67 -10
  154. package/src/mcpl/modern-connection.ts +83 -68
  155. package/src/mcpl/protocol-family.ts +37 -4
  156. package/src/mcpl/push-coalescer.ts +2 -7
  157. package/src/mcpl/references.ts +3 -4
  158. package/src/mcpl/server-connection.ts +30 -21
  159. package/src/mcpl/tool-signature.ts +18 -11
  160. package/src/mcpl/transport.ts +261 -60
  161. package/src/mcpl/types.ts +18 -1
  162. package/src/module-registry.ts +178 -25
  163. package/src/modules/discord/discord-js-client.ts +33 -29
  164. package/src/modules/sender-name.ts +8 -5
  165. package/src/modules/workspace/disk-agreement.ts +39 -15
  166. package/src/modules/workspace/index.ts +65 -14
  167. package/src/modules/workspace/observe.ts +51 -10
  168. package/src/modules/workspace/reconcile.ts +22 -8
  169. package/src/modules/workspace/types.ts +5 -0
  170. package/src/modules/workspace/watcher.ts +8 -6
  171. package/src/recovery/discord-awareness-outbox.ts +1 -11
  172. package/src/supplied-text.ts +17 -6
  173. package/src/tool-result-guard.ts +1 -1
  174. package/src/tune-out/coordinator.ts +187 -85
  175. package/src/tune-out/tools.ts +14 -2
  176. package/src/types/agent.ts +6 -12
  177. package/src/types/framework.ts +66 -24
  178. package/src/types/module.ts +42 -3
  179. package/src/types/trace.ts +4 -3
  180. package/dist/src/reasoning-loss-notice.d.ts +0 -23
  181. package/dist/src/reasoning-loss-notice.d.ts.map +0 -1
  182. package/dist/src/reasoning-loss-notice.js +0 -52
  183. package/dist/src/reasoning-loss-notice.js.map +0 -1
  184. package/dist/src/reasoning-loss.d.ts +0 -24
  185. package/dist/src/reasoning-loss.d.ts.map +0 -1
  186. package/dist/src/reasoning-loss.js +0 -102
  187. package/dist/src/reasoning-loss.js.map +0 -1
  188. package/src/reasoning-loss-notice.ts +0 -64
  189. package/src/reasoning-loss.ts +0 -120
@@ -11,7 +11,7 @@
11
11
  * inference tokens, not whether the agent should know about the event.
12
12
  */
13
13
  import { NOTICE_TAG } from '../notice-tags.js';
14
- import { cutToFit, suppliedValue } from '../supplied-text.js';
14
+ import { cutToFit, quotedJson, quotedValue, suppliedValue } from '../supplied-text.js';
15
15
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
16
16
  import { basename, dirname, join } from 'node:path';
17
17
  import { GateScript } from './gate-script.js';
@@ -284,10 +284,10 @@ function beforeWriting(err) {
284
284
  * "policies" that isn't a list and holds something. `runs` is what the gate
285
285
  * runs meanwhile (rulesRun).
286
286
  */
287
- function noListRefusal(noList, runs) {
287
+ function noListRefusal(noList, runs, path) {
288
288
  return `${noList}, so it has no list of rules a change could edit without writing over what it holds, and nothing `
289
- + `was saved. Meanwhile the gate runs ${runs}. Until gate.json is an object whose "policies" is a list holding its `
290
- + 'rules, a change is refused the same way.';
289
+ + `was saved. Meanwhile the gate runs ${runs}. Until gate.json, at ${path}, is an object whose "policies" is a list `
290
+ + 'holding its rules, a change is refused the same way.';
291
291
  }
292
292
  /**
293
293
  * A change's refusal where gate.json is there but couldn't be used as a file
@@ -295,15 +295,15 @@ function noListRefusal(noList, runs) {
295
295
  * written over it, so what it holds, often an operator's unfinished edit, stays
296
296
  * as it is. `runs` is what the gate runs meanwhile (rulesRun).
297
297
  */
298
- function unusableRefusal(unusable, runs) {
298
+ function unusableRefusal(unusable, runs, path) {
299
299
  if (!unusable.notJson) {
300
- return `gate.json couldn't be read (${unusable.error}), so a change can't edit it, and nothing was written over it. `
301
- + `Meanwhile the gate runs ${runs}. Whoever runs this host can make gate.json readable; until then, a change is `
302
- + 'refused the same way.';
300
+ return `gate.json, at ${path}, couldn't be read (${unusable.error}), so a change can't edit it, and nothing was written `
301
+ + `over it. Meanwhile the gate runs ${runs}. Whoever runs this host can make gate.json a file the gate can read; `
302
+ + 'until then, a change is refused the same way.';
303
303
  }
304
- return `gate.json isn't JSON the gate can read (${unusable.error}), so a change can't edit its rules without writing `
305
- + `over what it holds, and nothing was saved. Meanwhile the gate runs ${runs}. Until gate.json is JSON, a change is `
306
- + 'refused the same way.';
304
+ return `gate.json, at ${path}, isn't JSON the gate can read (${unusable.error}), so a change can't edit its rules without `
305
+ + `writing over what it holds, and nothing was saved. Meanwhile the gate runs ${runs}. Until gate.json is JSON, a change `
306
+ + 'is refused the same way.';
307
307
  }
308
308
  /**
309
309
  * A change's refusal at a start that didn't use gate.json, which has no rules
@@ -313,12 +313,17 @@ function unusableRefusal(unusable, runs) {
313
313
  * (notUsedClause, describeStart) say which rules keep gate.json out of use
314
314
  * and each fix.
315
315
  */
316
- function notUsedRefusal(runs) {
317
- return "gate.json isn't used at this start, which runs as one without it does, and a change would put gate.json's rules in "
316
+ function notUsedRefusal(runs, path) {
317
+ return `gate.json, at ${path}, isn't used at this start, which runs as one without it does, and a change would put gate.json's rules in `
318
318
  + `force in place of what that runs, so nothing was saved. Meanwhile the gate runs ${runs}. gate_status says what keeps `
319
319
  + 'gate.json out of use and what would put it in use, such as giving it rules under "policies", or fixing the starting '
320
320
  + 'rules, which whoever runs this host can do, for the next start; until then, a change is refused the same way.';
321
321
  }
322
+ function savedOnlyInForce(path, remedy) {
323
+ return remedy === undefined
324
+ ? ''
325
+ : ` gate.json, at ${path}, isn't in force, and a change is saved only if it brings it into force. ${remedy}`;
326
+ }
322
327
  /**
323
328
  * A value as a message quotes it: its JSON, cut short when long, never inside a
324
329
  * surrogate pair, since the message reaches the agent's context.
@@ -331,7 +336,8 @@ export function shownValue(value) {
331
336
  return String(value);
332
337
  let json;
333
338
  try {
334
- json = JSON.stringify(value) ?? String(value);
339
+ const stringified = JSON.stringify(value);
340
+ json = stringified === undefined ? String(value) : quotedJson(stringified);
335
341
  }
336
342
  catch {
337
343
  json = String(value);
@@ -346,9 +352,22 @@ function shownValues(values) {
346
352
  function joinedList(items, joiner = 'and') {
347
353
  return items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} ${joiner} ${items[items.length - 1]}`;
348
354
  }
355
+ const asGateNames = (path) => path;
356
+ function namedBy(names) {
357
+ if (!names)
358
+ return asGateNames;
359
+ return (path) => {
360
+ for (let end = path.length; end > 0; end = path.lastIndexOf('.', end - 1)) {
361
+ const head = path.slice(0, end);
362
+ if (Object.hasOwn(names, head))
363
+ return `${names[head]}${path.slice(end)}`;
364
+ }
365
+ return path;
366
+ };
367
+ }
349
368
  /** `"a"`, `"a" and "b"`, `"a", "b" and "c"` (or "or"). */
350
369
  function quotedList(names, joiner = 'and') {
351
- return joinedList(names.map((n) => `"${n}"`), joiner);
370
+ return joinedList(names.map((n) => quotedValue(n)), joiner);
352
371
  }
353
372
  /** 1st, 2nd, 3rd, 4th, 11th, 21st. */
354
373
  function ordinal(n) {
@@ -396,7 +415,7 @@ function fieldNames(obj, fields, held = true) {
396
415
  }
397
416
  /** `"tags" (did you mean "tagsAny", "tagsAll" or "tagsNone"?)` */
398
417
  function slipShown(slip) {
399
- return `"${slip.key}" (did you mean ${quotedList(slip.meant, 'or')}?)`;
418
+ return `${quotedValue(slip.key)} (did you mean ${quotedList(slip.meant, 'or')}?)`;
400
419
  }
401
420
  /**
402
421
  * An object's names that do nothing in its place, split into those that are
@@ -427,21 +446,21 @@ function misplacedShown(m) {
427
446
  * The levels of a gate.json the gate reads fields at, each as a message names
428
447
  * it, `file` naming the file itself.
429
448
  */
430
- function fieldLevels(file) {
449
+ function fieldLevels(file, named = asGateNames) {
431
450
  return [
432
451
  { name: file, fields: CONFIG_FIELDS },
433
452
  { name: 'a rule', fields: POLICY_FIELDS },
434
453
  { name: '"match"', fields: MATCH_FIELDS },
435
454
  { name: '"match.filter"', fields: FILTER_FIELDS },
436
455
  { name: '"behavior"', fields: BEHAVIOR_KINDS },
437
- { name: '"behavior.rate_limit"', fields: RATE_LIMIT_FIELDS },
438
- { name: '"behavior.passive_sample"', fields: PASSIVE_SAMPLE_FIELDS },
439
- { name: '"behavior.judge"', fields: JUDGE_FIELDS },
456
+ { name: `"${named('behavior.rate_limit')}"`, fields: RATE_LIMIT_FIELDS },
457
+ { name: `"${named('behavior.passive_sample')}"`, fields: PASSIVE_SAMPLE_FIELDS },
458
+ { name: `"${named('behavior.judge')}"`, fields: JUDGE_FIELDS },
440
459
  ];
441
460
  }
442
461
  /** Where a name is a field the gate reads, at a level other than the one whose fields are `here`. */
443
- function homesOf(file, here) {
444
- return (key) => fieldLevels(file)
462
+ function homesOf(file, here, named) {
463
+ return (key) => fieldLevels(file, named)
445
464
  .filter((level) => level.fields !== here && level.fields.includes(key))
446
465
  .map((level) => level.name);
447
466
  }
@@ -573,14 +592,15 @@ const DROPPED = 'so the rule is read without it';
573
592
  */
574
593
  function readFieldNames(r, level, obj, fields, readOf = () => SLIP_READ, misplacedReadOf = () => undefined) {
575
594
  const { slips, unread } = fieldNames(obj, fields);
576
- const { misplaced, rest } = placedElsewhere(unread, homesOf(r.file, fields));
577
- noteUnread(level ? `${r.where}: ${level}` : r.where, rest, fields, r.notes);
595
+ const { misplaced, rest } = placedElsewhere(unread, homesOf(r.file, fields, r.named));
596
+ const shown = level === null ? null : r.named(level);
597
+ noteUnread(shown ? `${r.where}: ${shown}` : r.where, rest, fields, r.notes);
578
598
  for (const slip of slips) {
579
- r.problems.push({ text: `${level ?? 'it'} has ${slipShown(slip)}, which the gate doesn't read`, read: readOf(slip) });
599
+ r.problems.push({ text: `${shown ?? 'it'} has ${slipShown(slip)}, which the gate doesn't read`, read: readOf(slip) });
580
600
  }
581
601
  for (const m of misplaced) {
582
602
  r.problems.push({
583
- text: `${level ?? 'it'} has ${misplacedShown(m)}, which the gate doesn't read here`,
603
+ text: `${shown ?? 'it'} has ${misplacedShown(m)}, which the gate doesn't read here`,
584
604
  read: misplacedReadOf(m) ?? (m.keys.length === 1 ? SLIP_READ : 'so they do nothing'),
585
605
  });
586
606
  }
@@ -593,7 +613,7 @@ function droppedEntries(count) {
593
613
  * A rule, read as written, or past each problem it has. `unnamed` names a rule
594
614
  * without a name in messages, and `file` the file it's in.
595
615
  */
596
- function readRule(raw, unnamed, file = 'gate.json') {
616
+ function readRule(raw, unnamed, file = 'gate.json', named = asGateNames) {
597
617
  if (!isPlainObject(raw)) {
598
618
  return {
599
619
  where: unnamed,
@@ -603,7 +623,7 @@ function readRule(raw, unnamed, file = 'gate.json') {
603
623
  };
604
624
  }
605
625
  const name = nameOf(raw);
606
- const r = { where: name ? `Policy "${name}"` : unnamed, file, problems: [], notes: [] };
626
+ const r = { where: name ? `Rule ${quotedValue(name)}` : unnamed, file, named, problems: [], notes: [] };
607
627
  const obj = withoutEmpty(r.where, raw, r.notes, RULE_LEFT_OUT);
608
628
  // Field names first, so a slip of one is named before what its absence does.
609
629
  readFieldNames(r, null, obj, POLICY_FIELDS, (slip) => {
@@ -629,7 +649,7 @@ function readRule(raw, unnamed, file = 'gate.json') {
629
649
  const { behavior, kind } = readBehavior(r, obj.behavior);
630
650
  const match = readMatch(r, obj.match);
631
651
  const resets = readResets(r, obj.resets);
632
- const passthrough = readPassthrough(r, obj.passthrough, behavior, kind);
652
+ const passthrough = readPassthrough(r, obj.passthrough, kind);
633
653
  const reading = {
634
654
  where: r.where, problems: r.problems, notes: r.notes, quiet: kind === 'skip' || kind === 'defer' || r.noTokens === true,
635
655
  };
@@ -685,7 +705,7 @@ function readBehavior(r, value) {
685
705
  }
686
706
  const behavior = READ_KIND[kind](r, b[kind]);
687
707
  for (const other of kinds.slice(1)) {
688
- const unread = { where: r.where, file: r.file, problems: [], notes: [] };
708
+ const unread = { where: r.where, file: r.file, named: r.named, problems: [], notes: [] };
689
709
  READ_KIND[other](unread, b[other]);
690
710
  for (const p of unread.problems)
691
711
  r.problems.push({ text: p.text, read: `which goes unread with the rest of "${other}"` });
@@ -701,18 +721,18 @@ function readBehavior(r, value) {
701
721
  */
702
722
  function readDebounce(r, ms) {
703
723
  if (typeof ms !== 'number' || !Number.isFinite(ms)) {
704
- r.problems.push({ text: `behavior.debounce is ${shownValue(ms)}, where a number of milliseconds is needed, such as 60000` });
724
+ r.problems.push({ text: `${r.named('behavior.debounce')} is ${shownValue(ms)}, where a number of milliseconds is needed, such as 60000` });
705
725
  return undefined;
706
726
  }
707
727
  if (ms < MIN_DEBOUNCE_MS) {
708
728
  r.problems.push({
709
- text: `behavior.debounce is ${ms} ms, under the ${MIN_DEBOUNCE_MS} ms minimum`, read: `so it's read as ${MIN_DEBOUNCE_MS} ms`,
729
+ text: `${r.named('behavior.debounce')} is ${ms} ms, under the ${MIN_DEBOUNCE_MS} ms minimum`, read: `so it's read as ${MIN_DEBOUNCE_MS} ms`,
710
730
  });
711
731
  return { debounce: MIN_DEBOUNCE_MS };
712
732
  }
713
733
  if (ms > MAX_DEBOUNCE_MS) {
714
734
  r.problems.push({
715
- text: `behavior.debounce is ${ms} ms, over the ${MAX_DEBOUNCE_MS} ms maximum`, read: `so it's read as ${MAX_DEBOUNCE_MS} ms`,
735
+ text: `${r.named('behavior.debounce')} is ${ms} ms, over the ${MAX_DEBOUNCE_MS} ms maximum`, read: `so it's read as ${MAX_DEBOUNCE_MS} ms`,
716
736
  });
717
737
  return { debounce: MAX_DEBOUNCE_MS };
718
738
  }
@@ -734,11 +754,11 @@ function readDebounce(r, ms) {
734
754
  function readRateLimit(r, value) {
735
755
  if (!isPlainObject(value)) {
736
756
  r.problems.push({
737
- text: `behavior.rate_limit is ${shownValue(value)}, where an object is needed, such as {"tokens": 3, "refillIntervalMs": 60000}`,
757
+ text: `${r.named('behavior.rate_limit')} is ${shownValue(value)}, where an object is needed, such as {"tokens": 3, "refillIntervalMs": 60000}`,
738
758
  });
739
759
  return undefined;
740
760
  }
741
- const conf = withoutEmpty(`${r.where}: behavior.rate_limit`, value, r.notes, { keyBy: 'every event the rule matches shares one bucket' });
761
+ const conf = withoutEmpty(`${r.where}: ${r.named('behavior.rate_limit')}`, value, r.notes, { keyBy: 'every event the rule matches shares one bucket' });
742
762
  readFieldNames(r, 'behavior.rate_limit', conf, RATE_LIMIT_FIELDS);
743
763
  const tokens = conf.tokens;
744
764
  const refill = conf.refillIntervalMs;
@@ -746,17 +766,17 @@ function readRateLimit(r, value) {
746
766
  if (typeof tokens === 'number' && !(tokens > 0))
747
767
  r.noTokens = true;
748
768
  if (!tokensUsable) {
749
- r.problems.push({ text: `behavior.rate_limit.tokens is ${shownValue(tokens)}, where a positive number is needed` });
769
+ r.problems.push({ text: `${r.named('behavior.rate_limit.tokens')} is ${shownValue(tokens)}, where a positive number is needed` });
750
770
  }
751
771
  const refillUsable = typeof refill === 'number' && Number.isFinite(refill);
752
772
  if (!refillUsable) {
753
773
  r.problems.push({
754
- text: `behavior.rate_limit.refillIntervalMs is ${shownValue(refill)}, where a positive number of milliseconds is needed`,
774
+ text: `${r.named('behavior.rate_limit.refillIntervalMs')} is ${shownValue(refill)}, where a positive number of milliseconds is needed`,
755
775
  });
756
776
  }
757
777
  else if (refill <= 0) {
758
778
  r.problems.push({
759
- text: `behavior.rate_limit.refillIntervalMs is ${refill}, where a positive number of milliseconds is needed`,
779
+ text: `${r.named('behavior.rate_limit.refillIntervalMs')} is ${refill}, where a positive number of milliseconds is needed`,
760
780
  read: refill === 0
761
781
  ? 'so its tokens refill at once, and it limits nothing it matches'
762
782
  : "so its tokens never refill, and once they're spent it wakes the agent on nothing it matches",
@@ -777,15 +797,15 @@ function readRateLimit(r, value) {
777
797
  */
778
798
  function readPassiveSample(r, value) {
779
799
  if (!isPlainObject(value)) {
780
- r.problems.push({ text: `behavior.passive_sample is ${shownValue(value)}, where an object is needed, such as {"every": 10}` });
800
+ r.problems.push({ text: `${r.named('behavior.passive_sample')} is ${shownValue(value)}, where an object is needed, such as {"every": 10}` });
781
801
  return undefined;
782
802
  }
783
- const conf = withoutEmpty(`${r.where}: behavior.passive_sample`, value, r.notes, { keyBy: 'every event the rule matches shares one count' });
803
+ const conf = withoutEmpty(`${r.where}: ${r.named('behavior.passive_sample')}`, value, r.notes, { keyBy: 'every event the rule matches shares one count' });
784
804
  readFieldNames(r, 'behavior.passive_sample', conf, PASSIVE_SAMPLE_FIELDS);
785
805
  const given = conf.every;
786
806
  let every;
787
807
  if (typeof given !== 'number' || !Number.isFinite(given)) {
788
- r.problems.push({ text: `behavior.passive_sample.every is ${shownValue(given)}, where a whole number of 1 or more is needed` });
808
+ r.problems.push({ text: `${r.named('behavior.passive_sample.every')} is ${shownValue(given)}, where a whole number of 1 or more is needed` });
789
809
  }
790
810
  else if (Number.isInteger(given) && given > 0) {
791
811
  every = given;
@@ -793,7 +813,7 @@ function readPassiveSample(r, value) {
793
813
  else {
794
814
  every = given <= 0 ? 1 : Math.ceil(given);
795
815
  r.problems.push({
796
- text: `behavior.passive_sample.every is ${given}, ${given <= 0 ? 'under 1' : 'not a whole number'}`,
816
+ text: `${r.named('behavior.passive_sample.every')} is ${given}, ${given <= 0 ? 'under 1' : 'not a whole number'}`,
797
817
  read: `so it's read as ${every}`,
798
818
  });
799
819
  }
@@ -817,11 +837,11 @@ function readPassiveSample(r, value) {
817
837
  function readJudge(r, value) {
818
838
  if (!isPlainObject(value)) {
819
839
  r.problems.push({
820
- text: `behavior.judge is ${shownValue(value)}, where an object is needed, such as {"debounceMs": 30000, "instructions": "..."}`,
840
+ text: `${r.named('behavior.judge')} is ${shownValue(value)}, where an object is needed, such as {"debounceMs": 30000, "instructions": "..."}`,
821
841
  });
822
842
  return undefined;
823
843
  }
824
- const conf = withoutEmpty(`${r.where}: behavior.judge`, value, r.notes, {
844
+ const conf = withoutEmpty(`${r.where}: ${r.named('behavior.judge')}`, value, r.notes, {
825
845
  maxWaitMs: 'a batch is judged only after its quiet period',
826
846
  instructions: 'the judge decides without the agent\'s word',
827
847
  });
@@ -830,18 +850,18 @@ function readJudge(r, value) {
830
850
  const ms = conf.debounceMs;
831
851
  let debounceMs;
832
852
  if (typeof ms !== 'number' || !Number.isFinite(ms)) {
833
- r.problems.push({ text: `behavior.judge.debounceMs is ${shownValue(ms)}, where a number of milliseconds is needed, such as 30000` });
853
+ r.problems.push({ text: `${r.named('behavior.judge.debounceMs')} is ${shownValue(ms)}, where a number of milliseconds is needed, such as 30000` });
834
854
  ok = false;
835
855
  }
836
856
  else if (ms < MIN_DEBOUNCE_MS) {
837
857
  r.problems.push({
838
- text: `behavior.judge.debounceMs is ${ms} ms, under the ${MIN_DEBOUNCE_MS} ms minimum`, read: `so it's read as ${MIN_DEBOUNCE_MS} ms`,
858
+ text: `${r.named('behavior.judge.debounceMs')} is ${ms} ms, under the ${MIN_DEBOUNCE_MS} ms minimum`, read: `so it's read as ${MIN_DEBOUNCE_MS} ms`,
839
859
  });
840
860
  debounceMs = MIN_DEBOUNCE_MS;
841
861
  }
842
862
  else if (ms > MAX_DEBOUNCE_MS) {
843
863
  r.problems.push({
844
- text: `behavior.judge.debounceMs is ${ms} ms, over the ${MAX_DEBOUNCE_MS} ms maximum`, read: `so it's read as ${MAX_DEBOUNCE_MS} ms`,
864
+ text: `${r.named('behavior.judge.debounceMs')} is ${ms} ms, over the ${MAX_DEBOUNCE_MS} ms maximum`, read: `so it's read as ${MAX_DEBOUNCE_MS} ms`,
845
865
  });
846
866
  debounceMs = MAX_DEBOUNCE_MS;
847
867
  }
@@ -852,21 +872,21 @@ function readJudge(r, value) {
852
872
  const wait = conf.maxWaitMs;
853
873
  if (wait !== undefined) {
854
874
  if (typeof wait !== 'number' || !Number.isFinite(wait)) {
855
- r.problems.push({ text: `behavior.judge.maxWaitMs is ${shownValue(wait)}, where a number of milliseconds is needed, such as 120000` });
875
+ r.problems.push({ text: `${r.named('behavior.judge.maxWaitMs')} is ${shownValue(wait)}, where a number of milliseconds is needed, such as 120000` });
856
876
  ok = false;
857
877
  }
858
878
  else {
859
879
  const floor = debounceMs ?? MIN_DEBOUNCE_MS;
860
880
  if (wait < floor) {
861
881
  r.problems.push({
862
- text: `behavior.judge.maxWaitMs is ${wait} ms, under the rule's ${floor} ms quiet period`,
882
+ text: `${r.named('behavior.judge.maxWaitMs')} is ${wait} ms, under the rule's ${floor} ms quiet period`,
863
883
  read: `so it's read as ${floor} ms`,
864
884
  });
865
885
  maxWaitMs = floor;
866
886
  }
867
887
  else if (wait > MAX_JUDGE_WAIT_MS) {
868
888
  r.problems.push({
869
- text: `behavior.judge.maxWaitMs is ${wait} ms, over the ${MAX_JUDGE_WAIT_MS} ms maximum`,
889
+ text: `${r.named('behavior.judge.maxWaitMs')} is ${wait} ms, over the ${MAX_JUDGE_WAIT_MS} ms maximum`,
870
890
  read: `so it's read as ${MAX_JUDGE_WAIT_MS} ms`,
871
891
  });
872
892
  maxWaitMs = MAX_JUDGE_WAIT_MS;
@@ -880,12 +900,12 @@ function readJudge(r, value) {
880
900
  let instructions;
881
901
  if (said !== undefined) {
882
902
  if (typeof said !== 'string') {
883
- r.problems.push({ text: `behavior.judge.instructions is ${shownValue(said)}, where text is needed` });
903
+ r.problems.push({ text: `${r.named('behavior.judge.instructions')} is ${shownValue(said)}, where text is needed` });
884
904
  ok = false;
885
905
  }
886
906
  else if (said.length > MAX_JUDGE_INSTRUCTIONS) {
887
907
  r.problems.push({
888
- text: `behavior.judge.instructions is ${said.length} characters long, over the ${MAX_JUDGE_INSTRUCTIONS}-character maximum`,
908
+ text: `${r.named('behavior.judge.instructions')} is ${said.length} characters long, over the ${MAX_JUDGE_INSTRUCTIONS}-character maximum`,
889
909
  });
890
910
  ok = false;
891
911
  }
@@ -916,7 +936,7 @@ function readKeyBy(r, kind, value) {
916
936
  if (typeof value === 'string')
917
937
  return value;
918
938
  r.problems.push({
919
- text: `behavior.${kind}.keyBy is ${shownValue(value)}, where a field name is needed, such as "channelId"`,
939
+ text: `${r.named(`behavior.${kind}.keyBy`)} is ${shownValue(value)}, where a field name is needed, such as "channelId"`,
920
940
  read: `so it does nothing, and every event the rule matches shares one ${kind === 'rate_limit' ? 'bucket' : 'count'}`,
921
941
  });
922
942
  return undefined;
@@ -1040,7 +1060,10 @@ function readMatch(r, value) {
1040
1060
  match.filter = { type, pattern: f.pattern };
1041
1061
  }
1042
1062
  catch (e) {
1043
- r.problems.push({ text: `match.filter.pattern isn't a regular expression the gate can read (${errorText(e)})` });
1063
+ r.problems.push({
1064
+ text: `match.filter.pattern isn't a regular expression the gate can read (${quotedValue(errorText(e))}), so fix it, `
1065
+ + 'or give match.filter.type "text" to look for the pattern as plain text',
1066
+ });
1044
1067
  }
1045
1068
  }
1046
1069
  }
@@ -1081,7 +1104,7 @@ function readResets(r, value) {
1081
1104
  * always, defer, skip or a debounce it would be a no-op at best and a
1082
1105
  * misleading promise at worst, so it's refused rather than ignored.
1083
1106
  */
1084
- function readPassthrough(r, value, behavior, kind) {
1107
+ function readPassthrough(r, value, kind) {
1085
1108
  if (value === undefined || value === false)
1086
1109
  return false;
1087
1110
  if (value !== true) {
@@ -1090,9 +1113,10 @@ function readPassthrough(r, value, behavior, kind) {
1090
1113
  }
1091
1114
  if (kind === undefined || kind === 'rate_limit' || kind === 'passive_sample')
1092
1115
  return true;
1116
+ const given = kind === 'always' || kind === 'defer' || kind === 'skip' ? `behavior "${kind}"` : r.named(`behavior.${kind}`);
1093
1117
  r.problems.push({
1094
- text: `passthrough is only valid with rate_limit or passive_sample behaviors — for "${behavior ? formatBehavior(behavior) : kind}" `
1095
- + 'there is no non-firing match to fall through on',
1118
+ text: `passthrough is true, and only a ${r.named('behavior.rate_limit')} or ${r.named('behavior.passive_sample')} rule passes on `
1119
+ + `an event it matches without waking on it; with ${given}, leave passthrough out`,
1096
1120
  });
1097
1121
  return false;
1098
1122
  }
@@ -1223,7 +1247,7 @@ function readConfig(raw, file, start) {
1223
1247
  + `[{"name": "my-rule", "behavior": "always"}], and ${noRules}.`);
1224
1248
  }
1225
1249
  const rules = policies.map((rule, i) => {
1226
- const reading = readRule(rule, `The ${ordinal(i + 1)} policy`, file);
1250
+ const reading = readRule(rule, `The ${ordinal(i + 1)} rule`, file);
1227
1251
  return { raw: rule, reading, outcome: ruleOutcome(reading) };
1228
1252
  });
1229
1253
  if (defaultBehavior !== 'always') {
@@ -1294,7 +1318,7 @@ function readLastRecipe(path) {
1294
1318
  const rules = read.rules.map((rule) => rule.raw);
1295
1319
  const twice = duplicateName(rules);
1296
1320
  if (twice !== undefined)
1297
- return { recipe: null, problem: `it names the rule "${twice}" twice` };
1321
+ return { recipe: null, problem: `it names the rule ${quotedValue(twice)} twice` };
1298
1322
  return { recipe: { rules, default: isPlainObject(raw) ? raw.default : undefined } };
1299
1323
  }
1300
1324
  /**
@@ -1303,7 +1327,7 @@ function readLastRecipe(path) {
1303
1327
  * wrote as read agrees with the same rule written more briefly.
1304
1328
  */
1305
1329
  function ruleMeaning(rule) {
1306
- const reading = readRule(rule, 'A policy');
1330
+ const reading = readRule(rule, 'A rule');
1307
1331
  return reading.policy ? `read ${canonical(reading.policy)}` : `written ${canonical(rule)}`;
1308
1332
  }
1309
1333
  /**
@@ -1377,7 +1401,7 @@ function notUsedClause(write, starting) {
1377
1401
  */
1378
1402
  function outOfForce(write) {
1379
1403
  const nameless = write.nameless ?? 0;
1380
- const left = [...(write.leftOut ?? []).map((name) => `"${name}"`), ...(nameless === 0 ? [] : [nameless === 1 ? 'one with no name' : `${nameless} with no name`])];
1404
+ const left = [...(write.leftOut ?? []).map((name) => quotedValue(name)), ...(nameless === 0 ? [] : [nameless === 1 ? 'one with no name' : `${nameless} with no name`])];
1381
1405
  const held = write.heldOut ?? [];
1382
1406
  const said = [];
1383
1407
  const fix = [];
@@ -1605,14 +1629,14 @@ export function findShadowedPolicies(policies, eventTypesFrom) {
1605
1629
  * three it is (the rule never applies; it misses some of its events; it
1606
1630
  * may not be meant for those events at all) and what to do. */
1607
1631
  export function formatShadowWarning(w) {
1608
- const later = `"${w.later}"`;
1609
- const earlier = `"${w.earlier}"`;
1610
- const move = `move ${later} above ${earlier} (wake_add_rule with the whole rule and insertBefore: "${w.earlier}")`;
1632
+ const later = quotedValue(w.later);
1633
+ const earlier = quotedValue(w.earlier);
1634
+ const move = `move ${later} above ${earlier} (wake_add_rule with the whole rule and insertBefore: ${quotedValue(w.earlier)})`;
1611
1635
  if (w.whole) {
1612
1636
  return `${later} never applies: ${earlier} comes first and matches every event ${later} would. `
1613
1637
  + `If ${later} should apply, ${move}, or narrow ${earlier}.`;
1614
1638
  }
1615
- const types = w.eventTypes === 'all' ? 'all' : w.eventTypes.join(', ');
1639
+ const types = w.eventTypes === 'all' ? 'all' : w.eventTypes.map((type) => quotedValue(type).slice(1, -1)).join(', ');
1616
1640
  if (w.known) {
1617
1641
  return `${earlier} comes first and takes ${later}'s ${types} events, so ${later} applies only to its others. `
1618
1642
  + `If it should apply to those too, ${move}.`;
@@ -1644,20 +1668,23 @@ function afterChange(name, callback) {
1644
1668
  }
1645
1669
  }
1646
1670
  /** The privileged-users file's error when some entries of its list aren't user ids. */
1647
- function skippedPrivilegedIds(skipped, loaded) {
1671
+ function skippedPrivilegedIds(skipped, loaded, path) {
1648
1672
  const one = skipped.length === 1;
1649
1673
  const shown = skipped.slice(0, 5).map((x) => {
1650
1674
  if (typeof x === 'number' && Number.isInteger(x)) {
1651
1675
  return `${x} (a number this large can't be read exactly: write the id as a string, in quotes)`;
1652
1676
  }
1653
- const json = JSON.stringify(x) ?? String(x);
1677
+ const json = quotedValue(x);
1654
1678
  return json.length > 40 ? `${safeSlice(json, 0, 39)}…` : json;
1655
1679
  });
1656
1680
  const more = skipped.length > shown.length ? `, and ${skipped.length - shown.length} more` : '';
1657
- const rest = loaded === 0 ? 'no id loaded, so no one is privileged' : `${loaded} ${loaded === 1 ? 'id' : 'ids'} did load`;
1658
- return `${skipped.length} ${one ? 'entry' : 'entries'} in the privileged-users file ${one ? "isn't a user id" : "aren't user ids"}, `
1681
+ const rest = loaded === 0
1682
+ ? 'no id loaded, so no one is privileged'
1683
+ : `the ${loaded === 1 ? 'one id that did load is' : `${loaded} ids that did load are`} privileged`;
1684
+ return `${skipped.length} ${one ? 'entry' : 'entries'} in the privileged-users file, at ${path}, ${one ? "isn't a user id" : "aren't user ids"}, `
1659
1685
  + `so ${one ? 'it was' : 'they were'} skipped: ${shown.join(', ')}${more}. A user id is a non-empty string, or a safe integer `
1660
- + `(a whole number within 2^53 - 1 of zero); ${rest}.`;
1686
+ + `(a whole number within 2^53 - 1 of zero); ${rest}. Once ${one ? 'that entry is a user id' : 'those entries are user ids'}, the gate `
1687
+ + 'reads the file again at its next check for changes.';
1661
1688
  }
1662
1689
  // ---------------------------------------------------------------------------
1663
1690
  // EventGate
@@ -1687,6 +1714,7 @@ export class EventGate {
1687
1714
  /** Whether gate.json's error is one that keeps what it holds out of force
1688
1715
  * (configUnreadable), so gate_status says what's in force instead. */
1689
1716
  configUnused = false;
1717
+ configFix = null;
1690
1718
  privilegedError = null;
1691
1719
  /** What the start did with the host's starting rules that it couldn't use
1692
1720
  * or add (describeStart), kept apart from gate.json's own error, until the
@@ -1759,8 +1787,8 @@ export class EventGate {
1759
1787
  sleepSuppressed = 0;
1760
1788
  sleepTimer = null;
1761
1789
  sleepAgent;
1762
- // From dispose() to the next resumeSleep(): no sleep or self-wake timer is
1763
- // armed, though a turn a failed stop() left running can still ask for one.
1790
+ // From dispose() on, no sleep or self-wake timer is armed, though a turn a
1791
+ // failed stop() left running can still ask for one.
1764
1792
  disposed = false;
1765
1793
  // Self-wake timers per agent (skip_reply's wake_in_seconds). Unlike sleep,
1766
1794
  // a self-wake suppresses NOTHING — it means "if nothing else wakes me by
@@ -1926,7 +1954,7 @@ export class EventGate {
1926
1954
  }
1927
1955
  const twice = duplicateName(starting.rules.map((rule) => rule.raw));
1928
1956
  if (twice !== undefined) {
1929
- this.startMergeNote = `At start, the recipe names the rule "${twice}" twice, so gate.json was seeded with it as given and it `
1957
+ this.startMergeNote = `At start, the recipe names the rule ${quotedValue(twice)} twice, so gate.json was seeded with it as given and it `
1930
1958
  + "wasn't recorded as the recipe last applied: a later start merges the recipe by name, once gate.json and the recipe "
1931
1959
  + 'name each rule once.';
1932
1960
  }
@@ -2122,8 +2150,10 @@ export class EventGate {
2122
2150
  raw = readFileSync(this.configPath, 'utf-8');
2123
2151
  }
2124
2152
  catch (err) {
2125
- if (!isMissing(err))
2126
- return this.configUnreadable(err);
2153
+ if (!isMissing(err)) {
2154
+ const why = quotedValue(errorText(err));
2155
+ return this.configUnreadable(why, `gate.json, at ${this.configPath}, can't be read: ${why}`, 'Whoever runs this host can make it a file the gate can read, and the gate reads it again at its next check for changes.');
2156
+ }
2127
2157
  // No file is no problem with it: the rules in force stay.
2128
2158
  this.configError = null;
2129
2159
  this.configUnused = false;
@@ -2143,12 +2173,22 @@ export class EventGate {
2143
2173
  parsed = parseGateJson(raw);
2144
2174
  }
2145
2175
  catch (err) {
2146
- return { ...this.configUnreadable(err), notJson: true };
2176
+ const why = quotedValue(errorText(err));
2177
+ return {
2178
+ ...this.configUnreadable(why, `gate.json, at ${this.configPath}, isn't JSON: ${why}`, "Once it's JSON, the gate reads it again at its next check for changes."),
2179
+ notJson: true,
2180
+ };
2147
2181
  }
2148
2182
  const read = readConfig(parsed, 'gate.json');
2149
2183
  // JSON the gate doesn't use is still the operator's file, edited as written.
2150
2184
  if ('refused' in read) {
2151
- return { ...this.configUnreadable(new Error(read.refused)), state: 'refused', listed: read.listed, silenced: read.silenced };
2185
+ const problem = read.refused.startsWith('gate.json ')
2186
+ ? `gate.json, at ${this.configPath},${read.refused.slice('gate.json'.length)}`
2187
+ : read.refused;
2188
+ return {
2189
+ ...this.configUnreadable(read.refused, problem, 'Once gate.json is mended, the gate reads it again at its next check for changes.'),
2190
+ state: 'refused', listed: read.listed, silenced: read.silenced,
2191
+ };
2152
2192
  }
2153
2193
  // A field the gate doesn't read does nothing, and a rule is read past a
2154
2194
  // problem or left out, so the file loads, and each is named.
@@ -2161,20 +2201,20 @@ export class EventGate {
2161
2201
  * gate.json's current error, replacing the last one, and traced and logged
2162
2202
  * when it's new, so an edit that doesn't load is in the log once.
2163
2203
  */
2164
- configUnreadable(err) {
2165
- const msg = err instanceof Error ? err.message : String(err);
2166
- if (msg !== this.configError) {
2167
- console.error(`[gate] ${this.configPath} couldn't be used (${msg}), so what it holds isn't in force`);
2204
+ configUnreadable(why, problem, fix = null) {
2205
+ if (problem !== this.configError) {
2206
+ console.error(`[gate] ${this.configPath} couldn't be used (${why}), so what it holds isn't in force${fix ? `. ${fix}` : ''}`);
2168
2207
  this.emitTrace({
2169
2208
  type: 'gate:config-error',
2170
- error: msg,
2209
+ error: why,
2171
2210
  configPath: this.configPath,
2172
2211
  timestamp: Date.now(),
2173
2212
  });
2174
2213
  }
2175
- this.configError = msg;
2214
+ this.configError = problem;
2215
+ this.configFix = fix;
2176
2216
  this.configUnused = true;
2177
- return { state: 'unreadable', error: msg };
2217
+ return { state: 'unreadable', error: why };
2178
2218
  }
2179
2219
  /**
2180
2220
  * gate.json's error as gate_status shows it: one that keeps what the file
@@ -2183,7 +2223,8 @@ export class EventGate {
2183
2223
  configErrorShown() {
2184
2224
  if (this.configError === null || !this.configUnused)
2185
2225
  return this.configError;
2186
- return `${sentence(this.configError)} So what gate.json holds isn't in force, and the gate runs ${this.rulesRun()}.`;
2226
+ return `${sentence(this.configError)} So what gate.json holds isn't in force, and the gate runs ${this.rulesRun()}.`
2227
+ + (this.configFix ? ` ${this.configFix}` : '');
2187
2228
  }
2188
2229
  /** What the gate runs, by where its rules came from, for a text that says so: "the host's starting rules". */
2189
2230
  rulesRun() {
@@ -2226,14 +2267,14 @@ export class EventGate {
2226
2267
  rulesForChange() {
2227
2268
  const read = this.readConfigFile();
2228
2269
  if (read.state === 'unreadable')
2229
- return { refused: unusableRefusal(read, this.rulesRun()) };
2270
+ return { refused: unusableRefusal(read, this.rulesRun(), this.configPath) };
2230
2271
  if (read.state === 'loaded' || read.state === 'refused') {
2231
2272
  if ('noList' in read.listed)
2232
- return { refused: noListRefusal(read.listed.noList, this.rulesRun()) };
2273
+ return { refused: noListRefusal(read.listed.noList, this.rulesRun(), this.configPath) };
2233
2274
  // Rules not gate.json's in force while it holds the text in force: what a
2234
2275
  // start ran in place of one with no rules of its own, until it changes.
2235
2276
  if (read.state === 'loaded' && read.text === this.configText && this.configSource !== 'file') {
2236
- return { refused: notUsedRefusal(this.rulesRun()) };
2277
+ return { refused: notUsedRefusal(this.rulesRun(), this.configPath) };
2237
2278
  }
2238
2279
  const edit = { top: read.listed.top, rules: [...read.listed.rules] };
2239
2280
  if (read.state === 'loaded')
@@ -2251,9 +2292,9 @@ export class EventGate {
2251
2292
  return {
2252
2293
  ...edit,
2253
2294
  remedy: sharing === 1
2254
- ? `${removes ? 'Replacing or removing' : 'Replacing'} "${only}" by name would bring gate.json into force.`
2295
+ ? `${removes ? 'Replacing or removing' : 'Replacing'} ${quotedValue(only)} by name would bring gate.json into force.`
2255
2296
  : sharing > 1
2256
- ? `gate.json has ${sharing} rules named "${only}", and a change by that name replaces the first of them`
2297
+ ? `gate.json has ${sharing} rules named ${quotedValue(only)}, and a change by that name replaces the first of them`
2257
2298
  + `${removes ? ' or removes them all' : ''}.`
2258
2299
  : 'No change through the gate can bring it into force, so gate.json has to be fixed by an edit of its own; '
2259
2300
  + 'until then, a change is refused the same way.',
@@ -2427,7 +2468,8 @@ export class EventGate {
2427
2468
  * part-written.
2428
2469
  */
2429
2470
  addPolicy(rawPolicy, options) {
2430
- const reading = readRule(rawPolicy, 'A policy');
2471
+ const named = namedBy(options?.fieldNames);
2472
+ const reading = readRule(rawPolicy, 'A rule', 'gate.json', named);
2431
2473
  if (reading.problems.length > 0 || !reading.policy)
2432
2474
  throw new Error(refusalOf(reading));
2433
2475
  const policy = reading.policy;
@@ -2451,8 +2493,23 @@ export class EventGate {
2451
2493
  const anchorName = 'before' in position ? position.before : position.after;
2452
2494
  const anchorIdx = rules.findIndex((rule) => nameOf(rule) === anchorName);
2453
2495
  if (anchorIdx < 0) {
2454
- throw new Error(`addPolicy: no policy named "${anchorName}" to anchor on — `
2455
- + `have: [${rules.map(nameOf).filter((name) => name !== undefined).join(', ')}]`);
2496
+ const field = named('before' in position ? 'position.before' : 'position.after');
2497
+ const others = [...new Set(rules.map(nameOf).filter((name) => name !== undefined))];
2498
+ if (idx >= 0 && anchorName === policy.name) {
2499
+ throw new Error(`${reading.where} wasn't saved: ${field} is ${quotedValue(anchorName)}, the rule being saved, and a rule can't be `
2500
+ + 'placed beside itself. '
2501
+ + (others.length > 0
2502
+ ? `The other rules are ${quotedList(others)}. Give ${field} one of those to place the rule beside it, or leave it out`
2503
+ : `There are no other rules, so leave ${field} out`)
2504
+ + ' to replace the rule where it is.'
2505
+ + savedOnlyInForce(this.configPath, remedy));
2506
+ }
2507
+ throw new Error(`${reading.where} wasn't saved: ${field} is ${quotedValue(anchorName)}, and no rule has that name. `
2508
+ + (others.length > 0
2509
+ ? `The other rules are ${quotedList(others)}. Give ${field} one of those to place the rule beside it, or leave it out`
2510
+ : `There are no other rules, so leave ${field} out`)
2511
+ + ' to add the rule at the end, or, where a rule already has its name, to replace that rule where it is.'
2512
+ + savedOnlyInForce(this.configPath, remedy));
2456
2513
  }
2457
2514
  rules.splice('before' in position ? anchorIdx : anchorIdx + 1, 0, policy);
2458
2515
  }
@@ -2528,6 +2585,16 @@ export class EventGate {
2528
2585
  const current = read.state === 'loaded' ? read.config : this.config;
2529
2586
  return current.policies.map((p) => p.name);
2530
2587
  }
2588
+ /** @internal For `wake_add_rule`'s and `wake_remove_rule`'s refusals; not a host API. */
2589
+ ruleNamesForChange() {
2590
+ const edit = this.rulesForChange();
2591
+ if ('refused' in edit)
2592
+ return { refused: edit.refused };
2593
+ return {
2594
+ names: [...new Set(edit.rules.map(nameOf).filter((name) => name !== undefined))],
2595
+ note: savedOnlyInForce(this.configPath, edit.remedy),
2596
+ };
2597
+ }
2531
2598
  /**
2532
2599
  * Persist gate.json and apply it in memory together: `top`, its own fields,
2533
2600
  * with `rules` as its rule list. Only what loads is written: a change to a
@@ -2553,13 +2620,14 @@ export class EventGate {
2553
2620
  const text = JSON.stringify({ ...top, policies: rules }, null, 2) + '\n';
2554
2621
  const read = readConfig(JSON.parse(text), 'gate.json');
2555
2622
  if ('refused' in read) {
2556
- throw new Error(`Nothing was saved: with this change, gate.json wouldn't be used. ${sentence(read.refused)}${remedy ? ` ${remedy}` : ''}`);
2623
+ throw new Error(`Nothing was saved: with this change, gate.json, at ${this.configPath}, wouldn't be used. ${sentence(read.refused)}`
2624
+ + (remedy ? ` ${remedy}` : ''));
2557
2625
  }
2558
2626
  // One the gate doesn't use, and that a change can mend, holds back what a
2559
2627
  // rule it leaves out would wake on, so its default holds events back.
2560
2628
  if (remedy !== undefined && read.config.policies.length === 0) {
2561
- throw new Error('Nothing was saved: with this change, gate.json would have no rules the gate reads, and its default would hold back every '
2562
- + `event. Meanwhile the gate runs ${this.rulesRun()}. ${remedy}`);
2629
+ throw new Error(`Nothing was saved: with this change, gate.json, at ${this.configPath}, would have no rules the gate reads, and its default `
2630
+ + `would hold back every event. Meanwhile the gate runs ${this.rulesRun()}. ${remedy}`);
2563
2631
  }
2564
2632
  try {
2565
2633
  mkdirSync(dirname(this.configPath), { recursive: true });
@@ -3190,7 +3258,7 @@ export class EventGate {
3190
3258
  }
3191
3259
  catch (err) {
3192
3260
  const one = events.length === 1;
3193
- console.error(`[gate] Policy "${policyName}": its pending batch of ${events.length} ${one ? 'event' : 'events'} couldn't be delivered `
3261
+ console.error(`[gate] Rule ${quotedValue(policyName)}: its pending batch of ${events.length} ${one ? 'event' : 'events'} couldn't be delivered `
3194
3262
  + `(${errorText(err)}), so the batch is dropped`);
3195
3263
  return;
3196
3264
  }
@@ -3380,7 +3448,7 @@ export class EventGate {
3380
3448
  if (state.timer)
3381
3449
  clearTimeout(state.timer);
3382
3450
  }).catch((err) => {
3383
- console.error(`[gate] Policy "${policyName}": the judge's verdict couldn't be applied (${errorText(err)})`);
3451
+ console.error(`[gate] Rule ${quotedValue(policyName)}: the judge's verdict couldn't be applied (${errorText(err)})`);
3384
3452
  });
3385
3453
  }
3386
3454
  /** Hold a batch for the judge until no turn changing the context it reads
@@ -3448,8 +3516,8 @@ export class EventGate {
3448
3516
  const n = state.events.length;
3449
3517
  const batch = `${n} ${n === 1 ? 'event' : 'events'}`;
3450
3518
  wakes.push(state.judge
3451
- ? { at: state.dueAt, certain: false, what: `rule "${name}"'s batch of ${batch} goes to the judge` }
3452
- : { at: state.dueAt, certain: true, what: `rule "${name}"'s debounced batch of ${batch}` });
3519
+ ? { at: state.dueAt, certain: false, what: `rule ${quotedValue(name)}'s batch of ${batch} goes to the judge` }
3520
+ : { at: state.dueAt, certain: true, what: `rule ${quotedValue(name)}'s debounced batch of ${batch}` });
3453
3521
  }
3454
3522
  return wakes.sort((a, b) => (a.at ?? 0) - (b.at ?? 0));
3455
3523
  }
@@ -3499,7 +3567,7 @@ export class EventGate {
3499
3567
  const failed = verdict == null || typeof verdict !== 'object' || typeof verdict.reply !== 'boolean';
3500
3568
  if (failed && err?.providerAdmission === 'deferred') {
3501
3569
  const wakes = this.judgeOnError === 'wake';
3502
- console.error(`[gate] Policy "${policyName}": the reply judge made no call (${errorText(err)}), so its batch of ${state.events.length} `
3570
+ console.error(`[gate] Rule ${quotedValue(policyName)}: the reply judge made no call (${errorText(err)}), so its batch of ${state.events.length} `
3503
3571
  + `${state.events.length === 1 ? 'event' : 'events'} ${wakes ? 'wakes the agent' : "doesn't wake the agent"} (judge onError "${this.judgeOnError}")`);
3504
3572
  if (this.disposed)
3505
3573
  return;
@@ -3534,7 +3602,7 @@ export class EventGate {
3534
3602
  }
3535
3603
  const wake = outcome === 'reply' || (outcome === 'error' && this.judgeOnError === 'wake');
3536
3604
  if (failed) {
3537
- console.error(`[gate] Policy "${policyName}": the reply judge failed (${reason}), so its batch of ${state.events.length} `
3605
+ console.error(`[gate] Rule ${quotedValue(policyName)}: the reply judge failed (${reason}), so its batch of ${state.events.length} `
3538
3606
  + `${state.events.length === 1 ? 'event' : 'events'} ${wake ? 'wakes the agent' : "doesn't wake the agent"} (judge onError "${this.judgeOnError}")`);
3539
3607
  }
3540
3608
  this.emitTrace({
@@ -3625,11 +3693,10 @@ export class EventGate {
3625
3693
  }
3626
3694
  /** A turn that continues another (a budget restart, a tool-result-guard
3627
3695
  * retry) holds the entry its predecessor kept for it, and takes it again
3628
- * where that entry ended in between: at a stop(), whose dispose() clears
3629
- * every entry, or at a provider hold that kept the turn from starting,
3630
- * whose end queues it again. Unlike onInferenceStarted it leaves a
3631
- * pending self-wake alone: the logical turn that armed it is the one
3632
- * still running. */
3696
+ * where that entry ended in between, as at a provider hold that kept the
3697
+ * turn from starting, whose end queues it again. Unlike
3698
+ * onInferenceStarted it leaves a pending self-wake alone: the logical
3699
+ * turn that armed it is the one still running. */
3633
3700
  onInferenceResumed(agentName) {
3634
3701
  this.inferring.add(agentName);
3635
3702
  if (this.changesJudgedContext(agentName))
@@ -3739,7 +3806,7 @@ export class EventGate {
3739
3806
  // Arm the self-wake: when the window elapses, clear sleep and request an
3740
3807
  // inference so the agent resumes on its own (not dependent on a heartbeat
3741
3808
  // tick or an incoming message). Cancelled by clearSleep()/`wake`, and by
3742
- // dispose(), after which start() arms it again (resumeSleep).
3809
+ // dispose(), after which nothing arms it.
3743
3810
  this.armSleepWake();
3744
3811
  this.reloadPrivilegedIfChanged();
3745
3812
  this.emitTrace({
@@ -3752,25 +3819,11 @@ export class EventGate {
3752
3819
  });
3753
3820
  return { until: this.sleepUntil };
3754
3821
  }
3755
- /**
3756
- * Arm timers again for a lifecycle that starts after a stop(), and the
3757
- * sleep's wake first: dispose() clears its timer and keeps the sleep. A
3758
- * sleep still ahead wakes the agent at its end; one that ended while
3759
- * stopped wakes it now, since that wake is what the agent asked for. With
3760
- * no sleep, wakeFromSleep does nothing.
3761
- */
3762
- resumeSleep() {
3763
- this.disposed = false;
3764
- if (this.sleepUntil > this.now())
3765
- this.armSleepWake();
3766
- else
3767
- this.wakeFromSleep('sleep-expired');
3768
- }
3769
3822
  /**
3770
3823
  * Arm the sleep's wake for its end by the gate's own clock. One timer holds
3771
3824
  * at most MAX_TIMER_MS, about 24.8 days, so a longer sleep waits in steps,
3772
3825
  * and wakeFromSleep reads the clock as each one fires. Nothing is armed
3773
- * while the gate is disposed: resumeSleep() arms it at the next start().
3826
+ * once the gate is disposed.
3774
3827
  */
3775
3828
  armSleepWake() {
3776
3829
  if (this.sleepTimer) {
@@ -3878,9 +3931,7 @@ export class EventGate {
3878
3931
  * fires early (Node's can, by a millisecond), or a clock set back.
3879
3932
  */
3880
3933
  wakeFromSleep(reason) {
3881
- // The timer that called it has fired; from resumeSleep(), for a sleep
3882
- // already over, one may still be armed, and it goes, so no stale step of
3883
- // it can fire later.
3934
+ // The timer that called it has fired.
3884
3935
  if (this.sleepTimer) {
3885
3936
  clearTimeout(this.sleepTimer);
3886
3937
  this.sleepTimer = null;
@@ -3943,15 +3994,21 @@ export class EventGate {
3943
3994
  * each a non-empty string or a safe integer (a whole number within 2^53 - 1 of zero). An absent file
3944
3995
  * means no one is privileged. A file that can't be read, isn't JSON, or
3945
3996
  * holds no list leaves the list that last loaded in this process in effect
3946
- * (none, at startup), with its error in gate_status until the file is read
3947
- * again. In a list, an entry that isn't a user id is skipped, and named in
3948
- * the file's error, and the rest load. With `unlessUnchanged`, a file whose
3997
+ * (none, at startup), with its error in gate_status until a later read
3998
+ * loads it, finds it gone, or fails another way. In a list, an entry that isn't a user id is skipped, and named
3999
+ * in the file's error, and the rest load. With `unlessUnchanged`, a file whose
3949
4000
  * text is the text last read isn't parsed again.
3950
4001
  */
3951
4002
  loadPrivileged(opts) {
3952
4003
  if (!this.privilegedUsersPath)
3953
4004
  return;
3954
- const failed = (why) => `Failed to load privileged-users file: ${why}`;
4005
+ const file = `The privileged-users file, at ${this.privilegedUsersPath},`;
4006
+ const held = this.privilegedUserIds.size;
4007
+ const meanwhile = held === 0
4008
+ ? 'no one is privileged'
4009
+ : `${held === 1 ? 'the one id of the list that last loaded stays' : `the ${held} ids of the list that last loaded stay`} privileged`;
4010
+ const revoke = 'Replacing it with a file holding [], or removing it, makes no one privileged at the gate\'s next check for changes.';
4011
+ const failed = (problem, fix) => `${file} ${problem}, so ${meanwhile}. ${fix} ${revoke}`;
3955
4012
  let raw;
3956
4013
  try {
3957
4014
  raw = readFileSync(this.privilegedUsersPath, 'utf8');
@@ -3965,26 +4022,27 @@ export class EventGate {
3965
4022
  this.privilegedError = null;
3966
4023
  }
3967
4024
  else {
3968
- this.privilegedError = failed(err instanceof Error ? err.message : String(err));
4025
+ this.privilegedError = failed(`can't be read: ${quotedValue(errorText(err))}`, 'Whoever runs this host can make it a file the gate can read, and the gate reads it again at its next check for changes.');
3969
4026
  }
3970
4027
  return;
3971
4028
  }
3972
4029
  if (opts?.unlessUnchanged && raw === this.privilegedText)
3973
4030
  return;
3974
4031
  this.privilegedText = raw;
4032
+ const fixed = 'Once it holds a list of user ids, the gate loads it at its next check for changes.';
3975
4033
  let ids;
3976
4034
  try {
3977
4035
  const parsed = parseGateJson(raw);
3978
4036
  ids = Array.isArray(parsed) ? parsed : parsed?.userIds;
3979
4037
  }
3980
4038
  catch (err) {
3981
- this.privilegedError = failed(err instanceof Error ? err.message : String(err));
4039
+ this.privilegedError = failed(`isn't JSON: ${quotedValue(errorText(err))}`, fixed);
3982
4040
  return;
3983
4041
  }
3984
4042
  // A file that parses but holds no list hasn't loaded either: it used to
3985
4043
  // mean no one is privileged, without a word.
3986
4044
  if (!Array.isArray(ids)) {
3987
- this.privilegedError = failed('expected a list of user ids, or {"userIds": [...]}');
4045
+ this.privilegedError = failed('holds no list of user ids, where one is needed, such as ["123"] or {"userIds": ["123"]}', fixed);
3988
4046
  return;
3989
4047
  }
3990
4048
  const loaded = new Set();
@@ -4000,7 +4058,7 @@ export class EventGate {
4000
4058
  skipped.push(id);
4001
4059
  }
4002
4060
  this.privilegedUserIds = loaded;
4003
- this.privilegedError = skipped.length > 0 ? skippedPrivilegedIds(skipped, loaded.size) : null;
4061
+ this.privilegedError = skipped.length > 0 ? skippedPrivilegedIds(skipped, loaded.size, this.privilegedUsersPath) : null;
4004
4062
  }
4005
4063
  /** At most once a second, the privileged-users file read again, and loaded when its text has changed. */
4006
4064
  reloadPrivilegedIfChanged() {
@@ -4154,9 +4212,9 @@ export class EventGate {
4154
4212
  this.selfWakeDue.clear();
4155
4213
  // The sleep stays, with its note and agent, and its timer goes: fired
4156
4214
  // after a stop(), its wake reached a framework that had stopped, and the
4157
- // timer held the process until the sleep's end. The next start() arms it
4158
- // again (resumeSleep). Until then nothing arms one, nor a self-wake: a
4159
- // turn a failed stop() left running can still call sleep or skip_reply.
4215
+ // timer held the process until the sleep's end. Nothing arms one after
4216
+ // this, nor a self-wake: a turn a failed stop() left running can still
4217
+ // call sleep or skip_reply.
4160
4218
  if (this.sleepTimer) {
4161
4219
  clearTimeout(this.sleepTimer);
4162
4220
  this.sleepTimer = null;
@@ -4169,13 +4227,9 @@ export class EventGate {
4169
4227
  this.inferenceBuffer = [];
4170
4228
  // stop() disposes the gate once its streams and event loop have ended (a
4171
4229
  // turn still compiling then is dropped at its next check, before it sends
4172
- // anything), and a start() goes on with this gate. An agent left here by
4173
- // a turn whose exit didn't end it (a retry stop() cancelled, a successor
4174
- // stop() refused) would make every debounced batch buffer until that
4175
- // agent's next turn ended. A turn that continues after the start() takes
4176
- // its entry again (onInferenceResumed). A start() made during the stop()
4177
- // is refused, so no turn of a new lifecycle can have begun before this
4178
- // runs.
4230
+ // anything). An agent left here by a turn whose exit didn't end it (a
4231
+ // retry stop() cancelled, a successor stop() refused) would make every
4232
+ // debounced batch buffer until that agent's next turn ended.
4179
4233
  this.inferring.clear();
4180
4234
  }
4181
4235
  }