mercury-agent 0.16.2 → 0.17.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 (62) hide show
  1. package/README.md +12 -0
  2. package/container/Dockerfile +1 -0
  3. package/container/Dockerfile.base +1 -0
  4. package/docs/autopilot/config.yaml +43 -0
  5. package/docs/behavior-layers.md +26 -12
  6. package/docs/container-lifecycle.md +1 -0
  7. package/docs/extensions.md +43 -0
  8. package/docs/goals/football-reporter-profile/decisions.md +37 -1
  9. package/docs/goals/football-reporter-profile/roadmap.md +63 -5
  10. package/docs/permissions.md +15 -0
  11. package/docs/profile-guide.md +304 -18
  12. package/docs/skills-guide.md +24 -0
  13. package/examples/extensions/feed-watch/config.ts +36 -0
  14. package/examples/extensions/feed-watch/digest.ts +230 -32
  15. package/examples/extensions/feed-watch/index.ts +8 -0
  16. package/examples/extensions/feed-watch/items.ts +48 -4
  17. package/examples/extensions/overview/actions.ts +264 -0
  18. package/examples/extensions/overview/gate.ts +82 -0
  19. package/examples/extensions/overview/index.ts +123 -0
  20. package/examples/extensions/overview/skill/SKILL.md +80 -0
  21. package/examples/profiles/_template/config.yaml +34 -0
  22. package/examples/profiles/football-reporter/AGENTS.md +24 -6
  23. package/examples/profiles/football-reporter/README.md +9 -1
  24. package/examples/profiles/football-reporter/config.yaml +31 -2
  25. package/examples/profiles/football-reporter/tasks/daily-article.md +6 -4
  26. package/package.json +7 -3
  27. package/resources/templates/mercury.example.yaml +5 -0
  28. package/src/adapters/whatsapp-media.ts +80 -3
  29. package/src/agent/container-entry.ts +143 -21
  30. package/src/agent/container-env.ts +11 -0
  31. package/src/agent/container-error.ts +12 -1
  32. package/src/agent/container-runner.ts +91 -9
  33. package/src/agent/pi-jsonl-parser.ts +166 -5
  34. package/src/bridges/discord.ts +4 -0
  35. package/src/bridges/slack.ts +4 -0
  36. package/src/bridges/teams.ts +4 -0
  37. package/src/bridges/telegram.ts +33 -1
  38. package/src/cli/build-image-tags.ts +34 -0
  39. package/src/cli/mercury.ts +51 -4
  40. package/src/config-file.ts +6 -0
  41. package/src/config.ts +28 -3
  42. package/src/core/attachment-notes.ts +4 -0
  43. package/src/core/confirmation.ts +3 -1
  44. package/src/core/handler.ts +2 -1
  45. package/src/core/media.ts +104 -2
  46. package/src/core/outbox.ts +23 -3
  47. package/src/core/reply-context.ts +61 -0
  48. package/src/core/routes/chat.ts +70 -3
  49. package/src/core/routes/config-builtin.ts +7 -0
  50. package/src/core/routes/dashboard.ts +91 -5
  51. package/src/core/runtime.ts +170 -22
  52. package/src/core/task-output.ts +36 -18
  53. package/src/core/task-scheduler.ts +18 -1
  54. package/src/extensions/catalog.ts +1 -0
  55. package/src/extensions/hooks.ts +8 -0
  56. package/src/extensions/loader.ts +68 -7
  57. package/src/extensions/types.ts +35 -2
  58. package/src/main.ts +19 -0
  59. package/src/server.ts +15 -8
  60. package/src/storage/db.ts +231 -12
  61. package/src/text/sanitize-text.ts +118 -0
  62. package/src/types.ts +73 -1
@@ -379,6 +379,9 @@ export function createDashboardRoutes(ctx: DashboardContext) {
379
379
  });
380
380
  const ambientOn =
381
381
  core.db.getSpaceConfig(spaceId, "ambient.enabled") !== "false";
382
+ // Absent is false: a space opts *in* to owning its persona.
383
+ const personaExclusive =
384
+ core.db.getSpaceConfig(spaceId, "persona.exclusive") === "true";
382
385
  // Read through the same helper the router uses, so the default cannot
383
386
  // drift between what this page shows and what actually gates a message.
384
387
  const mentionAlwaysOn = mentionAlwaysEnabled(core.db, spaceId);
