@rubytech/create-realagent-code 0.1.603 → 0.1.605

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 (133) hide show
  1. package/dist/__tests__/email-sampler-unit.test.js +22 -0
  2. package/dist/index.js +3 -0
  3. package/dist/port-resolution.js +9 -1
  4. package/package.json +1 -1
  5. package/payload/platform/lib/routine-templates/dist/roster.d.ts +46 -2
  6. package/payload/platform/lib/routine-templates/dist/roster.d.ts.map +1 -1
  7. package/payload/platform/lib/routine-templates/dist/roster.js +84 -32
  8. package/payload/platform/lib/routine-templates/dist/roster.js.map +1 -1
  9. package/payload/platform/lib/routine-templates/src/__tests__/roster.test.ts +0 -0
  10. package/payload/platform/lib/routine-templates/src/__tests__/seed.test.ts +48 -2
  11. package/payload/platform/lib/routine-templates/src/roster.ts +133 -35
  12. package/payload/platform/lib/telegram-store-read/dist/index.d.ts +59 -0
  13. package/payload/platform/lib/telegram-store-read/dist/index.d.ts.map +1 -0
  14. package/payload/platform/lib/telegram-store-read/dist/index.js +219 -0
  15. package/payload/platform/lib/telegram-store-read/dist/index.js.map +1 -0
  16. package/payload/platform/lib/telegram-store-read/src/__tests__/fold.test.ts +124 -0
  17. package/payload/platform/lib/telegram-store-read/src/index.ts +262 -0
  18. package/payload/platform/lib/telegram-store-read/tsconfig.json +9 -0
  19. package/payload/platform/lib/telegram-store-read/vitest.config.ts +9 -0
  20. package/payload/platform/package.json +2 -2
  21. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +39 -6
  22. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +11 -0
  23. package/payload/platform/plugins/connector/PLUGIN.md +9 -1
  24. package/payload/platform/plugins/connector/mcp/dist/__tests__/queue-descriptor.test.d.ts +2 -0
  25. package/payload/platform/plugins/connector/mcp/dist/__tests__/queue-descriptor.test.d.ts.map +1 -0
  26. package/payload/platform/plugins/connector/mcp/dist/__tests__/queue-descriptor.test.js +67 -0
  27. package/payload/platform/plugins/connector/mcp/dist/__tests__/queue-descriptor.test.js.map +1 -0
  28. package/payload/platform/plugins/connector/mcp/dist/index.js +36 -5
  29. package/payload/platform/plugins/connector/mcp/dist/index.js.map +1 -1
  30. package/payload/platform/plugins/connector/mcp/dist/lib/store.d.ts +35 -0
  31. package/payload/platform/plugins/connector/mcp/dist/lib/store.d.ts.map +1 -1
  32. package/payload/platform/plugins/connector/mcp/dist/lib/store.js +33 -0
  33. package/payload/platform/plugins/connector/mcp/dist/lib/store.js.map +1 -1
  34. package/payload/platform/plugins/connector/mcp/package.json +4 -2
  35. package/payload/platform/plugins/connector/mcp/vitest.config.ts +8 -0
  36. package/payload/platform/plugins/docs/references/admin-ui.md +2 -2
  37. package/payload/platform/plugins/docs/references/outlook-guide.md +21 -2
  38. package/payload/platform/plugins/docs/references/telegram-guide.md +15 -1
  39. package/payload/platform/plugins/email/PLUGIN.md +14 -1
  40. package/payload/platform/plugins/email/mcp/dist/sampler/__tests__/retention.test.d.ts +2 -0
  41. package/payload/platform/plugins/email/mcp/dist/sampler/__tests__/retention.test.d.ts.map +1 -0
  42. package/payload/platform/plugins/email/mcp/dist/sampler/__tests__/retention.test.js +233 -0
  43. package/payload/platform/plugins/email/mcp/dist/sampler/__tests__/retention.test.js.map +1 -0
  44. package/payload/platform/plugins/email/mcp/dist/sampler/__tests__/sample-mailbox.test.js +30 -0
  45. package/payload/platform/plugins/email/mcp/dist/sampler/__tests__/sample-mailbox.test.js.map +1 -1
  46. package/payload/platform/plugins/email/mcp/dist/sampler/main.js +68 -3
  47. package/payload/platform/plugins/email/mcp/dist/sampler/main.js.map +1 -1
  48. package/payload/platform/plugins/email/mcp/dist/sampler/retention-sources.d.ts +3 -0
  49. package/payload/platform/plugins/email/mcp/dist/sampler/retention-sources.d.ts.map +1 -0
  50. package/payload/platform/plugins/email/mcp/dist/sampler/retention-sources.js +146 -0
  51. package/payload/platform/plugins/email/mcp/dist/sampler/retention-sources.js.map +1 -0
  52. package/payload/platform/plugins/email/mcp/dist/sampler/retention.d.ts +150 -0
  53. package/payload/platform/plugins/email/mcp/dist/sampler/retention.d.ts.map +1 -0
  54. package/payload/platform/plugins/email/mcp/dist/sampler/retention.js +199 -0
  55. package/payload/platform/plugins/email/mcp/dist/sampler/retention.js.map +1 -0
  56. package/payload/platform/plugins/email/mcp/dist/sampler/sample-mailbox.d.ts +5 -0
  57. package/payload/platform/plugins/email/mcp/dist/sampler/sample-mailbox.d.ts.map +1 -1
  58. package/payload/platform/plugins/email/mcp/dist/sampler/sample-mailbox.js +10 -5
  59. package/payload/platform/plugins/email/mcp/dist/sampler/sample-mailbox.js.map +1 -1
  60. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-section-properties-strip.test.js +11 -0
  61. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-section-properties-strip.test.js.map +1 -1
  62. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.d.ts.map +1 -1
  63. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.js +8 -0
  64. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.js.map +1 -1
  65. package/payload/platform/plugins/outlook/mcp/dist/__tests__/sample-outlook-mailbox.test.d.ts +2 -0
  66. package/payload/platform/plugins/outlook/mcp/dist/__tests__/sample-outlook-mailbox.test.d.ts.map +1 -0
  67. package/payload/platform/plugins/outlook/mcp/dist/__tests__/sample-outlook-mailbox.test.js +324 -0
  68. package/payload/platform/plugins/outlook/mcp/dist/__tests__/sample-outlook-mailbox.test.js.map +1 -0
  69. package/payload/platform/plugins/outlook/mcp/dist/__tests__/sampler-cursor.test.d.ts +2 -0
  70. package/payload/platform/plugins/outlook/mcp/dist/__tests__/sampler-cursor.test.d.ts.map +1 -0
  71. package/payload/platform/plugins/outlook/mcp/dist/__tests__/sampler-cursor.test.js +102 -0
  72. package/payload/platform/plugins/outlook/mcp/dist/__tests__/sampler-cursor.test.js.map +1 -0
  73. package/payload/platform/plugins/outlook/mcp/dist/__tests__/sampler-enumerate.test.d.ts +2 -0
  74. package/payload/platform/plugins/outlook/mcp/dist/__tests__/sampler-enumerate.test.d.ts.map +1 -0
  75. package/payload/platform/plugins/outlook/mcp/dist/__tests__/sampler-enumerate.test.js +125 -0
  76. package/payload/platform/plugins/outlook/mcp/dist/__tests__/sampler-enumerate.test.js.map +1 -0
  77. package/payload/platform/plugins/outlook/mcp/dist/sampler/arm.d.ts +2 -0
  78. package/payload/platform/plugins/outlook/mcp/dist/sampler/arm.d.ts.map +1 -0
  79. package/payload/platform/plugins/outlook/mcp/dist/sampler/arm.js +109 -0
  80. package/payload/platform/plugins/outlook/mcp/dist/sampler/arm.js.map +1 -0
  81. package/payload/platform/plugins/outlook/mcp/dist/sampler/cursor.d.ts +25 -0
  82. package/payload/platform/plugins/outlook/mcp/dist/sampler/cursor.d.ts.map +1 -0
  83. package/payload/platform/plugins/outlook/mcp/dist/sampler/cursor.js +94 -0
  84. package/payload/platform/plugins/outlook/mcp/dist/sampler/cursor.js.map +1 -0
  85. package/payload/platform/plugins/outlook/mcp/dist/sampler/enumerate.d.ts +41 -0
  86. package/payload/platform/plugins/outlook/mcp/dist/sampler/enumerate.d.ts.map +1 -0
  87. package/payload/platform/plugins/outlook/mcp/dist/sampler/enumerate.js +122 -0
  88. package/payload/platform/plugins/outlook/mcp/dist/sampler/enumerate.js.map +1 -0
  89. package/payload/platform/plugins/outlook/mcp/dist/sampler/sample-outlook-mailbox.d.ts +73 -0
  90. package/payload/platform/plugins/outlook/mcp/dist/sampler/sample-outlook-mailbox.d.ts.map +1 -0
  91. package/payload/platform/plugins/outlook/mcp/dist/sampler/sample-outlook-mailbox.js +162 -0
  92. package/payload/platform/plugins/outlook/mcp/dist/sampler/sample-outlook-mailbox.js.map +1 -0
  93. package/payload/platform/plugins/scheduling/PLUGIN.md +8 -1
  94. package/payload/platform/plugins/scheduling/mcp/dist/index.js +1 -1
  95. package/payload/platform/plugins/scheduling/mcp/dist/index.js.map +1 -1
  96. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate-flag.test.d.ts +2 -0
  97. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate-flag.test.d.ts.map +1 -0
  98. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate-flag.test.js +90 -0
  99. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate-flag.test.js.map +1 -0
  100. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate.test.js +26 -4
  101. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate.test.js.map +1 -1
  102. package/payload/platform/plugins/scheduling/mcp/dist/lib/gate-flag.d.ts +78 -0
  103. package/payload/platform/plugins/scheduling/mcp/dist/lib/gate-flag.d.ts.map +1 -0
  104. package/payload/platform/plugins/scheduling/mcp/dist/lib/gate-flag.js +76 -0
  105. package/payload/platform/plugins/scheduling/mcp/dist/lib/gate-flag.js.map +1 -0
  106. package/payload/platform/plugins/scheduling/mcp/dist/lib/gate.d.ts +8 -0
  107. package/payload/platform/plugins/scheduling/mcp/dist/lib/gate.d.ts.map +1 -1
  108. package/payload/platform/plugins/scheduling/mcp/dist/lib/gate.js +9 -1
  109. package/payload/platform/plugins/scheduling/mcp/dist/lib/gate.js.map +1 -1
  110. package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/gate-dispatch-wiring.test.js +15 -0
  111. package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/gate-dispatch-wiring.test.js.map +1 -1
  112. package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.d.ts +12 -1
  113. package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.d.ts.map +1 -1
  114. package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.js +34 -1
  115. package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.js.map +1 -1
  116. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +9 -1
  117. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
  118. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-gate-write.test.js +40 -0
  119. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-gate-write.test.js.map +1 -1
  120. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/scheduling-gate.test.js +435 -5
  121. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/scheduling-gate.test.js.map +1 -1
  122. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.d.ts.map +1 -1
  123. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.js +21 -1
  124. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.js.map +1 -1
  125. package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.d.ts +294 -6
  126. package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.d.ts.map +1 -1
  127. package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.js +553 -11
  128. package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.js.map +1 -1
  129. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +1 -1
  130. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
  131. package/payload/platform/templates/specialists/agents/personal-assistant.md +1 -1
  132. package/payload/server/public/operator.html +1 -1
  133. package/payload/server/server.js +69 -5
