mercury-agent 0.23.2 → 0.24.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 (128) hide show
  1. package/container/Dockerfile +1 -1
  2. package/container/Dockerfile.base +1 -1
  3. package/container/agent-package.json +1 -1
  4. package/docs/configuration.md +60 -0
  5. package/docs/container-lifecycle.md +27 -15
  6. package/docs/extensions.md +36 -0
  7. package/docs/goals/rehearsal-bench/roadmap.md +1 -1
  8. package/docs/goals/release-gate/roadmap.md +8 -8
  9. package/docs/goals/whatsapp-bot-hardening/open-threads.md +18 -0
  10. package/docs/goals/whatsapp-bot-hardening/roadmap.md +1 -1
  11. package/docs/graceful-shutdown.md +21 -5
  12. package/docs/pending-verification.md +195 -4
  13. package/docs/plans/{bugs-0915 → archive/2026-09-15-bugs-0915}/decisions.md +1 -0
  14. package/docs/plans/{bugs-0915 → archive/2026-09-15-bugs-0915}/plan.md +2 -2
  15. package/docs/plans/archive/2026-09-15-bugs-0915/sessions/S1.md +56 -0
  16. package/docs/plans/archive/2026-09-15-bugs-0915/sessions/S2.md +51 -0
  17. package/docs/plans/archive/2026-09-15-bugs-0915/sessions/S3.md +60 -0
  18. package/docs/plans/archive/2026-09-15-bugs-0915/sessions/S4.md +46 -0
  19. package/docs/plans/archive/2026-09-22-p0-bugs-0922/briefs/S1.md +60 -0
  20. package/docs/plans/archive/2026-09-22-p0-bugs-0922/briefs/S2.md +60 -0
  21. package/docs/plans/archive/2026-09-22-p0-bugs-0922/briefs/S3.md +58 -0
  22. package/docs/plans/archive/2026-09-22-p0-bugs-0922/briefs/S4.md +58 -0
  23. package/docs/plans/archive/2026-09-22-p0-bugs-0922/decisions.md +8 -0
  24. package/docs/plans/archive/2026-09-22-p0-bugs-0922/plan.md +102 -0
  25. package/docs/plans/archive/2026-09-22-p0-bugs-0922/sessions/S1.md +29 -0
  26. package/docs/plans/archive/2026-09-22-p0-bugs-0922/sessions/S2.md +29 -0
  27. package/docs/plans/archive/2026-09-22-p0-bugs-0922/sessions/S3.md +30 -0
  28. package/docs/plans/archive/2026-09-22-p0-bugs-0922/sessions/S4.md +32 -0
  29. package/docs/plans/bench-patterns-0915/briefs/S10.md +72 -0
  30. package/docs/plans/bench-patterns-0915/briefs/S11.md +95 -0
  31. package/docs/plans/bench-patterns-0915/briefs/S12.md +87 -0
  32. package/docs/plans/bench-patterns-0915/briefs/S9.md +81 -0
  33. package/docs/plans/bench-patterns-0915/decisions.md +7 -1
  34. package/docs/plans/bench-patterns-0915/plan.md +92 -8
  35. package/docs/plans/bench-patterns-0915/sessions/S10.md +40 -0
  36. package/docs/plans/bench-patterns-0915/sessions/S11.md +53 -0
  37. package/docs/plans/bench-patterns-0915/sessions/S12.md +44 -0
  38. package/docs/plans/bench-patterns-0915/sessions/S4.md +29 -0
  39. package/docs/plans/bench-patterns-0915/sessions/S5.md +30 -0
  40. package/docs/plans/bench-patterns-0915/sessions/S6.md +46 -0
  41. package/docs/plans/bench-patterns-0915/sessions/S7.md +30 -0
  42. package/docs/plans/bench-patterns-0915/sessions/S8.md +30 -0
  43. package/docs/plans/bench-patterns-0915/sessions/S9.md +2 -0
  44. package/docs/plans/flat-0916/briefs/S1.md +55 -0
  45. package/docs/plans/flat-0916/briefs/S2.md +55 -0
  46. package/docs/plans/flat-0916/decisions.md +8 -0
  47. package/docs/plans/flat-0916/plan.md +88 -0
  48. package/docs/plans/p0-plan-0922/briefs/S1.md +60 -0
  49. package/docs/plans/p0-plan-0922/briefs/S2.md +54 -0
  50. package/docs/plans/p0-plan-0922/briefs/S3.md +58 -0
  51. package/docs/plans/p0-plan-0922/briefs/S4.md +54 -0
  52. package/docs/plans/p0-plan-0922/briefs/S5.md +79 -0
  53. package/docs/plans/p0-plan-0922/briefs/S6.md +77 -0
  54. package/docs/plans/p0-plan-0922/briefs/S7.md +79 -0
  55. package/docs/plans/p0-plan-0922/briefs/S8.md +69 -0
  56. package/docs/plans/p0-plan-0922/decisions.md +9 -0
  57. package/docs/plans/p0-plan-0922/plan.md +215 -0
  58. package/docs/plans/p0-plan-0922/sessions/S1.md +22 -0
  59. package/docs/plans/p0-plan-0922/sessions/S2.md +22 -0
  60. package/docs/plans/p0-plan-0922/sessions/S3.md +22 -0
  61. package/docs/plans/p0-plan-0922/sessions/S4.md +22 -0
  62. package/docs/plans/p0-plan-0922/sessions/S5.md +44 -0
  63. package/docs/plans/p0-plan-0922/sessions/S6.md +60 -0
  64. package/docs/plans/p0-plan-0922/sessions/S7.md +69 -0
  65. package/docs/plans/sensitive-bugs-0922/briefs/S1.md +63 -0
  66. package/docs/plans/sensitive-bugs-0922/briefs/S2.md +62 -0
  67. package/docs/plans/sensitive-bugs-0922/briefs/S3.md +61 -0
  68. package/docs/plans/sensitive-bugs-0922/briefs/S4.md +61 -0
  69. package/docs/plans/sensitive-bugs-0922/briefs/S5.md +62 -0
  70. package/docs/plans/sensitive-bugs-0922/decisions.md +9 -0
  71. package/docs/plans/sensitive-bugs-0922/plan.md +147 -0
  72. package/docs/release-gate.md +1 -1
  73. package/docs/smoke-fixtures.md +9 -0
  74. package/examples/extensions/feed-watch/fixtures.ts +210 -0
  75. package/examples/extensions/feed-watch/index.ts +140 -9
  76. package/examples/extensions/office-365-mail/README.md +132 -0
  77. package/examples/extensions/office-365-mail/index.ts +170 -0
  78. package/examples/extensions/office-365-mail/lib/graph.ts +1031 -0
  79. package/examples/extensions/office-365-mail/scripts/login.ts +252 -0
  80. package/examples/extensions/office-365-mail/skill/SKILL.md +94 -0
  81. package/examples/profiles/football-reporter/AGENTS.md +15 -2
  82. package/examples/profiles/football-reporter/config.yaml +5 -1
  83. package/examples/profiles/tel-aviv-flat-search/AGENTS.md +5 -1
  84. package/package.json +10 -6
  85. package/resources/pi-extensions/subagent/index.ts +38 -42
  86. package/resources/pi-extensions/subagent/outcome.ts +28 -0
  87. package/src/adapters/whatsapp-thread.ts +12 -0
  88. package/src/agent/container-entry.ts +40 -7
  89. package/src/agent/container-env.ts +26 -0
  90. package/src/agent/container-error.ts +28 -0
  91. package/src/agent/container-runner.ts +50 -14
  92. package/src/audit/ledger-queries.ts +35 -3
  93. package/src/bridges/slack.ts +4 -1
  94. package/src/bridges/teams.ts +55 -15
  95. package/src/bridges/telegram.ts +3 -1
  96. package/src/cli/mercury.ts +46 -0
  97. package/src/cli/mrctl-http.ts +25 -0
  98. package/src/cli/mrctl.ts +13 -14
  99. package/src/config.ts +70 -34
  100. package/src/core/api.ts +51 -29
  101. package/src/core/boot-reconcile.ts +168 -0
  102. package/src/core/chat-run-journal.ts +383 -0
  103. package/src/core/debounce.ts +97 -1
  104. package/src/core/handler.ts +292 -44
  105. package/src/core/instance-lock.ts +159 -0
  106. package/src/core/operator-alerts.ts +80 -10
  107. package/src/core/pending-ingress.ts +298 -0
  108. package/src/core/routes/dashboard.ts +28 -6
  109. package/src/core/routes/media.ts +22 -0
  110. package/src/core/routes/shadow.ts +28 -3
  111. package/src/core/runtime.ts +139 -4
  112. package/src/core/simulate-ingress.ts +5 -2
  113. package/src/core/space-queue.ts +38 -9
  114. package/src/core/storage-cleanup.ts +16 -8
  115. package/src/core/system-messages.ts +50 -0
  116. package/src/core/task-scheduler.ts +150 -14
  117. package/src/dashboard/index.html +2 -0
  118. package/src/main.ts +121 -2
  119. package/src/ops/shadow-service.ts +113 -1
  120. package/src/ops/shadow-snapshot.ts +126 -0
  121. package/src/preflight/checks/roundtrip.ts +50 -39
  122. package/src/smoke/cli.ts +24 -4
  123. package/src/storage/db.ts +613 -89
  124. package/src/types.ts +20 -3
  125. /package/docs/plans/{bugs-0915 → archive/2026-09-15-bugs-0915}/briefs/S1.md +0 -0
  126. /package/docs/plans/{bugs-0915 → archive/2026-09-15-bugs-0915}/briefs/S2.md +0 -0
  127. /package/docs/plans/{bugs-0915 → archive/2026-09-15-bugs-0915}/briefs/S3.md +0 -0
  128. /package/docs/plans/{bugs-0915 → archive/2026-09-15-bugs-0915}/briefs/S4.md +0 -0
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Shadow fixtures — a chosen feed body served in place of the network.
3
+ *
4
+ * A feed-watch change could be checked only by waiting for the world to
5
+ * publish the right headline. In a **shadow** (a rehearsal instance, never the
6
+ * live bot) an operator can drop a file named after a target's key into
7
+ * `<dataDir>/fixtures/feeds/`, and the next tick reads that file instead of the
8
+ * target's URL. Rehearsal-bench story M1.7, `docs/archive/2026-09-24-shadow-feed-fixtures.md`.
9
+ *
10
+ * **The fixture replaces only the transport.** What this module hands back is a
11
+ * `FetchImpl`; the kind's parser, the redirect policy, the byte and item caps,
12
+ * `HTTP <status>` on a non-ok answer, item ids, matching, `seen` and the verify
13
+ * task all stay the real code in `feeds.ts` / `watch.ts`. A fixture that
14
+ * exercised a parallel path would prove nothing about the live one.
15
+ *
16
+ * No logger and no `ctx` here — pure functions plus one async reader, like
17
+ * `feeds.ts`. `index.ts` owns the wiring and every log line.
18
+ */
19
+
20
+ import { randomInt } from "node:crypto";
21
+ import { readdir, readFile } from "node:fs/promises";
22
+ import { join, resolve } from "node:path";
23
+ import type { FetchImpl } from "./feeds.js";
24
+
25
+ /** `<dataDir>/fixtures/feeds` — the one place a shadow looks. */
26
+ export const FIXTURE_SUBDIR = ["fixtures", "feeds"] as const;
27
+
28
+ /** A fixture whose whole content (trimmed) is this fails its source as HTTP 503. */
29
+ export const DEAD_MARKER = "dead";
30
+
31
+ /** The status a `dead` fixture answers with — the throw a real 503 makes. */
32
+ const DEAD_STATUS = 503;
33
+
34
+ /**
35
+ * The fixture directory, or `null` when fixtures are off.
36
+ *
37
+ * **Absence denies.** Only a literal `shadow === true` turns this on — a
38
+ * missing, false or non-boolean value (`"true"` from a hand-built config) is
39
+ * off — and a shadow without a non-empty `dataDir` has nowhere to look. On
40
+ * the live bot this is one strict equality and the directory is never listed
41
+ * or read.
42
+ *
43
+ * `resolve()` gives the path `loadConfig`'s `resolveProjectPath` gave the
44
+ * shadow data-dir guard (same cwd; `tests/feed-watch-fixtures.test.ts` pins
45
+ * the equivalence). The extension cannot import that function: feed-watch
46
+ * imports nothing from `src/`, and its installed snapshot could not resolve
47
+ * such an import. Never `~/mercury-shadow/current`, which `up` can move, and
48
+ * never `os.homedir()` or an env var.
49
+ */
50
+ export function shadowFixtureDir(config: {
51
+ shadow?: unknown;
52
+ dataDir?: unknown;
53
+ }): string | null {
54
+ if (config.shadow !== true) return null;
55
+ const dataDir = config.dataDir;
56
+ if (typeof dataDir !== "string" || dataDir.length === 0) return null;
57
+ return join(resolve(dataDir), ...FIXTURE_SUBDIR);
58
+ }
59
+
60
+ /**
61
+ * The file name a target key is served from: `encodeURIComponent`, no
62
+ * extension. It round-trips exactly and never contains `/`.
63
+ */
64
+ export function fixtureFileName(targetKey: string): string {
65
+ return encodeURIComponent(targetKey);
66
+ }
67
+
68
+ /**
69
+ * The target key a file name encodes, or `null` for a dotfile (`.gitkeep`, an
70
+ * editor swap file — ignored) or a name `decodeURIComponent` rejects
71
+ * (reported as unknown by the caller).
72
+ */
73
+ export function fixtureKeyOf(fileName: string): string | null {
74
+ if (fileName.startsWith(".")) return null;
75
+ try {
76
+ return decodeURIComponent(fileName);
77
+ } catch {
78
+ // A malformed escape is not a key; the caller warns about it as an
79
+ // unknown file, which is where the operator learns the name is wrong.
80
+ return null;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Nine decimal digits, drawn once per process.
86
+ *
87
+ * Digits only: a Telegram fixture puts it in `data-post="<channel>/{{nonce}}"`,
88
+ * and `TG_POST` in `feeds.ts` accepts `\d{1,12}` and nothing else.
89
+ */
90
+ export function newNonce(): string {
91
+ return String(randomInt(100_000_000, 1_000_000_000));
92
+ }
93
+
94
+ /**
95
+ * Substitute the template tokens, every occurrence. Anything else in double
96
+ * braces is left as written.
97
+ *
98
+ * - `{{nonce}}` — the per-process nonce (item ids: `<guid>`, `<id>`, `data-post`)
99
+ * - `{{now}}` — RFC 822 (`<pubDate>`)
100
+ * - `{{now_iso}}` — ISO 8601 (Atom `<updated>`, Telegram `datetime`)
101
+ */
102
+ export function renderFixture(
103
+ body: string,
104
+ t: { nowMs: number; nonce: string },
105
+ ): string {
106
+ const now = new Date(t.nowMs);
107
+ return body
108
+ .replaceAll("{{nonce}}", t.nonce)
109
+ .replaceAll("{{now_iso}}", now.toISOString())
110
+ .replaceAll("{{now}}", now.toUTCString());
111
+ }
112
+
113
+ export type FixtureHit =
114
+ | { kind: "body"; body: string; file: string }
115
+ | { kind: "dead"; file: string };
116
+
117
+ function isEnoent(err: unknown): boolean {
118
+ return (
119
+ typeof err === "object" &&
120
+ err !== null &&
121
+ (err as { code?: unknown }).code === "ENOENT"
122
+ );
123
+ }
124
+
125
+ function errorCode(err: unknown): string {
126
+ const code =
127
+ typeof err === "object" && err !== null
128
+ ? (err as { code?: unknown }).code
129
+ : undefined;
130
+ if (typeof code === "string" && code.length > 0) return code;
131
+ return err instanceof Error ? err.message : String(err);
132
+ }
133
+
134
+ /**
135
+ * The reader over one fixture directory.
136
+ *
137
+ * `node:fs/promises` only: no sync read on the host thread. A fixture is an
138
+ * operator-written local file in a directory only a shadow reads, so it is not
139
+ * read in bounded chunks; `fetchFeed`'s byte cap still applies to what it
140
+ * serves.
141
+ */
142
+ export function createFixtureSource(dir: string): {
143
+ lookup(targetKey: string): Promise<FixtureHit | null>;
144
+ list(): Promise<string[]>;
145
+ } {
146
+ return {
147
+ /**
148
+ * `null` when no file of that name exists (ENOENT) — the request goes to
149
+ * the network unchanged. **Any other read error rejects** with
150
+ * `fixture unreadable: <code>`: the source then fails visibly and backs
151
+ * off, and never silently turns into a network fetch the operator did not
152
+ * ask for.
153
+ */
154
+ async lookup(targetKey) {
155
+ const file = fixtureFileName(targetKey);
156
+ let body: string;
157
+ try {
158
+ body = await readFile(join(dir, file), "utf8");
159
+ } catch (err) {
160
+ if (isEnoent(err)) return null;
161
+ throw new Error(`fixture unreadable: ${errorCode(err)}`);
162
+ }
163
+ if (body.trim() === DEAD_MARKER) return { kind: "dead", file };
164
+ return { kind: "body", body, file };
165
+ },
166
+ /** Fixture file names, dotfiles excluded; `[]` when the directory is absent. */
167
+ async list() {
168
+ let names: string[];
169
+ try {
170
+ names = await readdir(dir);
171
+ } catch (err) {
172
+ if (isEnoent(err)) return [];
173
+ throw err;
174
+ }
175
+ return names.filter((n) => !n.startsWith(".")).sort();
176
+ },
177
+ };
178
+ }
179
+
180
+ /**
181
+ * The `FetchImpl` that answers one hit. It ignores the URL: the file was
182
+ * chosen by target key, not by URL, and the kind's parser and redirect policy
183
+ * are still chosen by `fetchForTarget` exactly as for the network.
184
+ */
185
+ export function fixtureFetchImpl(
186
+ hit: FixtureHit,
187
+ t: { nowMs: number; nonce: string },
188
+ ): FetchImpl {
189
+ if (hit.kind === "dead") {
190
+ return async () => ({
191
+ ok: false,
192
+ status: DEAD_STATUS,
193
+ text: async () => "",
194
+ });
195
+ }
196
+ const rendered = renderFixture(hit.body, t);
197
+ return async () => ({ ok: true, status: 200, text: async () => rendered });
198
+ }
199
+
200
+ /** Files whose decoded key is not in `knownKeys` (undecodable names included). */
201
+ export function unknownFixtures(
202
+ files: readonly string[],
203
+ knownKeys: ReadonlySet<string>,
204
+ ): string[] {
205
+ return files.filter((f) => {
206
+ if (f.startsWith(".")) return false;
207
+ const key = fixtureKeyOf(f);
208
+ return key === null || !knownKeys.has(key);
209
+ });
210
+ }
@@ -23,7 +23,7 @@
23
23
 
24
24
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
25
25
  import { dirname, join } from "node:path";
26
- import { CONFIG_KEYS } from "./config.js";
26
+ import { CONFIG_KEYS, readSpaceConfig } from "./config.js";
27
27
  import {
28
28
  type DigestFileIO,
29
29
  handleAfterContainer,
@@ -38,8 +38,18 @@ import {
38
38
  type OnTruncate,
39
39
  parseTelegramPreview,
40
40
  } from "./feeds.js";
41
+ import {
42
+ createFixtureSource,
43
+ fixtureFetchImpl,
44
+ fixtureFileName,
45
+ fixtureKeyOf,
46
+ newNonce,
47
+ shadowFixtureDir,
48
+ unknownFixtures,
49
+ } from "./fixtures.js";
41
50
  import type { PostedReply } from "./posted.js";
42
51
  import {
52
+ expandSources,
43
53
  type FetchTarget,
44
54
  type PollDeps,
45
55
  pollSpace,
@@ -59,6 +69,13 @@ export const POLL_INTERVAL_MS = 180_000;
59
69
  */
60
70
  export const LAST_TICK_KEY = "last_tick";
61
71
 
72
+ /**
73
+ * How many `{ key, file }` pairs the unknown-fixture warning carries. A space
74
+ * with a gnews fan-out has one key per term, and a warn line is not the place
75
+ * for all of them; `more` says how many were left out.
76
+ */
77
+ export const FIXTURE_KNOWN_KEYS_SHOWN = 40;
78
+
62
79
  // Ships alongside this extension, so `import.meta.dir` is the installed
63
80
  // extension directory — the same arrangement longview uses for its prompt.
64
81
  const VERIFY_PROMPT_PATH = join(import.meta.dir, "prompts", "verify.md");
@@ -98,7 +115,9 @@ interface ExtensionDb {
98
115
 
99
116
  interface ExtensionContext {
100
117
  db: ExtensionDb;
101
- config: { defaultTimezone?: string };
118
+ // `shadow` and `dataDir` are read only by the fixture lookup, and only a
119
+ // literal `shadow === true` turns it on (`shadowFixtureDir`).
120
+ config: { defaultTimezone?: string; shadow?: boolean; dataDir?: string };
102
121
  // Mirrors `Logger` in `src/logger.ts`: every level takes a message and one
103
122
  // optional `extra`. An extension declares its own structural type and the
104
123
  // loader hands it an untyped object, so nothing would catch a third argument
@@ -281,6 +300,22 @@ export default function (mercury: MercuryApi) {
281
300
  */
282
301
  let missingReadWarned = false;
283
302
 
303
+ // ── Shadow fixtures ──────────────────────────────────────────────────────
304
+
305
+ /**
306
+ * Drawn once per process: every tick of one shadow renders a fixture to the
307
+ * same item ids, so the second tick finds the item in `seen` and opens no
308
+ * second run; the next `shadow up` draws a new one, so the same file is new
309
+ * again against a snapshot that already saw it. No state is deleted.
310
+ */
311
+ const fixtureNonce = newNonce();
312
+
313
+ /** The `shadow fixtures active` line is said on the first tick only. */
314
+ let fixturesAnnounced = false;
315
+
316
+ /** Unknown fixture file names already warned about — once per name per process. */
317
+ const fixtureWarned = new Set<string>();
318
+
284
319
  /**
285
320
  * The one place the host's `listRepliesSince` is reached. Both consumers —
286
321
  * the poller's link drop and the `<recent_posts>` hook — go through it, so
@@ -304,13 +339,16 @@ export default function (mercury: MercuryApi) {
304
339
  };
305
340
  }
306
341
 
307
- function buildDeps(ctx: ExtensionContext): Omit<PollDeps, "log"> & {
342
+ function buildDeps(
343
+ ctx: ExtensionContext,
344
+ fixtures: ReturnType<typeof createFixtureSource> | null,
345
+ ): Omit<PollDeps, "log"> & {
308
346
  log: WatchLogger;
309
347
  } {
310
348
  return {
311
349
  getConfig: (spaceId, key) => ctx.getConfig(spaceId, key),
312
350
  store,
313
- fetchFeed: (target: FetchTarget): Promise<FeedItem[]> => {
351
+ fetchFeed: async (target: FetchTarget): Promise<FeedItem[]> => {
314
352
  // Hoisted now that there are two kinds — M1.2 left it inline in the
315
353
  // `xml` case so that case would diff against the pre-seam closure as
316
354
  // whitespace only. That debt is paid by the second caller, not by a
@@ -325,10 +363,23 @@ export default function (mercury: MercuryApi) {
325
363
  ...detail,
326
364
  });
327
365
  };
328
- return fetchForTarget(target, {
329
- fetchImpl: globalThis.fetch as unknown as FetchImpl,
330
- onTruncate,
331
- });
366
+ let fetchImpl = globalThis.fetch as unknown as FetchImpl;
367
+ // Shadow only. A lookup that rejects (anything but ENOENT) rejects
368
+ // this fetch, so the source fails visibly as `fixture unreadable`
369
+ // rather than quietly reading the network instead.
370
+ const hit = fixtures ? await fixtures.lookup(target.key) : null;
371
+ if (hit) {
372
+ ctx.log.info("feed-watch: serving fixture", {
373
+ source: target.key,
374
+ file: hit.file,
375
+ dead: hit.kind === "dead",
376
+ });
377
+ fetchImpl = fixtureFetchImpl(hit, {
378
+ nowMs: Date.now(),
379
+ nonce: fixtureNonce,
380
+ });
381
+ }
382
+ return fetchForTarget(target, { fetchImpl, onTruncate });
332
383
  },
333
384
  createTask: (spaceId, prompt, runAs) =>
334
385
  ctx.db.createTask(
@@ -355,6 +406,71 @@ export default function (mercury: MercuryApi) {
355
406
  };
356
407
  }
357
408
 
409
+ /**
410
+ * The two fixture lines a shadow tick owes its operator: the directory on
411
+ * the first tick (even at `files: 0` — it proves the lookup is on and names
412
+ * the path to copy into; `setup` has no `ctx`, so the first tick is the
413
+ * earliest `ctx.config` can be read), and each file name that matches no
414
+ * target of any enabled space, once per name per process.
415
+ *
416
+ * Known keys come from the functions `pollSpace` itself uses —
417
+ * `readSpaceConfig` → `expandSources` — so the warning cannot disagree with
418
+ * the poller about what a key is. Not from fetched targets: a target in
419
+ * backoff (one served `dead`, say) is not fetched and would read as unknown.
420
+ * `readSpaceConfig` is called without a logger because `pollSpace` has
421
+ * already said whatever is wrong with the config this tick.
422
+ */
423
+ async function reportFixtures(
424
+ ctx: ExtensionContext,
425
+ dir: string,
426
+ fixtures: ReturnType<typeof createFixtureSource>,
427
+ ): Promise<void> {
428
+ const files = await fixtures.list();
429
+ if (!fixturesAnnounced) {
430
+ fixturesAnnounced = true;
431
+ ctx.log.info("feed-watch: shadow fixtures active", {
432
+ dir,
433
+ files: files.length,
434
+ });
435
+ }
436
+ if (files.every((f) => fixtureWarned.has(f))) return;
437
+
438
+ const known = new Set<string>();
439
+ let enabledSpaces = 0;
440
+ for (const space of ctx.db.listSpaces()) {
441
+ const cfg = readSpaceConfig(
442
+ (spaceId, key) => ctx.getConfig(spaceId, key),
443
+ space.id,
444
+ );
445
+ if (!cfg.enabled) continue;
446
+ enabledSpaces++;
447
+ for (const target of expandSources(cfg)) known.add(target.key);
448
+ }
449
+ // With nothing enabled every file is "unknown", and saying so once would
450
+ // use up the warning before the operator enables the space it was for.
451
+ if (enabledSpaces === 0) return;
452
+
453
+ const unknown = unknownFixtures(files, known).filter(
454
+ (f) => !fixtureWarned.has(f),
455
+ );
456
+ if (unknown.length === 0) return;
457
+ const keys = [...known].sort();
458
+ const shown = keys
459
+ .slice(0, FIXTURE_KNOWN_KEYS_SHOWN)
460
+ .map((key) => ({ key, file: fixtureFileName(key) }));
461
+ for (const file of unknown) {
462
+ fixtureWarned.add(file);
463
+ ctx.log.warn("feed-watch: fixture matches no source", {
464
+ file,
465
+ decoded: fixtureKeyOf(file),
466
+ known: shown,
467
+ ...(keys.length > shown.length
468
+ ? { more: keys.length - shown.length }
469
+ : {}),
470
+ });
471
+ }
472
+ }
473
+
358
474
  mercury.job("poll", {
359
475
  interval: POLL_INTERVAL_MS,
360
476
  run: async (ctx) => {
@@ -379,7 +495,9 @@ export default function (mercury: MercuryApi) {
379
495
  deferred: [] as string[],
380
496
  };
381
497
  try {
382
- const deps = buildDeps(ctx);
498
+ const fixtureDir = shadowFixtureDir(ctx.config);
499
+ const fixtures = fixtureDir ? createFixtureSource(fixtureDir) : null;
500
+ const deps = buildDeps(ctx, fixtures);
383
501
  for (const space of ctx.db.listSpaces()) {
384
502
  tally.spaces++;
385
503
  try {
@@ -405,6 +523,19 @@ export default function (mercury: MercuryApi) {
405
523
  });
406
524
  }
407
525
  }
526
+ if (fixtureDir && fixtures) {
527
+ try {
528
+ await reportFixtures(ctx, fixtureDir, fixtures);
529
+ } catch (err) {
530
+ // Counted like a space's failure, and said: a fixture directory
531
+ // that cannot be listed is the operator's whole rehearsal.
532
+ tally.errors++;
533
+ ctx.log.error("feed-watch: fixture directory unreadable", {
534
+ dir: fixtureDir,
535
+ error: err instanceof Error ? err.message : String(err),
536
+ });
537
+ }
538
+ }
408
539
  } finally {
409
540
  inFlight = false;
410
541
  const elapsedMs = Date.now() - started;
@@ -0,0 +1,132 @@
1
+ # office-365-mail — Outlook / Microsoft 365 mail over Microsoft Graph
2
+
3
+ Read, search, triage, send and reply to a Microsoft 365 (Office 365 / Outlook)
4
+ mailbox from a chat. Third adopter of `mercury.capability()`: the Entra client
5
+ id, the refresh token and every rotated successor stay in the Mercury host
6
+ process and never enter an agent container.
7
+
8
+ Microsoft has switched off basic-auth IMAP/SMTP for Microsoft 365, so this
9
+ extension speaks Microsoft Graph over delegated OAuth2. No CLI is installed in
10
+ the image and there are no runtime dependencies — plain `fetch`.
11
+
12
+ ## What this example shows
13
+
14
+ - `mercury.capability()` with ten verbs the agent calls via `mrctl capability office-365-mail <action> '<json>'`
15
+ - three `hostOnly` env claims, so no `M365_*` value reaches a container
16
+ - a `mercury.connection()` whose daily `healthCheck` is a forced token exchange through the same single-flight path the verbs use
17
+ - a credential that **rotates on every use** — see "How the refresh token is stored"
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ mercury add ./examples/extensions/office-365-mail
23
+ ```
24
+
25
+ No CLI is declared, so no Docker image rebuild is needed.
26
+
27
+ ## One-time setup
28
+
29
+ ### 1. Register a public-client app in Microsoft Entra
30
+
31
+ In the [Entra admin center](https://entra.microsoft.com) → **App registrations** → **New registration**:
32
+
33
+ - Name: anything (e.g. `Mercury mail`)
34
+ - Supported account types: *Accounts in this organizational directory only* is enough for one work mailbox
35
+ - Redirect URI: leave empty (the device-code flow needs none)
36
+
37
+ Then, on the new registration:
38
+
39
+ - **Authentication** → *Advanced settings* → **Allow public client flows: Yes** → Save. Without this the device-code login fails with `AADSTS7000218` / `invalid_client`.
40
+ - **API permissions** → *Add a permission* → *Microsoft Graph* → *Delegated* → add `Mail.ReadWrite`, `Mail.Send`, `User.Read`, `offline_access`. Some tenants require an admin to press **Grant admin consent** for these; if the login step below says consent is required, that is the button.
41
+ - Copy the **Application (client) ID** from *Overview*.
42
+
43
+ No client secret is created — the app is a public client and the login proves the user, not the app.
44
+
45
+ ### 2. Sign in once, on the host
46
+
47
+ ```bash
48
+ bun examples/extensions/office-365-mail/scripts/login.ts --client-id <application-client-id>
49
+ ```
50
+
51
+ (After `mercury add`, the same script lives at `.mercury/extensions/office-365-mail/scripts/login.ts`.)
52
+
53
+ It prints a URL and a short code. Open the URL in any browser, sign in as the
54
+ mailbox owner, enter the code, accept the permissions. The script then prints:
55
+
56
+ ```
57
+ MERCURY_M365_CLIENT_ID=…
58
+ MERCURY_M365_TENANT=organizations
59
+ MERCURY_M365_REFRESH_TOKEN=…
60
+ ```
61
+
62
+ Add those lines to Mercury's `.env` and restart Mercury. All three are claimed
63
+ `hostOnly`.
64
+
65
+ `--tenant` defaults to `organizations` (any work or school account). If Microsoft
66
+ answers `AADSTS90133` ("device code flow is not supported under this
67
+ endpoint"), pass your tenant id or primary domain: `--tenant contoso.onmicrosoft.com`.
68
+ For a personal outlook.com mailbox use `--tenant consumers` (untested).
69
+
70
+ ### 3. Verify
71
+
72
+ ```bash
73
+ mercury chat --space main --caller "<admin-caller-id>" \
74
+ "run exactly: mrctl capability office-365-mail list-folders '{}'"
75
+ ```
76
+
77
+ A non-admin caller is blocked by RBAC (`Forbidden: requires 'office-365-mail' permission`).
78
+ The console's Connections page shows **Office 365 Mail** as connected.
79
+
80
+ ## How the refresh token is stored
81
+
82
+ Microsoft's token endpoint returns a **new refresh token on every exchange**.
83
+ The `.env` value is therefore only a seed:
84
+
85
+ - The newest token lives in Mercury's extension state (`state.db`), under a
86
+ fingerprint of the seed it descends from.
87
+ - An unchanged seed defers to its stored descendant. A **changed** seed (you
88
+ re-ran `login.ts`) wins outright and the old descendant is dropped.
89
+ - Removing the seed from `.env` after the first rotation does **not** break the
90
+ connection — the extension loads on `MERCURY_M365_CLIENT_ID`, not the seed.
91
+ - A failed exchange never deletes the stored token. Microsoft keeps the previous
92
+ token valid, so a transient outage costs nothing.
93
+
94
+ Consequence worth knowing: `state.db` now carries a live mailbox credential.
95
+ Treat its backups the way you treat `.env`.
96
+
97
+ ## When it needs re-authentication
98
+
99
+ The daily connection-health pass does one token exchange. If Microsoft rejects
100
+ the token (`invalid_grant` — password changed, sessions revoked, 90 days of
101
+ inactivity, conditional-access policy), the operator alert names the fix and
102
+ the Connections page shows **needs-reauth**. Re-run step 2, replace the three
103
+ `.env` lines, restart.
104
+
105
+ ## Verbs
106
+
107
+ | Action | Body | Returns |
108
+ |---|---|---|
109
+ | `list-folders` | `{}` | `{ folders: [{ id, name, total, unread }] }` |
110
+ | `list-inbox` | `{ limit?, folder? }` | `{ folder, total, messages: [...] }` |
111
+ | `search` | `{ query, limit?, folder? }` | `{ query, folder, total, messages: [...] }` — `query` is KQL (`from:ann subject:invoice`) |
112
+ | `read` | `{ id }` | message with text `body`, `cc`, `attachments: [{ name, size }]`, `conversationId` |
113
+ | `move` | `{ id, destination }` | `{ ok, id: <new id>, to }` — the id changes |
114
+ | `delete` | `{ id }` | `{ ok, id: <new id>, folder: "deleteditems" }` — a move, recoverable |
115
+ | `mark-read` / `mark-unread` | `{ id }` | `{ ok, id, marked }` |
116
+ | `send` | `{ to, subject, body, cc? }` | `{ ok, to, subject }` |
117
+ | `reply` | `{ id, body }` | `{ ok, id, subject }` |
118
+
119
+ Folders: the well-known names `inbox`, `drafts`, `sentitems`, `deleteditems`,
120
+ `junkemail`, `archive` work in any mailbox language; anything else is matched by
121
+ display name. `limit` defaults to 20 and is capped at 100.
122
+
123
+ Every failure is returned as JSON with Microsoft's own words, never thrown. A
124
+ `401` carries `"reauth": true`.
125
+
126
+ ## Not implemented
127
+
128
+ - Calendar, contacts, OneDrive — different scopes; a separate extension.
129
+ - Attachments beyond names and sizes (no upload on send, no download on read).
130
+ - Application (client-credentials) permissions and shared mailboxes — one delegated mailbox per host.
131
+ - A dashboard "Connect Microsoft" button — the console has no OAuth redirect flow; the host-side login is the operator step, as with `gws auth login`.
132
+ - Nested folders — `list-folders` lists the root level only, and a subfolder's display name is not resolved by `list-inbox`, `search` or `move`. The well-known names and root-level folders cover the common case.