@@ -469,6 +472,14 @@ export function createDashboardRoutes(ctx: DashboardContext) {
469
472
  ${row("trigger.media_in_groups", "trigger.media_in_groups", boolSelect("trigger.media_in_groups", tc.mediaInGroups))}
470
473
  ${row("trigger.mention_always", "trigger.mention_always", boolSelect("trigger.mention_always", mentionAlwaysOn))}
471
474
  ${row("ambient.enabled", "ambient.enabled", boolSelect("ambient.enabled", ambientOn))}
475
+ <p class="muted" style="margin:14px 0 10px;line-height:1.5">
476
+ <span class="mono">persona.exclusive</span> — when true, this space's
477
+ <span class="mono">AGENTS.md</span> is the only persona the model hears:
478
+ the owner's global character and the global
479
+ <span class="mono">AGENTS.md</span> are left out of its prompt. Off by
480
+ default; meant for a space whose standard is repo-owned.
481
+ </p>
482
+ ${row("persona.exclusive", "persona.exclusive", boolSelect("persona.exclusive", personaExclusive))}
472
483
  `;
473
484
  }
474
485
 
@@ -1799,7 +1810,9 @@ export function createDashboardRoutes(ctx: DashboardContext) {
1799
1810
  <td class="muted">${formatRelativeTime(run.startedAt)}</td>
1800
1811
  <td>${outcomeCell(run)}</td>
1801
1812
  <td class="mono muted">${run.model ? escapeHtml(run.model) : "—"}</td>
1802
- <td class="mono">${run.totalTokens === null ? "—" : formatTokenCount(run.totalTokens)}</td>
1813
+ <td class="mono" title="The prompt, measured on the first billed round. Comparable between runs — unlike the billed total, which multiplies by the number of rounds.">${run.promptPrefixTokens === null ? "—" : formatTokenCount(run.promptPrefixTokens)}</td>
1814
+ <td class="mono muted" title="Billed model rounds. Every round re-sends the prompt, which is why cost tracks rounds x prefix.">${run.rounds === null ? "—" : run.rounds}</td>
1815
+ <td class="mono muted" title="Every input-side token billed, summed across all rounds. This is what cost is proportional to. It is NOT the prompt size. A dash on a run that has a cost means the row predates the cache columns and its billed input was never recorded.">${run.billedPromptTokens === null ? "—" : formatTokenCount(run.billedPromptTokens)}</td>
1803
1816
  <td class="mono">${run.cost === null ? "—" : formatCost(run.cost)}</td>
1804
1817
  <td class="mono muted">${duration}</td>
1805
1818
  <td class="mono muted">${run.replyChars === null ? "—" : run.replyChars}</td>
@@ -1811,7 +1824,7 @@ export function createDashboardRoutes(ctx: DashboardContext) {
1811
1824
  `;
1812
1825
  })
1813
1826
  .join("")