@@ -19,10 +19,48 @@
19
19
  * on every surface, which is the Task 2313 class this must not recreate.
20
20
  */
21
21
  import { createHash } from "node:crypto";
22
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
23
- import { dirname, resolve } from "node:path";
22
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
23
+ import { dirname, join, resolve } from "node:path";
24
+ import { int } from "neo4j-driver";
24
25
  import { spawnProviderCall } from "../lib/connector-freebusy.js";
26
+ import { unflaggedLabel } from "../lib/gate-flag.js";
25
27
  import { getSession } from "../lib/neo4j.js";
28
+ /**
29
+ * Task 2689 — how long after its newest report the memory sweep counts as
30
+ * stale.
31
+ *
32
+ * The sweep runs hourly and `graph-health`'s own silence condition is "the
33
+ * sweep ran within the past day", so 26 hours gives it two missed runs of slack
34
+ * before the routine is woken.
35
+ */
36
+ export const SWEEP_STALE_AFTER_MS = 26 * 60 * 60 * 1000;
37
+ /**
38
+ * Task 2689 — one week of growth worth a person's attention on a Pi-class disk.
39
+ *
40
+ * The shipped `filesystem-health` template names its threshold explicitly; a
41
+ * gate written by hand that omits the field inherits this.
42
+ */
43
+ export const FS_DEFAULT_MIN_GROWTH_BYTES = 64 * 1024 * 1024;
44
+ /**
45
+ * Task 2689 — the most files one account-directory measurement walks.
46
+ *
47
+ * A bound, not a budget. A directory above it is exactly what
48
+ * `filesystem-health` exists to report, and the walk that stopped there has
49
+ * measured a floor rather than a total, so the check reports work and records
50
+ * no position.
51
+ */
52
+ export const FS_WALK_ENTRY_CAP = 200_000;
53
+ /**
54
+ * Task 2684 — the most node ids one `graph-unflagged` check hands over per fire.
55
+ *
56
+ * A cap is needed because the ids travel to the dispatcher inside the tool's
57
+ * `structuredContent`, and a routine bound against an account that already holds
58
+ * a large archive would otherwise put every one of them on the wire on its first
59
+ * fire. It fails in the only safe direction: nodes above the cap stay unflagged,
60
+ * so the next fire counts them again and the backlog drains over several fires.
61
+ * Nothing is skipped — that is the whole property this check exists for.
62
+ */
63
+ export const UNFLAGGED_ID_CAP = 500;
26
64
  /**
27
65
  * Task 2653 — the accepted `type` values, in one place.
28
66
  *
@@ -32,7 +70,27 @@ import { getSession } from "../lib/neo4j.js";
32
70
  * time. A second hand-maintained copy is how a type accepted by the writer
33
71
  * becomes a type the prober cannot answer.
34
72
  */
