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.
- package/README.md +12 -0
- package/container/Dockerfile +1 -0
- package/container/Dockerfile.base +1 -0
- package/docs/autopilot/config.yaml +43 -0
- package/docs/behavior-layers.md +26 -12
- package/docs/container-lifecycle.md +1 -0
- package/docs/extensions.md +43 -0
- package/docs/goals/football-reporter-profile/decisions.md +37 -1
- package/docs/goals/football-reporter-profile/roadmap.md +63 -5
- package/docs/permissions.md +15 -0
- package/docs/profile-guide.md +304 -18
- package/docs/skills-guide.md +24 -0
- package/examples/extensions/feed-watch/config.ts +36 -0
- package/examples/extensions/feed-watch/digest.ts +230 -32
- package/examples/extensions/feed-watch/index.ts +8 -0
- package/examples/extensions/feed-watch/items.ts +48 -4
- package/examples/extensions/overview/actions.ts +264 -0
- package/examples/extensions/overview/gate.ts +82 -0
- package/examples/extensions/overview/index.ts +123 -0
- package/examples/extensions/overview/skill/SKILL.md +80 -0
- package/examples/profiles/_template/config.yaml +34 -0
- package/examples/profiles/football-reporter/AGENTS.md +24 -6
- package/examples/profiles/football-reporter/README.md +9 -1
- package/examples/profiles/football-reporter/config.yaml +31 -2
- package/examples/profiles/football-reporter/tasks/daily-article.md +6 -4
- package/package.json +7 -3
- package/resources/templates/mercury.example.yaml +5 -0
- package/src/adapters/whatsapp-media.ts +80 -3
- package/src/agent/container-entry.ts +143 -21
- package/src/agent/container-env.ts +11 -0
- package/src/agent/container-error.ts +12 -1
- package/src/agent/container-runner.ts +91 -9
- package/src/agent/pi-jsonl-parser.ts +166 -5
- package/src/bridges/discord.ts +4 -0
- package/src/bridges/slack.ts +4 -0
- package/src/bridges/teams.ts +4 -0
- package/src/bridges/telegram.ts +33 -1
- package/src/cli/build-image-tags.ts +34 -0
- package/src/cli/mercury.ts +51 -4
- package/src/config-file.ts +6 -0
- package/src/config.ts +28 -3
- package/src/core/attachment-notes.ts +4 -0
- package/src/core/confirmation.ts +3 -1
- package/src/core/handler.ts +2 -1
- package/src/core/media.ts +104 -2
- package/src/core/outbox.ts +23 -3
- package/src/core/reply-context.ts +61 -0
- package/src/core/routes/chat.ts +70 -3
- package/src/core/routes/config-builtin.ts +7 -0
- package/src/core/routes/dashboard.ts +91 -5
- package/src/core/runtime.ts +170 -22
- package/src/core/task-output.ts +36 -18
- package/src/core/task-scheduler.ts +18 -1
- package/src/extensions/catalog.ts +1 -0
- package/src/extensions/hooks.ts +8 -0
- package/src/extensions/loader.ts +68 -7
- package/src/extensions/types.ts +35 -2
- package/src/main.ts +19 -0
- package/src/server.ts +15 -8
- package/src/storage/db.ts +231 -12
- package/src/text/sanitize-text.ts +118 -0
- 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.
|
|
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="
|
|
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>
|
|
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 "${escapeHtml(skip.name)}"? 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">
|
package/src/core/runtime.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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) {
|
package/src/core/task-output.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
{
|
package/src/extensions/hooks.ts
CHANGED
|
@@ -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
|
}
|