1814
- : `<tr><td colspan="11" class="empty">No runs recorded yet${
1827
+ : `<tr><td colspan="13" class="empty">No runs recorded yet${
1815
1828
  task.lastRunAt === null
1816
1829
  ? ""
1817
1830
  : " — this task last ran before the ledger existed"
@@ -1829,7 +1842,9 @@ export function createDashboardRoutes(ctx: DashboardContext) {
1829
1842
  <th>When</th>
1830
1843
  <th>Outcome</th>
1831
1844
  <th>Model</th>
1832
- <th>Tokens</th>
1845
+ <th>Prefix</th>
1846
+ <th>Rounds</th>
1847
+ <th>Billed</th>
1833
1848
  <th>Cost</th>
1834
1849
  <th>Took</th>
1835
1850
  <th>Chars</th>
@@ -2164,8 +2179,47 @@ export function createDashboardRoutes(ctx: DashboardContext) {
2164
2179
  .join("")
2165
2180
  : '<tr><td colspan="5" class="empty">No extensions installed. Add one from the catalog below.</td></tr>';
2166
2181
 
2182
+ // Extensions present on disk but absent from the registry. Without this the
2183
+ // page cannot tell them apart from "not installed" and offers an Install
2184
+ // button that the installer rejects because the files are already there.
2185
+ const inactive = registry.listSkipped();
2186
+ const inactiveNames = new Set(inactive.map((s) => s.name));
2187
+
2188
+ const inactiveRows = inactive
2189
+ .map((skip) => {
2190
+ const cat = getCatalogEntryByName(skip.name);
2191
+ const label = cat?.label ?? skip.name;
2192
+ const desc =
2193
+ cat?.description ?? "Installed extension (outside catalog).";
2194
+ const hint = skip.missingEnvVar
2195
+ ? `Set <span class="mono">${escapeHtml(skip.missingEnvVar)}</span> in the project root <span class="mono">.env</span> and restart Mercury.`
2196
+ : escapeHtml(skip.message);
2197
+ return `
2198
+ <tr>
2199
+ <td class="mono">${escapeHtml(skip.name)}</td>
2200
+ <td>${escapeHtml(label)}</td>
2201
+ <td class="muted" style="max-width:320px">
2202
+ ${escapeHtml(desc)}
2203
+ <div class="muted" style="font-size:12px;margin-top:4px">${hint}</div>
2204
+ </td>
2205
+ <td class="muted">${skip.reason === "credential" ? "missing credential" : "load error"}</td>
2206
+ <td>${
2207
+ // Only the project extensions dir is removable — built-ins ship
2208
+ // with Mercury and removeInstalledExtension cannot delete them.
2209
+ skip.source === "project"
2210
+ ? `<button type="button" class="btn btn-sm btn-danger"
2211
+ hx-delete="/dashboard/api/extensions/${encodeURIComponent(skip.name)}"
2212
+ hx-target="#features-toast"
2213
+ hx-swap="innerHTML"
2214
+ hx-confirm="Remove extension &quot;${escapeHtml(skip.name)}&quot;? It is installed — removing deletes its files.">Remove</button>`
2215
+ : '<span class="muted">built-in</span>'
2216
+ }</td>
2217
+ </tr>`;
2218
+ })
2219
+ .join("");
2220
+
2167
2221
  const available = EXTENSION_CATALOG.filter(
2168
- (e) => !installedNames.has(e.name),
2222
+ (e) => !installedNames.has(e.name) && !inactiveNames.has(e.name),
2169
2223
  );
2170
2224
  const availableRows =
2171
2225
  available.length > 0
@@ -2226,11 +2280,15 @@ export function createDashboardRoutes(ctx: DashboardContext) {
2226
2280
  </div>
2227
2281
  <div class="panel">
2228
2282
  <div class="panel-header">Extension counts</div>
2229
- <div class="panel-body stats" style="grid-template-columns: 1fr 1fr">
2283
+ <div class="panel-body stats" style="grid-template-columns: 1fr 1fr 1fr">
2230
2284
  <div class="stat">
2231
2285
  <div class="stat-value">${installed.length}</div>
2232
2286
  <div class="stat-label">Installed</div>
2233
2287
  </div>
2288
+ <div class="stat">
2289
+ <div class="stat-value">${inactive.length}</div>
2290
+ <div class="stat-label">Inactive</div>
2291
+ </div>
2234
2292
  <div class="stat">
2235
2293
  <div class="stat-value">${nAvail}</div>
2236
2294
  <div class="stat-label">Available to add</div>
@@ -2261,6 +2319,34 @@ export function createDashboardRoutes(ctx: DashboardContext) {
2261
2319
  </div>
2262
2320
  </div>
2263
2321
 
2322
+ ${
2323
+ inactive.length > 0
2324
+ ? raw(`
2325
+ <div class="panel">
2326
+ <div class="panel-header">Installed but inactive</div>
2327
+ <div class="panel-body">
2328
+ <p class="muted" style="margin-bottom:10px;font-size:13px">These extensions are installed on disk but did not load. Installing again will fail — fix the cause below and restart Mercury.</p>
2329
+ <div class="table-scroll">
2330
+ <table class="table">
2331
+ <thead>
2332
+ <tr>
2333
+ <th>Name</th>
2334
+ <th>Label</th>
2335
+ <th>Description</th>
2336
+ <th>Reason</th>
2337
+ <th></th>
2338
+ </tr>
2339
+ </thead>
2340
+ <tbody>
2341
+ ${inactiveRows}
2342
+ </tbody>
2343
+ </table>
2344
+ </div>
2345
+ </div>
2346
+ </div>`)
2347
+ : ""
2348
+ }
2349
+
2264
2350
  <div class="panel">
2265
2351
  <div class="panel-header">Available features</div>
2266
2352
  <div class="panel-body">
@@ -19,6 +19,7 @@ import {
19
19
  import type {
20
20
  ContainerResult,
21
21
  IngressMessage,
22
+ InputSource,
22
23
  MessageAttachment,
23
24
  MessageRunMeta,
24
25
  MessageSender,
@@ -39,10 +40,19 @@ import {
39
40
  createOperatorAlerter,
40
41
  registerCredentialAlertSink,
41
42
  } from "./operator-alerts.js";
42
- import { hasPermission, resolveRole } from "./permissions.js";
43
+ import {
44
+ getRolePermissions,
45
+ hasPermission,
46
+ resolveRole,
47
+ } from "./permissions.js";
43
48
  import { reapProcessTree } from "./process-tree.js";
44
49
  import { getActiveProfilePrompt } from "./profiles.js";
45
50
  import { RateLimiter } from "./rate-limiter.js";
51
+ import {
52
+ hasReplyToBlock,
53
+ scrubDeliverySuffix,
54
+ stripReplyToBlocks,
55
+ } from "./reply-context.js";
46
56
  import { type RouteResult, routeInput } from "./router.js";
47
57
  import { SpaceQueue } from "./space-queue.js";
48
58
  import {
@@ -50,14 +60,16 @@ import {
50
60
  formatSystemMessage,
51
61
  resolveLocale,
52
62
  } from "./system-messages.js";
53
- import { isNoOpTaskReply } from "./task-output.js";
63
+ import { isNoOpTaskReply, isSilenceSentinel } from "./task-output.js";
54
64
  import {
55
65
  TaskDeliveryError,
56
66
  type TaskFailure,
57
67
  TaskScheduler,
58
68
  } from "./task-scheduler.js";
59
69
 
60
- export type InputSource = "cli" | "scheduler" | "chat-sdk";
70
+ // Defined in types.ts so the storage layer can name it; re-exported here for
71
+ // callers that have always imported it from the runtime.
72
+ export type { InputSource };
61
73
 
62
74
  export type ShutdownHook = () => Promise<void> | void;
63
75
 
@@ -839,6 +851,25 @@ export class MercuryCoreRuntime {
839
851
  );
840
852
  return { type: "denied", reason };
841
853
  }
854
+ case "sandbox-unconfined": {
855
+ // Host refused to start the container rather than silently weaken
856
+ // its sandbox. An operator config choice, not a model or auth
857
+ // failure — the actionable detail is in the host log, so the reply
858
+ // stays generic rather than telling the user to try again.
859
+ logger.error(
860
+ "Container start refused: sandbox would be unconfined",
861
+ {
862
+ detail: error.message,
863
+ },
864
+ );
865
+ const reason = friendlyErrorMessage(
866
+ "generic",
867
+ this.config.apiKeyMode,
868
+ this.config.consoleUrl,
869
+ locale,
870
+ );
871
+ return { type: "denied", reason };
872
+ }
842
873
  case "error": {
843
874
  logger.error(
844
875
  "Container error",
@@ -1607,7 +1638,11 @@ export class MercuryCoreRuntime {
1607
1638
  // keep isolating. Fail-safe: if outbound recording ever missed the
1608
1639
  // message, the lookup returns null and isolation still fires.
1609
1640
  let replyIsolated = false;
1610
- let finalPrompt = prompt;
1641
+ // The `_(responded in Ns)_` suffix is delivery decoration the bot never
1642
+ // said (`handler.ts` adds it after the reply is stored). When a member
1643
+ // quotes that reply the adapter copies it into the quote; it comes out
1644
+ // here, at the join, before anything reads or stores the prompt.
1645
+ let finalPrompt = scrubDeliverySuffix(prompt);
1611
1646
  if (
1612
1647
  replyFlags?.isReplyToBot &&
1613
1648
  !replyFlags.isDM &&
@@ -1622,10 +1657,7 @@ export class MercuryCoreRuntime {
1622
1657
  const earlyRole = resolveRole(this.db, spaceId, callerId, seededAdmins);
1623
1658
  if (earlyRole !== "admin" && earlyRole !== "system") {
1624
1659
  replyIsolated = true;
1625
- finalPrompt = finalPrompt.replace(
1626
- /\n*<reply_to[^>]*>[\s\S]*?<\/reply_to>/g,
1627
- "",
1628
- );
1660
+ finalPrompt = stripReplyToBlocks(finalPrompt);
1629
1661
  }
1630
1662
  }
1631
1663
  // ────────────────────────────────────────────────────────────────────
@@ -1685,6 +1717,7 @@ export class MercuryCoreRuntime {
1685
1717
 
1686
1718
  // Emit before_container hook
1687
1719
  let extraEnv: Record<string, string> | undefined;
1720
+ let ephemeralAppend = "";
1688
1721
  if (this.hooks && this.extensionCtx) {
1689
1722
  const result = await this.hooks.emitBeforeContainer(
1690
1723
  {
@@ -1715,6 +1748,10 @@ export class MercuryCoreRuntime {
1715
1748
  .filter(Boolean)
1716
1749
  .join("\n\n");
1717
1750
  }
1751
+ // Kept out of `finalPrompt` on purpose — see `containerPrompt` below.
1752
+ if (result.ephemeralAppend) {
1753
+ ephemeralAppend = result.ephemeralAppend;
1754
+ }
1718
1755
  }
1719
1756
  }
1720
1757
 
@@ -1731,8 +1768,17 @@ export class MercuryCoreRuntime {
1731
1768
  };
1732
1769
  }
1733
1770
 
1771
+ // A space that owns its editorial standard can declare its AGENTS.md the
1772
+ // only persona: the owner's global character is left out of this run's
1773
+ // system prompt and the global AGENTS.md is not mounted (see the runner
1774
+ // input below). Absent is false — every other space is byte-identical.
1775
+ const personaExclusive =
1776
+ this.db.getSpaceConfig(spaceId, "persona.exclusive") === "true";
1777
+
1734
1778
  // Inject global character (owner-set voice, deploy-safe, global-admin gated).
1735
- const characterPrompt = this.db.getProjectConfig("character");
1779
+ const characterPrompt = personaExclusive
1780
+ ? null
1781
+ : this.db.getProjectConfig("character");
1736
1782
  if (characterPrompt) {
1737
1783
  const existing = extraEnv?.MERCURY_EXT_SYSTEM_PROMPT;
1738
1784
  const block = `## Bot Character (set by the owner — applies to all conversations)\n${characterPrompt}`;
@@ -1785,12 +1831,16 @@ export class MercuryCoreRuntime {
1785
1831
  : (this.config.contextWindowSize ?? 10);
1786
1832
 
1787
1833
  if (replyMercuryMsgId !== null) {
1788
- const trimmedWindow = Math.floor(windowSize / 2);
1834
+ // The anchor chain is *added to* the window, not carved out of it.
1835
+ // Halving the window on a swipe-reply meant a reply saw five turns
1836
+ // where a plain message saw ten, and in a space with scheduled
1837
+ // runs those five were mostly procedure. `replyChainDepth` already
1838
+ // bounds the chain, so the total stays bounded either way.
1789
1839
  const anchored = this.db.getAnchoredContext(
1790
1840
  spaceId,
1791
1841
  replyMercuryMsgId,
1792
1842
  replyChainDepth,
1793
- trimmedWindow,
1843
+ windowSize,
1794
1844
  );
1795
1845
  anchorMessages = anchored.anchor;
1796
1846
  history = anchored.recent;
@@ -1825,6 +1875,52 @@ export class MercuryCoreRuntime {
1825
1875
  }
1826
1876
  }
1827
1877
 
1878
+ // ── One copy of the quote ──────────────────────────────────────────
1879
+ // A swipe-reply arrives with the adapter's `<reply_to>` rendering of the
1880
+ // quoted message inside the body. When the same message is about to be
1881
+ // rendered from the DB anyway — in `<reply_anchor>` (context mode) or as
1882
+ // the reply chain (clear mode) — the body copy is dropped, so the turn
1883
+ // reads quote → question and not quote → question → quote. The DB copy
1884
+ // is the better one: it carries the timestamp, the chain above it and
1885
+ // the bot's stored text rather than WhatsApp's rendering of it.
1886
+ //
1887
+ // Keyed on the rows the container will actually see, not on the id
1888
+ // lookup alone: a `/clear` boundary or a zero chain depth can resolve
1889
+ // the id and still leave the message out of the chain, and then the
1890
+ // body copy is the only one. A body that is nothing but the quote is
1891
+ // left whole for the same reason — an empty turn is worse than a
1892
+ // repeated one.
1893
+ if (replyMercuryMsgId !== null && hasReplyToBlock(finalPrompt)) {
1894
+ const quoteRendered = [...(anchorMessages ?? []), ...history].some(
1895
+ (m) => m.id === replyMercuryMsgId,
1896
+ );
1897
+ // "Nothing but the quote" is measured on what the person sent, not
1898
+ // on `finalPrompt`: by now a hook append or a media-gate note may
1899
+ // have been joined on, and those would make an empty body look
1900
+ // non-empty.
1901
+ const personOnly = stripReplyToBlocks(prompt).trim();
1902
+ if (quoteRendered && personOnly) {
1903
+ finalPrompt = stripReplyToBlocks(finalPrompt);
1904
+ }
1905
+ }
1906
+ // ────────────────────────────────────────────────────────────────────
1907
+
1908
+ // What the container is sent, as opposed to what the turn is remembered
1909
+ // as. They differ by `ephemeralAppend`: per-run context an extension
1910
+ // expands for this run only.
1911
+ //
1912
+ // The stored turn keeps the *marker* that asked for the expansion, not
1913
+ // the expansion. Storing the expanded text is what made feed-watch's
1914
+ // 103 KB digest an input to every following run — this space replays the
1915
+ // last N turns (`context.mode = context`), so a block written once was
1916
+ // re-sent, and re-paid for, by every verify run for the rest of the day
1917
+ // (`docs/debug/major/2026-08-31-feed-watch-digest-persisted-and-replayed.md`).
1918
+ //
1919
+ // `AfterContainerResult.storedReply` is the same split on the reply side.
1920
+ const containerPrompt = [finalPrompt, ephemeralAppend]
1921
+ .filter(Boolean)
1922
+ .join("\n\n");
1923
+
1828
1924
  const userMessageId = this.db.addMessage(
1829
1925
  spaceId,
1830
1926
  "user",
@@ -1832,6 +1928,7 @@ export class MercuryCoreRuntime {
1832
1928
  effectiveAttachments,
1833
1929
  userReplyToId,
1834
1930
  { id: callerId, name: authorName },
1931
+ source,
1835
1932
  );
1836
1933
 
1837
1934
  // Record platform message ID mapping for the inbound user message
@@ -1849,16 +1946,21 @@ export class MercuryCoreRuntime {
1849
1946
  }
1850
1947
 
1851
1948
  // Compute caller role, denied CLIs, and permitted env vars
1852
- let callerRole = "member";
1949
+ const seededAdmins = this.config.admins
1950
+ ? this.config.admins
1951
+ .split(",")
1952
+ .map((s) => s.trim())
1953
+ .filter(Boolean)
1954
+ : [];
1955
+ const callerRole = resolveRole(this.db, spaceId, callerId, seededAdmins);
1956
+ // The caller's effective permission set, handed to the container so the
1957
+ // platform prompt can leave out instructions about tools this caller
1958
+ // cannot use ("set a preference", "mute") instead of telling the model to
1959
+ // do a thing and then to refuse it in the same turn.
1960
+ const callerPermissions = [
1961
+ ...getRolePermissions(this.db, spaceId, callerRole),
1962
+ ];
1853
1963
  if (this.extensionRegistry) {
1854
- const seededAdmins = this.config.admins
1855
- ? this.config.admins
1856
- .split(",")
1857
- .map((s) => s.trim())
1858
- .filter(Boolean)
1859
- : [];
1860
- callerRole = resolveRole(this.db, spaceId, callerId, seededAdmins);
1861
-
1862
1964
  const cliExtensions = this.extensionRegistry.getCliExtensions();
1863
1965
  if (cliExtensions.length > 0) {
1864
1966
  const denied = cliExtensions
@@ -1947,15 +2049,17 @@ export class MercuryCoreRuntime {
1947
2049
  spaceWorkspace: workspace,
1948
2050
  messages: history,
1949
2051
  anchorMessages,
1950
- prompt: finalPrompt,
2052
+ prompt: containerPrompt,
1951
2053
  callerId,
1952
2054
  callerRole,
2055
+ callerPermissions,
1953
2056
  authorName,
1954
2057
  attachments: effectiveAttachments,
1955
2058
  preferences,
1956
2059
  members,
1957
2060
  extraEnv,
1958
2061
  claimedEnvSources: this.extensionRegistry?.getClaimedEnvSources(),
2062
+ mountGlobalAgentsMd: !personaExclusive,
1959
2063
  });
1960
2064
  } catch (err) {
1961
2065
  this.db.updateMessageRunMeta(userMessageId, userTurnRunMeta(undefined));
@@ -1970,6 +2074,8 @@ export class MercuryCoreRuntime {
1970
2074
  "[System: this run was killed at the container time limit before replying. Partial work may exist in the workspace.]",
1971
2075
  undefined,
1972
2076
  userMessageId,
2077
+ undefined,
2078
+ source,
1973
2079
  );
1974
2080
  }
1975
2081
  throw err;
@@ -1990,7 +2096,7 @@ export class MercuryCoreRuntime {
1990
2096
  spaceId,
1991
2097
  workspace,
1992
2098
  callerId,
1993
- prompt: finalPrompt,
2099
+ prompt: containerPrompt,
1994
2100
  reply: containerResult.reply,
1995
2101
  durationMs,
1996
2102
  },
@@ -2076,12 +2182,54 @@ export class MercuryCoreRuntime {
2076
2182
  }
2077
2183
  // ─────────────────────────────────────────────────────────────────────
2078
2184
 
2185
+ // ── Chat silence: the sentinel is a legal reply to a person too ──────
2186
+ // "If you have nothing to add, don't" was a rule the space could write
2187
+ // but the runtime could not honour: a chat turn always sent *something*,
2188
+ // and an empty reply became the "Done." placeholder. So the model
2189
+ // answered "מקובל." to "אל תענה לי".
2190
+ //
2191
+ // Narrower than the scheduler's guard on purpose: only the explicit
2192
+ // `NO_UPDATE` sentinel counts, never a bare "ok" or the "Done."
2193
+ // placeholder. In chat those are still the answer to *someone* — a person
2194
+ // who says "thanks" and gets "ok" back got a reply, and a run that
2195
+ // produced no text at all is a failure the person should see, not a
2196
+ // choice the model made. The person's own row stays (they really did
2197
+ // say it); only the assistant row is not written. Files are never
2198
+ // silenced — same rule as the scheduler.
2199
+ const chatSilence =
2200
+ source !== "scheduler" &&
2201
+ containerResult.files.length === 0 &&
2202
+ isSilenceSentinel(containerResult.reply);
2203
+
2204
+ if (chatSilence) {
2205
+ if (containerResult.usage) {
2206
+ this.db.recordUsage(spaceId, containerResult.usage);
2207
+ }
2208
+ this.db.updateMessageRunMeta(
2209
+ userMessageId,
2210
+ userTurnRunMeta(containerResult.usage),
2211
+ );
2212
+ // Info, not debug: the re-audit counts these. A model that goes quiet
2213
+ // often is dodging, and D-018 says that is when the standard's
2214
+ // priorities section needs to say where silence is not allowed.
2215
+ logger.info("Chat turn answered with silence — sending nothing", {
2216
+ spaceId,
2217
+ source,
2218
+ userMessageId,
2219
+ suppressed: containerResult.reply.slice(0, 80),
2220
+ });
2221
+ return { ...containerResult, reply: "", silent: true };
2222
+ }
2223
+ // ─────────────────────────────────────────────────────────────────────
2224
+
2079
2225
  const assistantMessageId = this.db.addMessage(
2080
2226
  spaceId,
2081
2227
  "assistant",
2082
2228
  storedReply,
2083
2229
  undefined,
2084
2230
  userMessageId, // reply chain: assistant replies to user message
2231
+ undefined,
2232
+ source,
2085
2233
  );
2086
2234
 
2087
2235
  if (containerResult.usage) {
@@ -80,6 +80,41 @@ const DECORATION =
80
80
  */
81
81
  const INVISIBLE_CONTROLS = /[؜​-‏‪-‮⁦-⁩]/g;
82
82
 
83
+ const normalize = (s: string) =>
84
+ s.replace(DECORATION, "").toLowerCase().replace(/\s+/g, " ");
85
+
86
+ /**
87
+ * True when the model *chose* silence: the sentinel stands on its own line,
88
+ * first or last.
89
+ *
90
+ * This is the one no-op case that is a decision rather than an absence, and
91
+ * the only one the chat path honours. "Done." is a placeholder the parser
92
+ * substitutes for no text at all, and a bare "ok" in chat is an answer to
93
+ * someone; neither is the model saying "nothing to add". The sentinel is.
94
+ *
95
+ * A model told to emit exactly NO_UPDATE complies partially in both
96
+ * directions — the gloss before the token as often as after — and either way
97
+ * the raw token must not reach the chat. Checking only whole-string equality
98
+ * published it; checking only the first line published it whenever the gloss
99
+ * came first.
100
+ *
101
+ * Still safe against a genuine report that mentions the token: "3 transfers
102
+ * found. NO_UPDATE was not emitted." is a single line, and that line is not
103
+ * the sentinel. (A whole-string check would be redundant with these two: a
104
+ * one-line reply is both its first and its last line.)
105
+ */
106
+ export function isSilenceSentinel(reply: string | null | undefined): boolean {
107
+ if (!reply) return false;
108
+ const stripped = reply
109
+ .replace(INVISIBLE_CONTROLS, "")
110
+ .replace(DECORATION, "");
111
+ const sentinel = NO_OUTPUT_SENTINEL.toLowerCase();
112
+ const lines = stripped.split("\n");
113
+ const firstLine = normalize(lines[0] ?? "");
114
+ const lastLine = normalize(lines[lines.length - 1] ?? "");
115
+ return firstLine === sentinel || lastLine === sentinel;
116
+ }
117
+
83
118
  /**
84
119
  * True when the reply should not be sent anywhere.
85
120
  *
@@ -106,24 +141,7 @@ export function isNoOpTaskReply(reply: string | null | undefined): boolean {
106
141
  const stripped = visible.replace(DECORATION, "");
107
142
  if (stripped.length === 0) return true;
108
143
 
109
- const normalize = (s: string) =>
110
- s.replace(DECORATION, "").toLowerCase().replace(/\s+/g, " ");
111
-
112
- // The sentinel on its own line, first or last. A model told to emit exactly
113
- // NO_UPDATE complies partially in both directions — the gloss before the
114
- // token as often as after — and either way the raw token must not reach the
115
- // chat. Checking only whole-string equality published it; checking only the
116
- // first line published it whenever the gloss came first.
117
- //
118
- // Still safe against a genuine report that mentions the token: "3 transfers
119
- // found. NO_UPDATE was not emitted." is a single line, and that line is not
120
- // the sentinel. (A whole-string check would be redundant with these two: a
121
- // one-line reply is both its first and its last line.)
122
- const sentinel = NO_OUTPUT_SENTINEL.toLowerCase();
123
- const lines = stripped.split("\n");
124
- const firstLine = normalize(lines[0] ?? "");
125
- const lastLine = normalize(lines[lines.length - 1] ?? "");
126
- if (firstLine === sentinel || lastLine === sentinel) return true;
144
+ if (isSilenceSentinel(reply)) return true;
127
145
 
128
146
  return ACKNOWLEDGEMENT_ONLY.has(normalize(stripped));
129
147
  }
@@ -1,6 +1,10 @@
1
1
  import { CronExpressionParser } from "cron-parser";
2
2
  import { logger } from "../logger.js";
3
- import { type Db, TASK_LAST_ERROR_MAX_LENGTH } from "../storage/db.js";
3
+ import {
4
+ type Db,
5
+ TASK_LAST_ERROR_MAX_LENGTH,
6
+ taskRunBilledPromptTokens,
7
+ } from "../storage/db.js";
4
8
  import type { ScheduledTask, TaskRunDetail, TaskRunStatus } from "../types.js";
5
9
 
6
10
  /**
@@ -363,6 +367,19 @@ export class TaskScheduler {
363
367
  outcome: run.status === "error" ? "error" : (run.detail?.outcome ?? null),
364
368
  model: run.detail?.usage?.model ?? null,
365
369
  durationMs: finishedAt - run.startedAt,
370
+ // The prompt (comparable between runs), the multiplier, and the input
371
+ // side the run was actually billed. `totalTokens` is input + output and
372
+ // excludes both cache legs — on a cached leg it is a sliver of the cost
373
+ // and reads as "6.7k tokens, $0.42", which is the misreading this ledger
374
+ // exists to end. Kept for continuity, never alone.
375
+ promptPrefixTokens: run.detail?.usage?.promptPrefixTokens ?? null,
376
+ rounds: run.detail?.usage?.rounds ?? null,
377
+ billedPromptTokens: taskRunBilledPromptTokens({
378
+ inputTokens: run.detail?.usage?.inputTokens ?? null,
379
+ cacheReadTokens: run.detail?.usage?.cacheReadTokens ?? null,
380
+ cacheWriteTokens: run.detail?.usage?.cacheWriteTokens ?? null,
381
+ rounds: run.detail?.usage?.rounds ?? null,
382
+ }),
366
383
  totalTokens: run.detail?.usage?.totalTokens ?? null,
367
384
  cost: run.detail?.usage?.cost ?? null,
368
385
  replyChars: run.detail?.replyChars ?? null,
@@ -66,6 +66,7 @@ export const EXTENSION_CATALOG: ExtensionCatalogEntry[] = [
66
66
  description: "Google Workspace integration (see extension skill).",
67
67
  category: "other",
68
68
  sourceDir: "gws",
69
+ requiredEnvVars: ["MERCURY_GWS_CREDENTIALS_JSON"],
69
70
  requiresRestart: true,
70
71
  },
71
72
  {
@@ -48,6 +48,7 @@ export class HookDispatcher {
48
48
  * Mutation semantics:
49
49
  * - systemPrompt: concatenated across handlers (newline-separated)
50
50
  * - promptAppend: concatenated across handlers (newline-separated)
51
+ * - ephemeralAppend: same, but the caller must keep it out of the stored turn
51
52
  * - env: merged (last-write-wins on key conflict)
52
53
  * - block: first handler to block stops the chain
53
54
  */
@@ -60,6 +61,7 @@ export class HookDispatcher {
60
61
 
61
62
  const systemPromptParts: string[] = [];
62
63
  const promptAppendParts: string[] = [];
64
+ const ephemeralAppendParts: string[] = [];
63
65
  let env: Record<string, string> = {};
64
66
  let hasMutations = false;
65
67
 
@@ -79,6 +81,9 @@ export class HookDispatcher {
79
81
  if (result.promptAppend) {
80
82
  promptAppendParts.push(result.promptAppend);
81
83
  }
84
+ if (result.ephemeralAppend) {
85
+ ephemeralAppendParts.push(result.ephemeralAppend);
86
+ }
82
87
  if (result.env) {
83
88
  env = { ...env, ...result.env };
84
89
  }
@@ -103,6 +108,9 @@ export class HookDispatcher {
103
108
  ...(promptAppendParts.length > 0
104
109
  ? { promptAppend: promptAppendParts.join("\n") }
105
110
  : {}),
111
+ ...(ephemeralAppendParts.length > 0
112
+ ? { ephemeralAppend: ephemeralAppendParts.join("\n") }
113
+ : {}),
106
114
  ...(Object.keys(env).length > 0 ? { env } : {}),
107
115
  };
108
116
  }