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
|
@@ -17,6 +17,7 @@ import { type GetConfig, readSpaceConfig } from "./config.js";
|
|
|
17
17
|
import {
|
|
18
18
|
formatItemLines,
|
|
19
19
|
formatLocal,
|
|
20
|
+
isAggregatorLink,
|
|
20
21
|
resolveTimezone,
|
|
21
22
|
type StoredItem,
|
|
22
23
|
} from "./items.js";
|
|
@@ -36,6 +37,20 @@ export const MARKER_VERIFY = "[feed-watch:verify]";
|
|
|
36
37
|
/** Path of the human-readable digest log inside a space workspace. */
|
|
37
38
|
export const DIGEST_FILE = "knowledge/feed-digest.md";
|
|
38
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Path of the *current* window, written before the article runs and overwritten
|
|
42
|
+
* every time.
|
|
43
|
+
*
|
|
44
|
+
* `DIGEST_FILE` is the archive and is written on the way out, so during a run it
|
|
45
|
+
* holds every window except the one the agent is being asked about. This file is
|
|
46
|
+
* the one the block can honestly point at: full lines, every item, every URL —
|
|
47
|
+
* including the aggregator redirects the block drops and anything the item cap
|
|
48
|
+
* left out. The prompt carries the summary; the workspace carries the detail,
|
|
49
|
+
* which is the arrangement `CLAUDE.md` asks for (facts come from tools, not
|
|
50
|
+
* prose) and the reason dropping links from the block costs nothing.
|
|
51
|
+
*/
|
|
52
|
+
export const DIGEST_WINDOW_FILE = "knowledge/feed-digest-window.md";
|
|
53
|
+
|
|
39
54
|
/** How many past windows the workspace file keeps. */
|
|
40
55
|
export const DIGEST_FILE_WINDOWS = 30;
|
|
41
56
|
|
|
@@ -55,13 +70,90 @@ export const pendingDigestKey = (spaceId: string) => `digest_until:${spaceId}`;
|
|
|
55
70
|
// ---------------------------------------------------------------------------
|
|
56
71
|
|
|
57
72
|
export interface Digest {
|
|
58
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* The `<feed_watch_digest>` block plus a trailer that promises no workspace
|
|
75
|
+
* file — `body` and `renderDigestTrailer` composed for a caller that writes
|
|
76
|
+
* nothing. `handleBeforeContainer` composes its own, because only a file
|
|
77
|
+
* that is actually on disk may be named.
|
|
78
|
+
*/
|
|
59
79
|
block: string;
|
|
80
|
+
/** The block itself: header, item lines, closing tag. No trailer. */
|
|
81
|
+
body: string;
|
|
60
82
|
/** ISO cursor this window starts at, or null when there was none. */
|
|
61
83
|
since: string | null;
|
|
62
84
|
/** ISO instant the window ends at. */
|
|
63
85
|
until: string;
|
|
86
|
+
/**
|
|
87
|
+
* The whole window, uncapped and with every link intact.
|
|
88
|
+
*
|
|
89
|
+
* `block` is a *rendering* of this and may be smaller in two ways — the item
|
|
90
|
+
* cap and dropped aggregator URLs. Everything downstream that is not a prompt
|
|
91
|
+
* (the cursor, the log line, the workspace file) uses this, so the archive
|
|
92
|
+
* keeps what the prompt could not afford to carry.
|
|
93
|
+
*/
|
|
64
94
|
items: StoredItem[];
|
|
95
|
+
/** How many of `items` the block omitted because the cap bit. */
|
|
96
|
+
omitted: number;
|
|
97
|
+
/** How many of `items` the block actually lists. */
|
|
98
|
+
shownCount: number;
|
|
99
|
+
/** Whether any listed item lost its URL for being aggregator-carried. */
|
|
100
|
+
hasAggregatorLinks: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The prose after the block: what the window is, and where the detail the
|
|
105
|
+
* block traded away can be found.
|
|
106
|
+
*
|
|
107
|
+
* Separate from `buildDigest` on purpose. `windowFile` is not an intention —
|
|
108
|
+
* it is a fact about the filesystem, and the only caller that can establish it
|
|
109
|
+
* is the one that just tried the write. Deciding it up front is how a run ended
|
|
110
|
+
* up telling the model to read `knowledge/feed-digest-window.md` on
|
|
111
|
+
* 2026-08-31 after the write for that same run had thrown.
|
|
112
|
+
*/
|
|
113
|
+
export function renderDigestTrailer(
|
|
114
|
+
digest: Pick<
|
|
115
|
+
Digest,
|
|
116
|
+
"items" | "omitted" | "shownCount" | "hasAggregatorLinks"
|
|
117
|
+
>,
|
|
118
|
+
windowFile: boolean,
|
|
119
|
+
): string {
|
|
120
|
+
return [
|
|
121
|
+
"These are the only items observed by the feed watcher in this window. " +
|
|
122
|
+
"Prefer them over anything remembered from earlier conversation; an item " +
|
|
123
|
+
"not listed here and not verifiable today from a source dated today is " +
|
|
124
|
+
"not news.",
|
|
125
|
+
// Said only when it is true, so the article is never told to go looking for
|
|
126
|
+
// a file that has nothing extra in it.
|
|
127
|
+
digest.omitted > 0
|
|
128
|
+
? `Only the ${digest.shownCount} most recent of ${digest.items.length} matched items are listed here` +
|
|
129
|
+
(windowFile
|
|
130
|
+
? `; the whole window is in \`${DIGEST_WINDOW_FILE}\`.`
|
|
131
|
+
: ".")
|
|
132
|
+
: "",
|
|
133
|
+
digest.hasAggregatorLinks
|
|
134
|
+
? "Items carried by a news aggregator are listed without a URL: search " +
|
|
135
|
+
"the named outlet for the headline" +
|
|
136
|
+
(windowFile
|
|
137
|
+
? `, or read the full lines — every item, every URL — from \`${DIGEST_WINDOW_FILE}\`.`
|
|
138
|
+
: ".")
|
|
139
|
+
: "",
|
|
140
|
+
]
|
|
141
|
+
.filter(Boolean)
|
|
142
|
+
.join(" ");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface BuildDigestOptions {
|
|
146
|
+
/**
|
|
147
|
+
* Close the window at this instant instead of `now`. The `after_container`
|
|
148
|
+
* pass uses it to rebuild the *same* window the prompt carried, minutes after
|
|
149
|
+
* the fact — see `handleAfterContainer`.
|
|
150
|
+
*/
|
|
151
|
+
untilOverride?: string;
|
|
152
|
+
/**
|
|
153
|
+
* Most items to render into `block`, newest first. Omitted items stay in
|
|
154
|
+
* `items`, so the workspace file still receives them.
|
|
155
|
+
*/
|
|
156
|
+
maxItems?: number;
|
|
65
157
|
}
|
|
66
158
|
|
|
67
159
|
/**
|
|
@@ -79,13 +171,9 @@ export function buildDigest(
|
|
|
79
171
|
now: number,
|
|
80
172
|
zone: string,
|
|
81
173
|
log: WatchLogger,
|
|
82
|
-
|
|
83
|
-
* Close the window at this instant instead of `now`. The `after_container`
|
|
84
|
-
* pass uses it to rebuild the *same* window the prompt carried, minutes after
|
|
85
|
-
* the fact — see `handleAfterContainer`.
|
|
86
|
-
*/
|
|
87
|
-
untilOverride?: string,
|
|
174
|
+
opts: BuildDigestOptions = {},
|
|
88
175
|
): Digest | undefined {
|
|
176
|
+
const { untilOverride, maxItems } = opts;
|
|
89
177
|
const raw = store.get(bufferKey(spaceId));
|
|
90
178
|
if (raw === null) return undefined;
|
|
91
179
|
|
|
@@ -112,16 +200,41 @@ export function buildDigest(
|
|
|
112
200
|
return !Number.isFinite(untilMs) || seen <= untilMs;
|
|
113
201
|
});
|
|
114
202
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
203
|
+
// Newest first, then cut. `formatItemLines` sorts the same way, so the lines
|
|
204
|
+
// and the cut agree about which items are the freshest.
|
|
205
|
+
const byRecency = [...items].sort(
|
|
206
|
+
(a, b) =>
|
|
207
|
+
Date.parse(b.publishedAt ?? b.seenAt) -
|
|
208
|
+
Date.parse(a.publishedAt ?? a.seenAt),
|
|
209
|
+
);
|
|
210
|
+
const cap = maxItems && maxItems > 0 ? maxItems : byRecency.length;
|
|
211
|
+
const shown = byRecency.slice(0, cap);
|
|
212
|
+
const omitted = byRecency.length - shown.length;
|
|
213
|
+
|
|
214
|
+
const shownAttr = omitted > 0 ? ` shown="${shown.length}"` : "";
|
|
215
|
+
const header = `<feed_watch_digest since="${since ?? ""}" until="${until}" items="${items.length}"${shownAttr}>`;
|
|
216
|
+
const lines =
|
|
217
|
+
shown.length > 0
|
|
218
|
+
? `\n${formatItemLines(shown, zone, { dropAggregatorLinks: true })}`
|
|
219
|
+
: "";
|
|
220
|
+
|
|
221
|
+
const body = `${header}${lines}\n</feed_watch_digest>`;
|
|
222
|
+
const counts = {
|
|
223
|
+
items,
|
|
224
|
+
omitted,
|
|
225
|
+
shownCount: shown.length,
|
|
226
|
+
hasAggregatorLinks: shown.some((i) => isAggregatorLink(i.link ?? "")),
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
...counts,
|
|
231
|
+
body,
|
|
232
|
+
// Composed for a caller that writes no window file. The one caller that
|
|
233
|
+
// does re-composes with the outcome of its own write.
|
|
234
|
+
block: `${body}\n${renderDigestTrailer(counts, false)}`,
|
|
235
|
+
since,
|
|
236
|
+
until,
|
|
237
|
+
};
|
|
125
238
|
}
|
|
126
239
|
|
|
127
240
|
// ---------------------------------------------------------------------------
|
|
@@ -141,10 +254,16 @@ export interface DigestDeps {
|
|
|
141
254
|
defaultTimezone?: string;
|
|
142
255
|
/** Injectable clock, so a test does not have to reason about wall time. */
|
|
143
256
|
now?: () => number;
|
|
257
|
+
/**
|
|
258
|
+
* Writes `DIGEST_WINDOW_FILE`. Optional: a caller that does not supply it
|
|
259
|
+
* still gets the block, and the block then says nothing about a file it
|
|
260
|
+
* cannot promise exists.
|
|
261
|
+
*/
|
|
262
|
+
writeWindow?: (workspace: string, content: string) => void;
|
|
144
263
|
}
|
|
145
264
|
|
|
146
265
|
/**
|
|
147
|
-
* Append the digest to a prompt that asked for it
|
|
266
|
+
* Append the digest to a prompt that asked for it, **for this run only**.
|
|
148
267
|
*
|
|
149
268
|
* Three ways to decline, all logged at debug and all leaving the run untouched:
|
|
150
269
|
* the prompt did not ask, the space is switched off, or the watcher has no
|
|
@@ -153,7 +272,7 @@ export interface DigestDeps {
|
|
|
153
272
|
export function handleBeforeContainer(
|
|
154
273
|
event: DigestEventLike,
|
|
155
274
|
deps: DigestDeps,
|
|
156
|
-
): {
|
|
275
|
+
): { ephemeralAppend: string } | undefined {
|
|
157
276
|
try {
|
|
158
277
|
if (!event.prompt.includes(MARKER_DIGEST)) return undefined;
|
|
159
278
|
|
|
@@ -167,7 +286,9 @@ export function handleBeforeContainer(
|
|
|
167
286
|
|
|
168
287
|
const now = deps.now?.() ?? Date.now();
|
|
169
288
|
const zone = resolveTimezone(cfg.timezone, deps.defaultTimezone, deps.log);
|
|
170
|
-
const digest = buildDigest(deps.store, event.spaceId, now, zone, deps.log
|
|
289
|
+
const digest = buildDigest(deps.store, event.spaceId, now, zone, deps.log, {
|
|
290
|
+
maxItems: cfg.digestMaxItems,
|
|
291
|
+
});
|
|
171
292
|
if (!digest) {
|
|
172
293
|
deps.log.debug("feed-watch: no buffer yet, digest omitted", {
|
|
173
294
|
spaceId: event.spaceId,
|
|
@@ -175,6 +296,40 @@ export function handleBeforeContainer(
|
|
|
175
296
|
return undefined;
|
|
176
297
|
}
|
|
177
298
|
|
|
299
|
+
// A cap nobody can see is the failure this whole bug was made of. Said at
|
|
300
|
+
// info, with both numbers, so "the article stopped seeing everything" is a
|
|
301
|
+
// grep away rather than a re-derivation.
|
|
302
|
+
if (digest.omitted > 0) {
|
|
303
|
+
deps.log.info("feed-watch: digest capped, older items left to the file", {
|
|
304
|
+
spaceId: event.spaceId,
|
|
305
|
+
window: digest.items.length,
|
|
306
|
+
shown: digest.items.length - digest.omitted,
|
|
307
|
+
omitted: digest.omitted,
|
|
308
|
+
cap: cfg.digestMaxItems,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// The detail the block traded away, put where the agent can fetch it. Wrapped
|
|
313
|
+
// on its own because the governing rule of this module is that the digest is
|
|
314
|
+
// an input, not a dependency: a workspace that cannot be written still gets
|
|
315
|
+
// the article, just without the lookup.
|
|
316
|
+
//
|
|
317
|
+
// The trailer is composed from `windowWritten` *after* this, never from the
|
|
318
|
+
// presence of a writer before it — see `renderDigestTrailer`.
|
|
319
|
+
let windowWritten = false;
|
|
320
|
+
if (deps.writeWindow) {
|
|
321
|
+
try {
|
|
322
|
+
deps.writeWindow(event.workspace, renderWindowFile(digest, zone, now));
|
|
323
|
+
windowWritten = true;
|
|
324
|
+
} catch (err) {
|
|
325
|
+
deps.log.warn("feed-watch: could not write the digest window file", {
|
|
326
|
+
spaceId: event.spaceId,
|
|
327
|
+
error: err instanceof Error ? err.message : String(err),
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const block = `${digest.body}\n${renderDigestTrailer(digest, windowWritten)}`;
|
|
332
|
+
|
|
178
333
|
// Remember exactly where this window closed. `after_container` promotes it
|
|
179
334
|
// into the cursor, so the cursor can never move past an item the agent was
|
|
180
335
|
// not shown.
|
|
@@ -183,10 +338,17 @@ export function handleBeforeContainer(
|
|
|
183
338
|
deps.log.info("feed-watch: digest appended", {
|
|
184
339
|
spaceId: event.spaceId,
|
|
185
340
|
items: digest.items.length,
|
|
341
|
+
blockChars: block.length,
|
|
342
|
+
windowFile: windowWritten,
|
|
186
343
|
since: digest.since,
|
|
187
344
|
until: digest.until,
|
|
188
345
|
});
|
|
189
|
-
|
|
346
|
+
// `ephemeralAppend`, not `promptAppend`: the article's turn is stored with
|
|
347
|
+
// the `[feed-watch:digest]` marker it was written with, and the expansion
|
|
348
|
+
// lives only in the prompt this one run receives. Stored, it would be
|
|
349
|
+
// replayed into every later run by `context.mode = context` — which is
|
|
350
|
+
// exactly what happened, at 103 KB a turn.
|
|
351
|
+
return { ephemeralAppend: block };
|
|
190
352
|
} catch (err) {
|
|
191
353
|
// The article is worth more than the digest. Whatever went wrong here, the
|
|
192
354
|
// run proceeds with the prompt it already had.
|
|
@@ -264,14 +426,11 @@ export function handleAfterContainer(
|
|
|
264
426
|
// Rebuild the window the prompt carried — while `lastDigestKey` still
|
|
265
427
|
// holds the *old* cursor, since that is the window's lower bound. Only
|
|
266
428
|
// then advance it.
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
deps.log,
|
|
273
|
-
shownUntil,
|
|
274
|
-
);
|
|
429
|
+
// No `maxItems` here on purpose: this rebuild feeds the cursor log and the
|
|
430
|
+
// workspace file, and the file is where the capped items have to land.
|
|
431
|
+
const digest = buildDigest(deps.store, event.spaceId, now, zone, deps.log, {
|
|
432
|
+
untilOverride: shownUntil,
|
|
433
|
+
});
|
|
275
434
|
|
|
276
435
|
// Advance and clear unconditionally. Writing the workspace file is
|
|
277
436
|
// best-effort; consuming the window is not, and a buffer that has become
|
|
@@ -294,6 +453,47 @@ export function handleAfterContainer(
|
|
|
294
453
|
}
|
|
295
454
|
}
|
|
296
455
|
|
|
456
|
+
/**
|
|
457
|
+
* Human label for the start of the window.
|
|
458
|
+
*
|
|
459
|
+
* A cursor that will not parse costs the pretty label and nothing else. On
|
|
460
|
+
* 2026-08-31 a malformed cursor threw out of `formatLocal` and took both
|
|
461
|
+
* workspace writes with it — the window file and the archive — while the block
|
|
462
|
+
* it belonged to went out promising the window file was there.
|
|
463
|
+
*/
|
|
464
|
+
function sinceLabelOf(digest: Digest, zone: string): string {
|
|
465
|
+
if (!digest.since) return "the beginning";
|
|
466
|
+
const ms = Date.parse(digest.since);
|
|
467
|
+
return Number.isFinite(ms) ? formatLocal(ms, zone) : digest.since;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* The whole current window as a standalone file: every item, every URL, newest
|
|
472
|
+
* first. Overwritten each article run — it describes one window, not a history,
|
|
473
|
+
* and `DIGEST_FILE` is where windows accumulate.
|
|
474
|
+
*/
|
|
475
|
+
export function renderWindowFile(
|
|
476
|
+
digest: Digest,
|
|
477
|
+
zone: string,
|
|
478
|
+
now: number,
|
|
479
|
+
): string {
|
|
480
|
+
const sinceLabel = sinceLabelOf(digest, zone);
|
|
481
|
+
const body =
|
|
482
|
+
digest.items.length > 0
|
|
483
|
+
? formatItemLines(digest.items, zone)
|
|
484
|
+
: "- (nothing matched in this window)";
|
|
485
|
+
return [
|
|
486
|
+
`# Feed watcher — current window (${digest.items.length} items)`,
|
|
487
|
+
"",
|
|
488
|
+
`Since ${sinceLabel}, up to ${formatLocal(now, zone)}. Overwritten every run.`,
|
|
489
|
+
"Full lines with URLs, including items the prompt's digest summarised or",
|
|
490
|
+
"left out. Grep it for a headline when you need the link.",
|
|
491
|
+
"",
|
|
492
|
+
body,
|
|
493
|
+
"",
|
|
494
|
+
].join("\n");
|
|
495
|
+
}
|
|
496
|
+
|
|
297
497
|
/** Render one window as a section and prepend it to the workspace file. */
|
|
298
498
|
export function renderDigestSection(
|
|
299
499
|
digest: Digest,
|
|
@@ -301,9 +501,7 @@ export function renderDigestSection(
|
|
|
301
501
|
now: number,
|
|
302
502
|
): string {
|
|
303
503
|
const stamp = formatLocal(now, zone);
|
|
304
|
-
const sinceLabel = digest
|
|
305
|
-
? formatLocal(Date.parse(digest.since), zone)
|
|
306
|
-
: "the beginning";
|
|
504
|
+
const sinceLabel = sinceLabelOf(digest, zone);
|
|
307
505
|
const body =
|
|
308
506
|
digest.items.length > 0
|
|
309
507
|
? formatItemLines(digest.items, zone)
|
|
@@ -26,6 +26,7 @@ import { dirname, join } from "node:path";
|
|
|
26
26
|
import { CONFIG_KEYS } from "./config.js";
|
|
27
27
|
import {
|
|
28
28
|
DIGEST_FILE,
|
|
29
|
+
DIGEST_WINDOW_FILE,
|
|
29
30
|
type DigestFileIO,
|
|
30
31
|
handleAfterContainer,
|
|
31
32
|
handleBeforeContainer,
|
|
@@ -90,6 +91,8 @@ interface ContainerEvent {
|
|
|
90
91
|
|
|
91
92
|
interface HookResult {
|
|
92
93
|
promptAppend?: string;
|
|
94
|
+
/** Appended to this run's prompt but kept out of the stored turn. */
|
|
95
|
+
ephemeralAppend?: string;
|
|
93
96
|
}
|
|
94
97
|
|
|
95
98
|
interface MercuryApi {
|
|
@@ -285,6 +288,11 @@ export default function (mercury: MercuryApi) {
|
|
|
285
288
|
store,
|
|
286
289
|
log: ctx.log,
|
|
287
290
|
defaultTimezone: ctx.config.defaultTimezone,
|
|
291
|
+
writeWindow: (workspace, content) => {
|
|
292
|
+
const path = join(workspace, DIGEST_WINDOW_FILE);
|
|
293
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
294
|
+
writeFileSync(path, content, "utf8");
|
|
295
|
+
},
|
|
288
296
|
});
|
|
289
297
|
});
|
|
290
298
|
|
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
* that has learned to read one has learned to read the other. That is why this
|
|
7
7
|
* lives apart from `watch.ts` and `digest.ts` rather than in whichever of them
|
|
8
8
|
* happened to need it first.
|
|
9
|
+
*
|
|
10
|
+
* There is exactly one deliberate divergence, `FormatOptions.dropAggregatorLinks`,
|
|
11
|
+
* and it is about volume rather than meaning: a verify run carries two to four
|
|
12
|
+
* items and exists to open them, while the digest carries a whole day's window
|
|
13
|
+
* into one prompt, where a 330-character redirect URL per item is most of the
|
|
14
|
+
* payload. Any further divergence should be argued the same way or not at all.
|
|
9
15
|
*/
|
|
10
16
|
|
|
11
17
|
import type { FeedItem } from "./feeds.js";
|
|
@@ -131,6 +137,34 @@ export function inWindow(hhmm: string, start: string, end: string): boolean {
|
|
|
131
137
|
// Rendering
|
|
132
138
|
// ---------------------------------------------------------------------------
|
|
133
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Is this link an aggregator redirect rather than a page?
|
|
142
|
+
*
|
|
143
|
+
* Google News RSS gives every item a `news.google.com/rss/articles/CBMi…` URL:
|
|
144
|
+
* an opaque base64 blob averaging **330 characters** that resolves to the
|
|
145
|
+
* outlet only sometimes. `prompts/verify.md` already tells the agent what to do
|
|
146
|
+
* when one fails — "search the outlet named on the line for the headline
|
|
147
|
+
* instead" — so the outlet name and title, which are always present, are the
|
|
148
|
+
* real handle on these items. The URL is mostly weight.
|
|
149
|
+
*/
|
|
150
|
+
export function isAggregatorLink(link: string): boolean {
|
|
151
|
+
return /^https?:\/\/(?:[a-z0-9-]+\.)*news\.google\.[a-z.]+\//i.test(link);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface FormatOptions {
|
|
155
|
+
/**
|
|
156
|
+
* Drop aggregator redirect URLs from the line. The digest sets this; a verify
|
|
157
|
+
* prompt does not.
|
|
158
|
+
*
|
|
159
|
+
* Measured on `football-friends`, 2026-08-31: of 345 buffered items, 203 carried
|
|
160
|
+
* a Google News redirect totalling 67 KB (avg 330 chars) while 142 carried a
|
|
161
|
+
* direct outlet URL totalling 11.5 KB (avg 81). Dropping only the redirects
|
|
162
|
+
* takes a full window from 124 KB to 57 KB and costs nothing the agent could
|
|
163
|
+
* rely on.
|
|
164
|
+
*/
|
|
165
|
+
dropAggregatorLinks?: boolean;
|
|
166
|
+
}
|
|
167
|
+
|
|
134
168
|
/**
|
|
135
169
|
* One item, one line:
|
|
136
170
|
*
|
|
@@ -141,12 +175,18 @@ export function inWindow(hhmm: string, start: string, end: string): boolean {
|
|
|
141
175
|
* clock presented as a publication date is exactly the staleness bug this
|
|
142
176
|
* feature exists to end, so the two are never allowed to look alike.
|
|
143
177
|
*/
|
|
144
|
-
export function formatItemLine(
|
|
178
|
+
export function formatItemLine(
|
|
179
|
+
item: StoredItem,
|
|
180
|
+
zone: string,
|
|
181
|
+
opts: FormatOptions = {},
|
|
182
|
+
): string {
|
|
145
183
|
const when = item.publishedAt
|
|
146
184
|
? formatLocal(Date.parse(item.publishedAt), zone)
|
|
147
185
|
: "?";
|
|
148
186
|
const parts = [when, item.source, item.title];
|
|
149
|
-
|
|
187
|
+
const hideLink =
|
|
188
|
+
opts.dropAggregatorLinks && isAggregatorLink(item.link ?? "");
|
|
189
|
+
if (item.link && !hideLink) parts.push(item.link);
|
|
150
190
|
parts.push(`terms: ${item.terms.join(", ")}`);
|
|
151
191
|
return `- ${parts.join(" | ")}`;
|
|
152
192
|
}
|
|
@@ -162,10 +202,14 @@ export function formatItemLine(item: StoredItem, zone: string): string {
|
|
|
162
202
|
* where it did not — an undated item's `seenAt` is the closest honest stand-in,
|
|
163
203
|
* and it keeps the order total rather than leaving undated items bunched.
|
|
164
204
|
*/
|
|
165
|
-
export function formatItemLines(
|
|
205
|
+
export function formatItemLines(
|
|
206
|
+
items: StoredItem[],
|
|
207
|
+
zone: string,
|
|
208
|
+
opts: FormatOptions = {},
|
|
209
|
+
): string {
|
|
166
210
|
const at = (i: StoredItem) => Date.parse(i.publishedAt ?? i.seenAt);
|
|
167
211
|
return [...items]
|
|
168
212
|
.sort((a, b) => at(b) - at(a))
|
|
169
|
-
.map((i) => formatItemLine(i, zone))
|
|
213
|
+
.map((i) => formatItemLine(i, zone, opts))
|
|
170
214
|
.join("\n");
|
|
171
215
|
}
|