35
- export const ACCEPTED_CHECK_TYPES = ["mail", "d1", "whatsapp", "calendar", "telegram", "graph"];
73
+ export const ACCEPTED_CHECK_TYPES = [
74
+ "mail",
75
+ "d1",
76
+ "whatsapp",
77
+ "calendar",
78
+ "telegram",
79
+ "graph",
80
+ "graph-unflagged",
81
+ "connector",
82
+ "sweep",
83
+ "filesystem",
84
+ ];
85
+ /**
86
+ * Task 2688 — the accepted `scope` values on a `connector` check, in one place.
87
+ *
88
+ * The same single-sourcing `ACCEPTED_MAIL_PROVIDERS` has: the probe asks the
89
+ * registry for queues of this kind, and `validateGate` refuses anything outside
90
+ * it at write time. A scope outside the set matches no queue, so the gate would
91
+ * answer quiet on every fire and the routine could never run.
92
+ */
93
+ export const ACCEPTED_CONNECTOR_SCOPES = ["operational", "accounting"];
36
94
  /**
37
95
  * Task 2657 — the accepted `provider` values on a `mail` check, in one place.
38
96
  *
@@ -66,6 +124,10 @@ export const ACCEPTED_CHECK_FIELDS = {
66
124
  calendar: ["type", "connector"],
67
125
  telegram: ["type", "scope", "botId"],
68
126
  graph: ["type", "scope"],
127
+ "graph-unflagged": ["type", "label"],
128
+ connector: ["type", "scope"],
129
+ sweep: ["type"],
130
+ filesystem: ["type", "minGrowthBytes"],
69
131
  };
70
132
  /**
71
133
  * Rooted at the INSTALL, not at the account.
@@ -219,6 +281,10 @@ export function slotKey(slot) {
219
281
  // commit over two bots names two entries rather than one repeated twice.
220
282
  if (slot.kind === "telegram" && slot.botId !== undefined)
221
283
  return `telegram:${slot.botId}`;
284
+ // Task 2688 — the connector's name, so a commit over two connectors names two
285
+ // entries rather than one repeated twice.
286
+ if (slot.kind === "connector")
287
+ return `connector:${slot.key}`;
222
288
  return slot.kind;
223
289
  }
224
290
  /**
@@ -251,6 +317,19 @@ export function slotPosition(slot, marks) {
251
317
  // The ISO `updatedAt` verbatim, as the Telegram position is: it IS the
252
318
  // value the comparison is made on.
253
319
  return marks.graph?.updatedAt ?? null;
320
+ case "connector":
321
+ // The ISO instant sent as the queue's changed-since argument, printed
322
+ // verbatim for the same reason.
323
+ return marks.connector?.[slot.key]?.changedSince ?? null;
324
+ case "sweep":
325
+ // Truncated for the reason the calendar digest is: the whole sha256 tells
326
+ // a reader nothing the first eight do not, and the file holds it all.
327
+ return marks.sweep?.digest?.slice(0, 8) ?? null;
328
+ case "filesystem":
329
+ // The optional chain, as every arm above has it: a file holding
330
+ // `"filesystem": {}` would otherwise print the string "undefined" as this
331
+ // routine's position in `op=gate-read` and in `positions=`.
332
+ return marks.filesystem?.bytes === undefined ? null : String(marks.filesystem.bytes);
254
333
  }
255
334
  }
256
335
  /**
@@ -277,6 +356,12 @@ export function slotsIn(marks) {
277
356
  slots.push({ kind: "calendar" });
278
357
  if (marks.graph?.updatedAt !== undefined)
279
358
  slots.push({ kind: "graph" });
359
+ for (const key of Object.keys(marks.connector ?? {}))
360
+ slots.push({ kind: "connector", key });
361
+ if (marks.sweep?.digest !== undefined)
362
+ slots.push({ kind: "sweep" });
363
+ if (marks.filesystem?.bytes !== undefined)
364
+ slots.push({ kind: "filesystem" });
280
365
  return slots;
281
366
  }
282
367
  /**
@@ -387,6 +472,107 @@ export function promoteSlot(slot, from, into) {
387
472
  into.graph = { ...(into.graph ?? {}), updatedAt };
388
473
  return true;
389
474
  }
475
+ case "connector": {
476
+ const entry = from.connector?.[slot.key];
477
+ if (entry === undefined)
478
+ return false;
479
+ into.connector = { ...(into.connector ?? {}), [slot.key]: entry };
480
+ return true;
481
+ }
482
+ case "sweep": {
483
+ const digest = from.sweep?.digest;
484
+ if (digest === undefined)
485
+ return false;
486
+ into.sweep = { ...(into.sweep ?? {}), digest };
487
+ return true;
488
+ }
489
+ case "filesystem": {
490
+ const bytes = from.filesystem?.bytes;
491
+ if (bytes === undefined)
492
+ return false;
493
+ into.filesystem = { ...(into.filesystem ?? {}), bytes };
494
+ return true;
495
+ }
496
+ }
497
+ }
498
+ /**
499
+ * Task 2689 — the memory sweep's state as one comparable value.
500
+ *
501
+ * The two review-queue counts and a staleness flag, never the newest report's
502
+ * identity. The sweep runs hourly and `graph-health` is proposed weekly, so a
503
+ * digest carrying the report id would differ on essentially every fire and the
504
+ * gate could never go quiet. A digest over the queue counts alone would go
505
+ * quiet correctly, but a sweep that STOPPED running moves no queue, so the
506
+ * routine could never report the one failure it exists to catch. The flag gives
507
+ * both: a constant value while the sweep is healthy, one change at the
508
+ * transition into staleness, and one more on the way out.
509
+ *
510
+ * `now` is a parameter rather than a `Date.now()` read inside, so the boundary
511
+ * is testable without moving the clock. A `newestReportAt` this cannot parse
512
+ * throws, through `instantOf`, and a throw counts as work.
513
+ */
514
+ export function digestSweepState(state, now) {
515
+ const stale = state.newestReportAt === null || now - instantOf(state.newestReportAt) > SWEEP_STALE_AFTER_MS;
516
+ return createHash("sha256").update(`${state.orphans}|${state.citations}|${stale}`).digest("hex");
517
+ }
518
+ /**
519
+ * Task 2689 — total bytes of the regular files under `root`.
520
+ *
521
+ * A symlink is never followed, so the walk cannot leave the account directory
522
+ * even when something inside points out of it — the rule the filesystem
523
+ * plugin's own walker holds to, and the reason this does not shell out to `du`.
524
+ *
525
+ * A vanished entry (`ENOENT` on a stat, or on a nested directory read) is
526
+ * SKIPPED: an account directory is written to continuously, and a file deleted
527
+ * between the read and the stat contributes nothing to a size. Every other
528
+ * error propagates. The ROOT is read outside that rule, so a missing account
529
+ * directory throws: a wrongly derived root that read zero would answer "nothing
530
+ * grew" for ever, which is the permanent mute this design forbids.
531
+ */
532
+ export function measureAccountBytes(root, cap) {
533
+ let bytes = 0;
534
+ // Every entry the walk touches, not only the files. Counting files alone
535
+ // would leave the directories unbounded, and the pending stack grows by one
536
+ // per directory: a tree that is wide in directories and thin in files would
537
+ // then walk all of it with the cap never reached.
538
+ let seen = 0;
539
+ const pending = [];
540
+ let dir = root;
541
+ let entries = readdirSync(root, { withFileTypes: true });
542
+ for (;;) {
543
+ for (const entry of entries) {
544
+ if (entry.isSymbolicLink())
545
+ continue;
546
+ if (seen >= cap)
547
+ return { bytes, capped: true };
548
+ seen++;
549
+ const full = join(dir, entry.name);
550
+ if (entry.isDirectory()) {
551
+ pending.push(full);
552
+ continue;
553
+ }
554
+ if (!entry.isFile())
555
+ continue;
556
+ try {
557
+ bytes += statSync(full).size;
558
+ }
559
+ catch (err) {
560
+ if (err.code !== "ENOENT")
561
+ throw err;
562
+ }
563
+ }
564
+ const next = pending.pop();
565
+ if (next === undefined)
566
+ return { bytes, capped: false };
567
+ dir = next;
568
+ try {
569
+ entries = readdirSync(dir, { withFileTypes: true });
570
+ }
571
+ catch (err) {
572
+ if (err.code !== "ENOENT")
573
+ throw err;
574
+ entries = [];
575
+ }
390
576
  }
391
577
  }
