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
@@ -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
- /** The `<feed_watch_digest>` block, ready to append to a prompt. */
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
- const header = `<feed_watch_digest since="${since ?? ""}" until="${until}" items="${items.length}">`;
116
- const lines = items.length > 0 ? `\n${formatItemLines(items, zone)}` : "";
117
- const block =
118
- `${header}${lines}\n</feed_watch_digest>\n` +
119
- "These are the only items observed by the feed watcher in this window. " +
120
- "Prefer them over anything remembered from earlier conversation; an item " +
121
- "not listed here and not verifiable today from a source dated today is " +
122
- "not news.";
123
-
124
- return { block, since, until, items };
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
- ): { promptAppend: string } | undefined {
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
- return { promptAppend: digest.block };
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
- const digest = buildDigest(
268
- deps.store,
269
- event.spaceId,
270
- now,
271
- zone,
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.since
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(item: StoredItem, zone: string): string {
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
- if (item.link) parts.push(item.link);
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(items: StoredItem[], zone: string): string {
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
  }