392
578
  // ---------------------------------------------------------------------------
@@ -601,6 +787,30 @@ const realMail = async (check, sinceUid, accountId) => {
601
787
  }
602
788
  }
603
789
  };
790
+ /**
791
+ * Task 2688 — one call to the connector plugin's own tools.
792
+ *
793
+ * A spawn rather than a loopback route or a direct read of the registry files,
794
+ * because the credential, the host binding and the SSRF guard all live inside
795
+ * that plugin, and reimplementing any of them here would put a second copy of a
796
+ * security control in a second package. It is the transport the Outlook mail
797
+ * probe already uses from this same heartbeat: same timeout, same ACCOUNT_ID
798
+ * scoping, same captured child stderr, same PLATFORM_PORT bridge.
799
+ *
800
+ * The import is dynamic because `lib/gate.ts` imports this module's types; a
801
+ * static value import would close that loop at run time.
802
+ */
803
+ async function callConnectorTool(accountId, tool, args) {
804
+ const { callPluginTool } = await import("../lib/gate.js");
805
+ const call = await callPluginTool("connector", tool, JSON.stringify(args), accountId);
806
+ const text = (Array.isArray(call.result.content) ? call.result.content : [])
807
+ .filter((c) => c.type === "text")
808
+ .map((c) => String(c.text))
809
+ .join("\n");
810
+ if (call.result.isError === true)
811
+ throw new Error(`${tool} returned an error: ${text}`);
812
+ return JSON.parse(text);
813
+ }
604
814
  export const REAL_SOURCES = {
605
815
  mail: realMail,
606
816
  // Task 2657 — the provider owns the decryption of its own tokens, so this is
@@ -740,6 +950,156 @@ export const REAL_SOURCES = {
740
950
  await session.close();
741
951
  }
742
952
  },
953
+ graphUnflagged: async (accountId, label, flagKey, limit) => {
954
+ // The label is resolved through the shared accepted set and interpolated;
955
+ // it is never operator text reaching the statement, and `unflaggedLabel`
956
+ // throws on anything outside that set, which the probe treats as work.
957
+ // Everything else is a parameter.
958
+ const cypherLabel = unflaggedLabel(label);
959
+ const session = getSession();
960
+ try {
961
+ const res = await session.run(`MATCH (n:${cypherLabel})
962
+ WHERE n.accountId = $accountId
963
+ AND NOT $flagKey IN coalesce(n.gateProcessedBy, [])
964
+ RETURN elementId(n) AS id
965
+ LIMIT $limit`,
966
+ // `int` because `LIMIT` needs a Cypher Integer: a bare JS number arrives
967
+ // as a Float and the statement is rejected at run time.
968
+ { accountId, flagKey, limit: int(limit) });
969
+ const ids = [];
970
+ for (const record of res.records) {
971
+ const id = record.get("id");
972
+ // A shape this cannot read THROWS rather than being dropped from the
973
+ // set. Dropping it would leave the node counted as work by the probe
974
+ // and unflagged by the dispatcher, so the routine would wake on it for
975
+ // ever; a throw counts as work once and names itself in the log.
976
+ if (typeof id !== "string") {
977
+ throw new Error(`graphUnflagged id is type ${typeof id}, expected a string`);
978
+ }
979
+ ids.push(id);
980
+ }
981
+ return { ids };
982
+ }
983
+ finally {
984
+ await session.close();
985
+ }
986
+ },
987
+ connectorQueues: async (accountId, kind) => {
988
+ // The connector plugin is optional. Its absence is an ANSWER (nothing is
989
+ // registered, so nothing registered can have moved), so it is detected
990
+ // before any spawn rather than by catching the transport error, which is
991
+ // indistinguishable from a child that crashed on boot.
992
+ const serverPath = resolve(process.env.PLATFORM_ROOT, "plugins", "connector", "mcp", "dist", "index.js");
993
+ if (!existsSync(serverPath))
994
+ return { pluginPresent: false, bindings: [] };
995
+ const payload = await callConnectorTool(accountId, "connector-list", {});
996
+ if (!Array.isArray(payload)) {
997
+ throw new Error(`connector-list returned ${typeof payload}, expected an array`);
998
+ }
999
+ const bindings = [];
1000
+ for (const entry of payload) {
1001
+ const e = entry;
1002
+ const q = e.queue;
1003
+ if (typeof e.name !== "string" || !q || q.kind !== kind)
1004
+ continue;
1005
+ // A descriptor missing a field it needs THROWS rather than being called
1006
+ // with `undefined` in the URL: that call would 404, the 404 would count as
1007
+ // work, and the routine would wake on every fire with nothing to show for
1008
+ // it. The throw names the connector once and counts as work once.
1009
+ if (typeof q.path !== "string" ||
1010
+ typeof q.changedSinceParam !== "string" ||
1011
+ typeof q.itemsField !== "string") {
1012
+ throw new Error(`connector ${e.name} carries an incomplete queue descriptor`);
1013
+ }
1014
+ bindings.push({ name: e.name, queue: q });
1015
+ }
1016
+ return { pluginPresent: true, bindings };
1017
+ },
1018
+ connectorQueueRead: async (accountId, binding, changedSince) => {
1019
+ const payload = await callConnectorTool(accountId, "connector-call", {
1020
+ name: binding.name,
1021
+ method: "GET",
1022
+ path: binding.queue.path,
1023
+ query: { [binding.queue.changedSinceParam]: changedSince },
1024
+ });
1025
+ const answer = payload;
1026
+ if (typeof answer.status !== "number") {
1027
+ throw new Error(`connector-call returned no status for ${binding.name}`);
1028
+ }
1029
+ // Every non-2xx is handed back with its status and no count. The probe
1030
+ // decides which of them is an answer (401 and 403) and which is a failure,
1031
+ // so that rule lives in one place rather than being split across the source
1032
+ // and its caller.
1033
+ if (answer.status < 200 || answer.status >= 300) {
1034
+ return { status: answer.status, items: 0, truncated: false };
1035
+ }
1036
+ // A truncated body cannot be counted — the array it carries is a prefix of
1037
+ // the real one, and the cut usually lands mid-token, so it will not even
1038
+ // parse. It is reported rather than thrown, because the cap is itself proof
1039
+ // the queue moved: the probe counts it as work and records the position, and
1040
+ // a throw would record none, leaving the same window to truncate again on
1041
+ // every fire for ever.
1042
+ if (answer.truncated === true)
1043
+ return { status: answer.status, items: 0, truncated: true };
1044
+ const parsed = typeof answer.body === "string" ? JSON.parse(answer.body) : answer.body;
1045
+ const items = parsed?.[binding.queue.itemsField];
1046
+ // A shape this cannot read throws, and a throw counts as work. Coercing it
1047
+ // to zero would answer "quiet" about a queue this never understood, which
1048
+ // is the one answer that loses a record.
1049
+ if (!Array.isArray(items)) {
1050
+ throw new Error(`connector ${binding.name} queue field ${binding.queue.itemsField} is not an array`);
1051
+ }
1052
+ return { status: answer.status, items: items.length, truncated: false };
1053
+ },
1054
+ sweepState: async (accountId) => {
1055
+ // Three reads on this install's own Neo4j, the way `calendarDigest` reads
1056
+ // `:ConnectorBusy`: the nodes are local and there is no cross-plugin call.
1057
+ // `dream-cycle` is the keyword `memory-dream-run` puts on every report it
1058
+ // writes, and it is the same keyword `graph-health`'s own prompt reads
1059
+ // through `memory-report-read-latest`.
1060
+ const session = getSession();
1061
+ try {
1062
+ const reportRes = await session.run(`MATCH (r:Report)
1063
+ WHERE r.accountId = $accountId AND 'dream-cycle' IN r.keywords
1064
+ RETURN toString(r.occurredAt) AS at
1065
+ ORDER BY r.occurredAt DESC LIMIT 1`, { accountId });
1066
+ const at = reportRes.records[0]?.get("at") ?? null;
1067
+ // An account whose sweep has never run holds no report, which reads as
1068
+ // stale. A value this cannot read THROWS rather than coercing to null:
1069
+ // a throw counts as work, while coercing would report a sweep age this
1070
+ // never established.
1071
+ if (at !== null && typeof at !== "string") {
1072
+ throw new Error(`sweep report occurredAt is type ${typeof at}, expected an ISO string`);
1073
+ }
1074
+ // The labels are literals in this file, never operator input, so
1075
+ // interpolating them cannot inject; the account is a parameter.
1076
+ const count = async (label) => {
1077
+ const res = await session.run(`MATCH (n:${label}) WHERE n.accountId = $accountId RETURN count(n) AS c`, { accountId });
1078
+ // neo4j returns a Cypher Integer. `Number` on a shape this cannot read
1079
+ // is NaN, which would digest as the string "NaN" and then never change
1080
+ // again — a gate that has gone quiet for the wrong reason.
1081
+ const n = Number(res.records[0]?.get("c") ?? 0);
1082
+ if (!Number.isFinite(n))
1083
+ throw new Error(`${label} count is not a number`);
1084
+ return n;
1085
+ };
1086
+ return {
1087
+ newestReportAt: at,
1088
+ orphans: await count("OrphanCandidate"),
1089
+ citations: await count("CitationProposal"),
1090
+ };
1091
+ }
1092
+ finally {
1093
+ await session.close();
1094
+ }
1095
+ },
1096
+ filesystemBytes: async (accountId) => {
1097
+ // `$PLATFORM_ROOT/../data/accounts` is the derivation `dispatcher-env.ts`
1098
+ // and `agent-dispatch.ts` already use. Re-deriving it differently here is
1099
+ // how the gate would come to measure a directory the routine never reads.
1100
+ const root = resolve(process.env.PLATFORM_ROOT, "..", "data/accounts", accountId);
1101
+ return measureAccountBytes(root, FS_WALK_ENTRY_CAP);
1102
+ },
743
1103
  calendarDigest: async (check, accountId) => {
744
1104
  const session = getSession();
745
1105
  try {
@@ -800,11 +1160,31 @@ export function checkSource(check) {
800
1160
  return check.botId ?? "telegram";
801
1161
  case "graph":
802
1162
  return "graph";
1163
+ // Task 2684 — the label, because one routine may watch two of them and one
1164
+ // going unreadable is not the other.
1165
+ case "graph-unflagged":
1166
+ return `graph:${check.label}`;
1167
+ // Task 2688 — the scope rather than a connector name: which connectors the
1168
+ // scope resolves to is a property of the account and can change between
1169
+ // fires, so a set built from a name would read as a new outage whenever the
1170
+ // operator registered another one.
1171
+ case "connector":
1172
+ return `connector:${check.scope}`;
1173
+ // Task 2689 — one sweep and one account directory per account, so each
1174
+ // names itself, the way `whatsapp` and `graph` do.
1175
+ case "sweep":
1176
+ return "sweep";
1177
+ case "filesystem":
1178
+ return "filesystem";
803
1179
  default:
804
1180
  return String(check.type);
805
1181
  }
806
1182
  }
807
- async function probe(check, marks, accountId, sources) {
1183
+ async function probe(check, marks, accountId, sources,
1184
+ // Task 2684 — the routine's identity on a node's `gateProcessedBy` list. The
1185
+ // event id, which is also what names the watermark file, so one routine has
1186
+ // one memory whichever form its checks take.
1187
+ flagKey) {
808
1188
  switch (check.type) {
809
1189
  case "mail": {
810
1190
  // Task 2635 — the folder segment lower-cases, so a binding that writes
@@ -953,6 +1333,140 @@ async function probe(check, marks, accountId, sources) {
953
1333
  },
954
1334
  };
955
1335
  }
1336
+ case "graph-unflagged": {
1337
+ // A label outside the accepted set is refused HERE, before the source is
1338
+ // reached, exactly as the mail arm refuses an unknown provider: the throw
1339
+ // lands inside `runSchedulingGate`'s try, where it counts as work and
1340
+ // names itself. Leaving it to the real source would make the refusal a
1341
+ // property of which source is injected, and an injected source answering
1342
+ // quiet about a label nothing can read is the mute this design forbids.
1343
+ const label = unflaggedLabel(check.label);
1344
+ // No watermark and no comparison: the answer IS the set the label holds
1345
+ // that this routine has not flagged. `mark: null` for the reason `d1` has
1346
+ // it — there is no position in a stream to record — but with the opposite
1347
+ // consequence, because the flag the dispatcher writes on these very nodes
1348
+ // is what makes the next fire quiet.
1349
+ const { ids } = await sources.graphUnflagged(accountId, label, flagKey, UNFLAGGED_ID_CAP);
1350
+ return { hasWork: ids.length > 0, mark: null, unflagged: { label, ids } };
1351
+ }
1352
+ case "connector": {
1353
+ // The instant this arm STARTED, recorded as the new position of every
1354
+ // connector that answers. Taken once, before the first call, so a slow
1355
+ // queue cannot advance its own mark past records that arrived while it
1356
+ // was answering.
1357
+ const startedAt = new Date().toISOString();
1358
+ const { pluginPresent, bindings } = await sources.connectorQueues(accountId, check.scope);
1359
+ // Two quiet answers, each with its own reason. Neither is an error: an
1360
+ // install with no connector plugin, and an account that has described no
1361
+ // queue of this kind, both mean nothing registered here can have moved.
1362
+ // The line is load-bearing — a routine that will never fire has to be
1363
+ // visible somewhere, and `op=checked` alone reads exactly like a healthy
1364
+ // quiet fire.
1365
+ if (!pluginPresent) {
1366
+ console.error(`[scheduling-gate] op=connector-quiet reason=no-plugin scope=${check.scope}`);
1367
+ return { hasWork: false, mark: null };
1368
+ }
1369
+ if (bindings.length === 0) {
1370
+ console.error(`[scheduling-gate] op=connector-quiet reason=no-queue scope=${check.scope}`);
1371
+ return { hasWork: false, mark: null };
1372
+ }
1373
+ let hasWork = false;
1374
+ const observed = [];
1375
+ for (const binding of bindings) {
1376
+ const record = () => observed.push({
1377
+ slot: { kind: "connector", key: binding.name },
1378
+ write: (m) => {
1379
+ m.connector = { ...(m.connector ?? {}), [binding.name]: { changedSince: startedAt } };
1380
+ },
1381
+ });
1382
+ const since = marks.connector?.[binding.name]?.changedSince;
1383
+ if (since === undefined) {
1384
+ // A first run: everything already queued counts as work, the same as
1385
+ // every other watermarked check. No call is made, because a read with
1386
+ // no changed-since filter is precisely the response `connector-call`
1387
+ // caps, and a capped body cannot be counted.
1388
+ console.error(`[scheduling-gate] op=connector-first-run connector=${binding.name}`);
1389
+ hasWork = true;
1390
+ record();
1391
+ continue;
1392
+ }
1393
+ const res = await sources.connectorQueueRead(accountId, binding, since);
1394
+ // An unauthorised connector is an ANSWER: it reports no work, records no
1395
+ // position, and says so on its own line. No position, because a
1396
+ // connector this gate could not read must not be advanced past — the
1397
+ // records that arrived while the credential was dead are still unseen.
1398
+ if (res.status === 401 || res.status === 403) {
1399
+ console.error(`[scheduling-gate] op=connector-quiet reason=unauthorised ` +
1400
+ `connector=${binding.name} status=${res.status}`);
1401
+ continue;
1402
+ }
1403
+ // Every other non-2xx throws, which counts as work and fires. A 500 or a
1404
+ // 404 is a source this gate could not read at all, and answering quiet
1405
+ // about it would mute the routine for the length of an outage.
1406
+ if (res.status < 200 || res.status >= 300) {
1407
+ throw new Error(`connector ${binding.name} queue answered ${res.status}`);
1408
+ }
1409
+ console.error(`[scheduling-gate] op=connector-probe connector=${binding.name} ` +
1410
+ `status=${res.status} items=${res.items} truncated=${res.truncated}`);
1411
+ // A capped body is proof of movement that cannot be counted, so it reads
1412
+ // as work and still records its position: recording none would leave the
1413
+ // same window in place, it would truncate again, and the routine would
1414
+ // wake the model on every fire with no path back to quiet.
1415
+ if (res.items > 0 || res.truncated)
1416
+ hasWork = true;
1417
+ record();
1418
+ }
1419
+ return { hasWork, mark: observed.length > 0 ? observed : null };
1420
+ }
1421
+ case "sweep": {
1422
+ const state = await sources.sweepState(accountId);
1423
+ const digest = digestSweepState(state, Date.now());
1424
+ const stored = marks.sweep?.digest;
1425
+ return {
1426
+ // An absent digest is a first run, which reports work exactly as every
1427
+ // other stored-position check does on its first fire.
1428
+ hasWork: stored !== digest,
1429
+ mark: {
1430
+ slot: { kind: "sweep" },
1431
+ write: (m) => {
1432
+ m.sweep = { ...(m.sweep ?? {}), digest };
1433
+ },
1434
+ },
1435
+ };
1436
+ }
1437
+ case "filesystem": {
1438
+ const minGrowth = check.minGrowthBytes ?? FS_DEFAULT_MIN_GROWTH_BYTES;
1439
+ // Refused HERE, before the source is reached, exactly as the mail arm
1440
+ // refuses an unknown provider: the throw lands inside
1441
+ // `runSchedulingGate`'s try, where it counts as work and names itself.
1442
+ // Zero would make every fire pass, because growth is never below zero,
1443
+ // and a gate that can never go quiet is the class this design removes.
1444
+ if (!Number.isInteger(minGrowth) || minGrowth <= 0) {
1445
+ throw new Error(`minGrowthBytes must be a positive integer, got ${JSON.stringify(check.minGrowthBytes)}`);
1446
+ }
1447
+ const { bytes, capped } = await sources.filesystemBytes(accountId);
1448
+ if (capped) {
1449
+ // A floor, not a total, so it records NO position: promoting a floor
1450
+ // would make the next fire measure growth from a number the directory
1451
+ // never held, and the error would be in the muting direction.
1452
+ console.error(`[scheduling-gate] op=fs-measure capped=true bytes=${bytes} treatedAs=has-work`);
1453
+ return { hasWork: true, mark: null };
1454
+ }
1455
+ const stored = marks.filesystem?.bytes;
1456
+ return {
1457
+ // An absent position is a first run: whatever is already there counts
1458
+ // as work, the way every other watermarked check reads its first fire.
1459
+ // A directory that SHRANK reports no work — growth is the question, and
1460
+ // the stored value stays put until an accepted wake moves it.
1461
+ hasWork: stored === undefined || bytes - stored >= minGrowth,
1462
+ mark: {
1463
+ slot: { kind: "filesystem" },
1464
+ write: (m) => {
1465
+ m.filesystem = { ...(m.filesystem ?? {}), bytes };
1466
+ },
1467
+ },
1468
+ };
1469
+ }
956
1470
  // Task 2653 — a type outside the union. The switch was exhaustive over
957
1471
  // `GateCheck` and had no default, so `probe` returned `undefined` and the
958
1472
  // caller's `outcome.hasWork` read threw OUTSIDE its own try: the TypeError
@@ -990,6 +1504,10 @@ export async function runSchedulingGate(params, accountId, platformRoot, sources
990
1504
  // read watermarks, so a check that is not named this fire leaves no stale
991
1505
  // position behind.
992
1506
  const fresh = {};
1507
+ // Task 2684 — the same contract as `fresh`, for the checks whose state is a
1508
+ // per-node flag: only what THIS call read, so nothing a check did not count
1509
+ // can be flagged.
1510
+ const unflagged = [];
993
1511
  const reasons = [];
994
1512
  const errored = [];
995
1513
  // Task 2699 — set ONLY by a check that answered. `hasWork` below stays the
@@ -999,7 +1517,7 @@ export async function runSchedulingGate(params, accountId, platformRoot, sources
999
1517
  for (const check of params.checks) {
1000
1518
  let outcome;
1001
1519
  try {
1002
- outcome = await probe(check, marks, accountId, sources);
1520
+ outcome = await probe(check, marks, accountId, sources, params.eventId);
1003
1521
  }
1004
1522
  catch (err) {
1005
1523
  const detail = err instanceof Error ? err.message : String(err);
@@ -1021,18 +1539,42 @@ export async function runSchedulingGate(params, accountId, platformRoot, sources
1021
1539
  }
1022
1540
  // A check that threw took the `continue` above, so it records no position:
1023
1541
  // its state is unknown, not absent.
1024
- if (outcome.mark) {
1542
+ //
1543
+ // Task 2688 — one check may hand back several positions (one per registered
1544
+ // connector), so the single mark is normalised to a list and each is logged
1545
+ // on its own line.
1546
+ const observedMarks = outcome.mark === null ? [] : Array.isArray(outcome.mark) ? outcome.mark : [outcome.mark];
1547
+ for (const mark of observedMarks) {
1025
1548
  // Every probe records what it saw, whatever the verdict: the position is a
1026
1549
  // fact about the source.
1027
- outcome.mark.write(fresh);
1550
+ mark.write(fresh);
1028
1551
  // Task 2663 — BOTH sides of the comparison. This line printed only
1029
1552
  // `position=`, which was `fresh` — this probe's own reading — so the value
1030
1553
  // the verdict was actually made against appeared in no log line anywhere,
1031
1554
  // and a gate stuck for ever on an empty slot read exactly like a healthy
1032
1555
  // one. Measured on 2026-08-10: 18 wakes whose `position=` never moved.
1033
- console.error(`[scheduling-gate] op=gate-read eventId=${id8} check=${check.type} ` +
1034
- `stored=${slotPosition(outcome.mark.slot, marks) ?? "null"} ` +
1035
- `observed=${slotPosition(outcome.mark.slot, fresh) ?? "null"}`);
1556
+ console.error(
1557
+ // Task 2688 — `slot=` is appended rather than inserted, because one
1558
+ // check can now print several of these lines and only the slot key
1559
+ // tells them apart. It goes last so the fields every existing reader
1560
+ // greps for keep their positions.
1561
+ `[scheduling-gate] op=gate-read eventId=${id8} check=${check.type} ` +
1562
+ `stored=${slotPosition(mark.slot, marks) ?? "null"} ` +
1563
+ `observed=${slotPosition(mark.slot, fresh) ?? "null"} ` +
1564
+ `slot=${slotKey(mark.slot)}`);
1565
+ }
1566
+ // Task 2684 — an empty set carries nothing to flag, so it contributes no
1567
+ // entry: the dispatcher's `op=gate-flag` then names only labels that
1568
+ // actually held work, and a zero line cannot be read as a flag that failed.
1569
+ if (outcome.unflagged && outcome.unflagged.ids.length > 0) {
1570
+ unflagged.push(outcome.unflagged);
1571
+ // Its OWN op, not `op=gate-read`. That line names both sides of a
1572
+ // comparison against a stored position (`stored=`/`observed=`) and this
1573
+ // check makes no comparison, so sharing the name would hand a grep for
1574
+ // `op=gate-read` lines carrying neither field.
1575
+ console.error(`[scheduling-gate] op=gate-outstanding eventId=${id8} label=${outcome.unflagged.label} ` +
1576
+ `outstanding=${outcome.unflagged.ids.length} ` +
1577
+ `capped=${outcome.unflagged.ids.length >= UNFLAGGED_ID_CAP}`);
1036
1578
  }
1037
1579
  }
1038
1580
  // Task 2699 — the disjunction, unchanged in value and now written in one
@@ -1042,6 +1584,6 @@ export async function runSchedulingGate(params, accountId, platformRoot, sources
1042
1584
  console.error(`[scheduling-gate] op=checked hasWork=${hasWork} genuineWork=${genuineWork} ` +
1043
1585
  `reasons=${reasons.join(",")} errored=[${errored.map((e) => e.source).join(",")}] ` +
1044
1586
  `checks=${params.checks.length} ms=${Date.now() - started}`);
1045
- return { hasWork, genuineWork, reasons, errored, observed: fresh };
1587
+ return { hasWork, genuineWork, reasons, errored, observed: fresh, unflagged };
1046
1588
  }
1047
1589
  //# sourceMappingURL=scheduling-gate.js.map