mercury-agent 0.16.0 → 0.16.2

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 (80) hide show
  1. package/README.md +6 -0
  2. package/container/Dockerfile +6 -1
  3. package/container/Dockerfile.base +6 -1
  4. package/docs/README.md +50 -0
  5. package/docs/authoring-profiles.md +8 -0
  6. package/docs/behavior-layers.md +193 -0
  7. package/docs/extensions.md +2 -0
  8. package/docs/goals/football-reporter-profile/decisions.md +479 -0
  9. package/docs/goals/football-reporter-profile/goal.md +91 -0
  10. package/docs/goals/football-reporter-profile/roadmap.md +196 -0
  11. package/docs/goals/whatsapp-bot-hardening/README.md +43 -0
  12. package/docs/goals/whatsapp-bot-hardening/archive/ambient-group-context.md +211 -0
  13. package/docs/goals/whatsapp-bot-hardening/archive/command-routing-consistency.md +210 -0
  14. package/docs/goals/whatsapp-bot-hardening/archive/destructive-command-guards.md +218 -0
  15. package/docs/goals/whatsapp-bot-hardening/archive/docs-extension.md +182 -0
  16. package/docs/goals/whatsapp-bot-hardening/archive/handoff-2026-08-09.md +588 -0
  17. package/docs/goals/whatsapp-bot-hardening/archive/media-size-and-silent-drop.md +178 -0
  18. package/docs/goals/whatsapp-bot-hardening/archive/member-memory.md +199 -0
  19. package/docs/goals/whatsapp-bot-hardening/archive/message-author-attribution.md +211 -0
  20. package/docs/goals/whatsapp-bot-hardening/archive/run-from-source-switchover.md +212 -0
  21. package/docs/goals/whatsapp-bot-hardening/archive/setup-plan-2026-08-06.md +1117 -0
  22. package/docs/goals/whatsapp-bot-hardening/decisions.md +200 -0
  23. package/docs/goals/whatsapp-bot-hardening/goal.md +75 -0
  24. package/docs/goals/whatsapp-bot-hardening/open-threads.md +396 -0
  25. package/docs/goals/whatsapp-bot-hardening/roadmap.md +245 -0
  26. package/docs/memory.md +30 -0
  27. package/docs/profile-guide.md +483 -0
  28. package/docs/refactor/.gitkeep +0 -0
  29. package/docs/refactor/archive/.gitkeep +0 -0
  30. package/docs/refactor/audits/.gitkeep +0 -0
  31. package/docs/refactor/backlog/.gitkeep +0 -0
  32. package/docs/skills-guide.md +154 -0
  33. package/examples/extensions/README.md +1 -0
  34. package/examples/extensions/feed-watch/config.ts +354 -0
  35. package/examples/extensions/feed-watch/digest.ts +344 -0
  36. package/examples/extensions/feed-watch/feeds.ts +341 -0
  37. package/examples/extensions/feed-watch/index.ts +302 -0
  38. package/examples/extensions/feed-watch/items.ts +171 -0
  39. package/examples/extensions/feed-watch/match.ts +107 -0
  40. package/examples/extensions/feed-watch/prompts/verify.md +35 -0
  41. package/examples/extensions/feed-watch/skill/SKILL.md +128 -0
  42. package/examples/extensions/feed-watch/watch.ts +548 -0
  43. package/examples/extensions/longview/hook.ts +179 -16
  44. package/examples/extensions/longview/index.ts +13 -2
  45. package/examples/extensions/longview/prompts/summarize.md +3 -2
  46. package/examples/extensions/longview/render/telegraph-nodes.ts +21 -1
  47. package/examples/extensions/longview/summarize.ts +57 -14
  48. package/examples/extensions/napkin/index.ts +334 -86
  49. package/examples/extensions/napkin/pi-spawn.ts +196 -0
  50. package/examples/extensions/pinchtab/index.ts +37 -4
  51. package/examples/extensions/pinchtab/lib/session-injector.ts +31 -9
  52. package/examples/extensions/pinchtab/skill/SKILL.md +29 -0
  53. package/examples/profiles/_template/AGENTS.md +138 -0
  54. package/examples/profiles/_template/README.md +65 -0
  55. package/examples/profiles/_template/config.yaml +57 -0
  56. package/examples/profiles/_template/tasks/daily.md +32 -0
  57. package/examples/profiles/football-reporter/AGENTS.md +219 -0
  58. package/examples/profiles/football-reporter/README.md +138 -0
  59. package/examples/profiles/football-reporter/config.yaml +174 -0
  60. package/examples/profiles/football-reporter/seed/MEMORY.md +42 -0
  61. package/examples/profiles/football-reporter/seed/episodes/barcelona-2026-27.md +25 -0
  62. package/examples/profiles/football-reporter/seed/episodes/beitar-jerusalem-2026-27.md +25 -0
  63. package/examples/profiles/football-reporter/seed/episodes/maccabi-haifa-2026-27.md +24 -0
  64. package/examples/profiles/football-reporter/seed/episodes/man-united-2026-27.md +26 -0
  65. package/examples/profiles/football-reporter/seed/episodes/real-madrid-2026-27.md +25 -0
  66. package/examples/profiles/football-reporter/seed/napkin-distill.md +55 -0
  67. package/examples/profiles/football-reporter/tasks/daily-article.md +142 -0
  68. package/package.json +8 -5
  69. package/src/adapters/whatsapp.ts +75 -13
  70. package/src/bridges/whatsapp.ts +6 -4
  71. package/src/cli/mrctl.ts +9 -1
  72. package/src/core/handler.ts +51 -6
  73. package/src/core/routes/dashboard.ts +106 -5
  74. package/src/core/routes/tasks.ts +28 -0
  75. package/src/core/runtime.ts +63 -2
  76. package/src/core/task-scheduler.ts +103 -12
  77. package/src/extensions/catalog.ts +9 -0
  78. package/src/profile/space-profile.ts +780 -0
  79. package/src/storage/db.ts +215 -0
  80. package/src/types.ts +56 -0
package/src/storage/db.ts CHANGED
@@ -12,6 +12,9 @@ import type {
12
12
  SpacePreferenceEntry,
13
13
  SpaceRole,
14
14
  StoredMessage,
15
+ TaskRun,
16
+ TaskRunDetail,
17
+ TaskRunOutcome,
15
18
  TaskRunStatus,
16
19
  TokenUsage,
17
20
  } from "../types.js";
@@ -23,6 +26,39 @@ import type {
23
26
  */
24
27
  export const TASK_LAST_ERROR_MAX_LENGTH = 500;
25
28
 
29
+ /**
30
+ * Ledger rows kept per task. A ceiling rather than a setting: a config key
31
+ * would be a two-surface job (dashboard rows are hardcoded) and nobody has
32
+ * asked to tune it. Questions that reach past this window belong to
33
+ * `token_usage`, which is unbounded.
34
+ *
35
+ * 200 is ~2 months of a thrice-daily task, or a week of a hourly one.
36
+ */
37
+ export const TASK_RUNS_RETENTION_PER_TASK = 200;
38
+
39
+ /**
40
+ * How long a ledger row outlives a task that no longer exists. Completed
41
+ * one-shots leave their rows behind on purpose (see the task_runs DDL), which
42
+ * is bounded per task but not in the number of tasks — feed-watch style
43
+ * verified one-shots would accumulate forever without a horizon. Thirty days
44
+ * comfortably covers the two-week re-audit the ledger exists to serve.
45
+ */
46
+ export const TASK_RUNS_ORPHAN_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
47
+
48
+ /**
49
+ * What a caller must know to write a ledger row: which space it ran in, when
50
+ * it started, whether it was a preview, which attempt it was, and — if the
51
+ * handler reported it — what the run produced.
52
+ */
53
+ export interface TaskRunLedgerEntry {
54
+ spaceId: string;
55
+ startedAt: number;
56
+ detail?: TaskRunDetail;
57
+ /** True for a "Run now" preview, which shares the code path but no schedule. */
58
+ manual: boolean;
59
+ attempt: number;
60
+ }
61
+
26
62
  // `bun:sqlite` caches prepared statements per Database, capped at
27
63
  // Database.MAX_QUERY_CACHE_SIZE (default 20). When a 21st distinct statement is
28
64
  // prepared the least-recently-used one is evicted WITHOUT being finalized, so
@@ -173,6 +209,41 @@ export class Db {
173
209
  CREATE INDEX IF NOT EXISTS idx_tasks_next
174
210
  ON tasks(active, next_run_at);
175
211
 
212
+ -- One row per completed attempt. The tasks table keeps a snapshot of the
213
+ -- last one (last_run_at / last_status / last_error); history needs rows,
214
+ -- not wider columns, and the snapshot is what the dashboard already
215
+ -- reads. Both are written from the same recordTaskRun call, so there is
216
+ -- still exactly one write site per run.
217
+ --
218
+ -- task_id is deliberately NOT a foreign key. A one-shot task is deleted
219
+ -- by the scheduler the moment it completes, and a cascade would erase
220
+ -- its row microseconds after it was written — the normal lifecycle of
221
+ -- an at-task, not an edge case. Rows outlive their task; the user-facing
222
+ -- deletes (deleteTask, deleteSpace) clean up explicitly, and rows left
223
+ -- behind by completed one-shots age out past TASK_RUNS_ORPHAN_MAX_AGE_MS.
224
+ CREATE TABLE IF NOT EXISTS task_runs (
225
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
226
+ task_id INTEGER NOT NULL,
227
+ space_id TEXT NOT NULL,
228
+ started_at INTEGER NOT NULL,
229
+ finished_at INTEGER NOT NULL,
230
+ status TEXT NOT NULL,
231
+ outcome TEXT,
232
+ model TEXT,
233
+ input_tokens INTEGER,
234
+ output_tokens INTEGER,
235
+ total_tokens INTEGER,
236
+ cost REAL,
237
+ reply_chars INTEGER,
238
+ files INTEGER,
239
+ manual INTEGER NOT NULL DEFAULT 0,
240
+ attempt INTEGER NOT NULL DEFAULT 1,
241
+ error TEXT
242
+ );
243
+
244
+ CREATE INDEX IF NOT EXISTS idx_task_runs_task
245
+ ON task_runs(task_id, started_at DESC);
246
+
176
247
  CREATE TABLE IF NOT EXISTS chat_state (
177
248
  space_id TEXT PRIMARY KEY,
178
249
  min_message_id INTEGER NOT NULL DEFAULT 0,
@@ -578,6 +649,7 @@ export class Db {
578
649
  const tasks = this.db
579
650
  .query("DELETE FROM tasks WHERE space_id = ?")
580
651
  .run(spaceId).changes;
652
+ this.db.query("DELETE FROM task_runs WHERE space_id = ?").run(spaceId);
581
653
  const chatState = this.db
582
654
  .query("DELETE FROM chat_state WHERE space_id = ?")
583
655
  .run(spaceId).changes;
@@ -964,6 +1036,31 @@ export class Db {
964
1036
  .run(JSON.stringify(meta), now, messageId);
965
1037
  }
966
1038
 
1039
+ /**
1040
+ * Remove one message row. Returns whether a row was deleted.
1041
+ *
1042
+ * Used for a turn that was written and then decided against — a scheduled
1043
+ * run whose reply turns out to be a no-op leaves a prompt in the history
1044
+ * that no answer will ever follow.
1045
+ *
1046
+ * Both tables that can reference a message are cleared first. `foreign_keys`
1047
+ * is ON, so a platform-id row or another message's `reply_to_id` pointing at
1048
+ * this one would otherwise turn the delete into a constraint failure rather
1049
+ * than a no-op. The `reply_to_id` pointers are nulled rather than followed:
1050
+ * severing a chain is right here, cascading it would delete a real answer.
1051
+ */
1052
+ deleteMessage(id: number): boolean {
1053
+ this.db
1054
+ .query("DELETE FROM message_platform_ids WHERE mercury_message_id = ?")
1055
+ .run(id);
1056
+ this.db
1057
+ .query("UPDATE messages SET reply_to_id = NULL WHERE reply_to_id = ?")
1058
+ .run(id);
1059
+ return (
1060
+ this.db.query("DELETE FROM messages WHERE id = ?").run(id).changes > 0
1061
+ );
1062
+ }
1063
+
967
1064
  clearMessages(spaceId: string): void {
968
1065
  this.db.query("DELETE FROM messages WHERE space_id = ?").run(spaceId);
969
1066
  }
@@ -1333,6 +1430,7 @@ export class Db {
1333
1430
  status: TaskRunStatus,
1334
1431
  error?: string | null,
1335
1432
  at = Date.now(),
1433
+ ledger?: TaskRunLedgerEntry,
1336
1434
  ): void {
1337
1435
  const message =
1338
1436
  status === "error" && error
@@ -1345,6 +1443,115 @@ export class Db {
1345
1443
  WHERE id = ?`,
1346
1444
  )
1347
1445
  .run(at, status, message, Date.now(), id);
1446
+
1447
+ // A four-argument call is exactly the behaviour it always had. Only a
1448
+ // caller that can say *what the run produced* writes history.
1449
+ if (!ledger) return;
1450
+ const usage = ledger.detail?.usage;
1451
+ const outcome: TaskRunOutcome | null =
1452
+ status === "error" ? "error" : (ledger.detail?.outcome ?? null);
1453
+ const tx = this.db.transaction(() => {
1454
+ this.db
1455
+ .query(
1456
+ `INSERT INTO task_runs(
1457
+ task_id, space_id, started_at, finished_at, status, outcome,
1458
+ model, input_tokens, output_tokens, total_tokens, cost,
1459
+ reply_chars, files, manual, attempt, error
1460
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1461
+ )
1462
+ .run(
1463
+ id,
1464
+ ledger.spaceId,
1465
+ ledger.startedAt,
1466
+ at,
1467
+ status,
1468
+ outcome,
1469
+ usage?.model ?? null,
1470
+ usage?.inputTokens ?? null,
1471
+ usage?.outputTokens ?? null,
1472
+ usage?.totalTokens ?? null,
1473
+ usage?.cost ?? null,
1474
+ ledger.detail?.replyChars ?? null,
1475
+ ledger.detail?.files ?? null,
1476
+ ledger.manual ? 1 : 0,
1477
+ ledger.attempt,
1478
+ message,
1479
+ );
1480
+ // Prune inside the same transaction: a reader can never see the table
1481
+ // over its ceiling, and a failed insert takes its own prune with it.
1482
+ this.db
1483
+ .query(
1484
+ `DELETE FROM task_runs
1485
+ WHERE task_id = ?
1486
+ AND id NOT IN (
1487
+ SELECT id FROM task_runs WHERE task_id = ? ORDER BY id DESC LIMIT ?
1488
+ )`,
1489
+ )
1490
+ .run(id, id, TASK_RUNS_RETENTION_PER_TASK);
1491
+ // Rows whose task is gone age out. Cheap: tasks is tiny and task_runs is
1492
+ // bounded, and it runs once per scheduled run, not per read.
1493
+ this.db
1494
+ .query(
1495
+ `DELETE FROM task_runs
1496
+ WHERE finished_at < ?
1497
+ AND task_id NOT IN (SELECT id FROM tasks)`,
1498
+ )
1499
+ .run(at - TASK_RUNS_ORPHAN_MAX_AGE_MS);
1500
+ });
1501
+ tx();
1502
+ }
1503
+
1504
+ /**
1505
+ * The last `limit` attempts of a task, newest first. Empty for a task that
1506
+ * predates the ledger — its history was never recorded, and inventing rows
1507
+ * for it would be worse than saying so.
1508
+ */
1509
+ listTaskRuns(taskId: number, limit = 50): TaskRun[] {
1510
+ const capped = Number.isFinite(limit)
1511
+ ? Math.max(1, Math.min(TASK_RUNS_RETENTION_PER_TASK, Math.floor(limit)))
1512
+ : 50;
1513
+ return this.db
1514
+ .query(
1515
+ `SELECT id,
1516
+ task_id as taskId,
1517
+ space_id as spaceId,
1518
+ started_at as startedAt,
1519
+ finished_at as finishedAt,
1520
+ status,
1521
+ outcome,
1522
+ model,
1523
+ input_tokens as inputTokens,
1524
+ output_tokens as outputTokens,
1525
+ total_tokens as totalTokens,
1526
+ cost,
1527
+ reply_chars as replyChars,
1528
+ files,
1529
+ manual,
1530
+ attempt,
1531
+ error
1532
+ FROM task_runs
1533
+ WHERE task_id = ?
1534
+ ORDER BY id DESC
1535
+ LIMIT ?`,
1536
+ )
1537
+ .all(taskId, capped) as TaskRun[];
1538
+ }
1539
+
1540
+ /**
1541
+ * Replace a task's prompt in place. Schedule, status and ids are untouched,
1542
+ * so a task whose wording changes keeps its next run and its history.
1543
+ *
1544
+ * The scheduler re-reads the row before each run, so a change lands on the
1545
+ * next run; a run already in flight keeps the prompt it started with.
1546
+ *
1547
+ * Returns whether a row was updated — an unknown id is a caller error, not
1548
+ * an exception, matching `deleteTask`.
1549
+ */
1550
+ updateTaskPrompt(id: number, prompt: string): boolean {
1551
+ const result = this.db
1552
+ .query("UPDATE tasks SET prompt = ?, updated_at = ? WHERE id = ?")
1553
+ .run(prompt, Date.now(), id);
1554
+ return result.changes > 0;
1348
1555
  }
1349
1556
 
1350
1557
  setTaskActive(id: number, active: boolean): void {
@@ -1353,10 +1560,18 @@ export class Db {
1353
1560
  .run(active ? 1 : 0, Date.now(), id);
1354
1561
  }
1355
1562
 
1563
+ /**
1564
+ * A user deleting a task takes its run history with it. `deleteTaskById`
1565
+ * deliberately does not: that is the scheduler's path for a completed
1566
+ * one-shot, whose history is the whole point of keeping.
1567
+ */
1356
1568
  deleteTask(id: number, spaceId: string): boolean {
1357
1569
  const result = this.db
1358
1570
  .query("DELETE FROM tasks WHERE id = ? AND space_id = ?")
1359
1571
  .run(id, spaceId);
1572
+ if (result.changes > 0) {
1573
+ this.db.query("DELETE FROM task_runs WHERE task_id = ?").run(id);
1574
+ }
1360
1575
  return result.changes > 0;
1361
1576
  }
1362
1577
 
package/src/types.ts CHANGED
@@ -106,6 +106,62 @@ export interface ScheduledTask {
106
106
  */
107
107
  export type TaskRunStatus = "ok" | "error";
108
108
 
109
+ /**
110
+ * What a scheduled run *produced*, as recorded in the run ledger.
111
+ *
112
+ * Deliberately wider than `TaskRunStatus`, and deliberately not a
113
+ * replacement for it: `posted` and `no_update` are both `status: "ok"`, and
114
+ * telling them apart is the question the 48-hour audit had to read an entire
115
+ * chat to answer. Keeping the two-valued snapshot on the task row means
116
+ * nothing downstream has to learn a new vocabulary.
117
+ *
118
+ * Delivery is not part of this. Whether the space actually saw the output is
119
+ * governed by the task's `silent` flag; folding the two together would make a
120
+ * silent task indistinguishable from a quiet one.
121
+ */
122
+ export type TaskRunOutcome = "posted" | "no_update" | "error";
123
+
124
+ /**
125
+ * What a task handler reports back about a run it completed. Optional at the
126
+ * scheduler boundary: a handler that returns nothing still gets a ledger row,
127
+ * with `outcome` null rather than a guess.
128
+ */
129
+ export interface TaskRunDetail {
130
+ /** `error` is the scheduler's to write, never the handler's. */
131
+ outcome: Exclude<TaskRunOutcome, "error">;
132
+ /** Model, tokens and cost. Absent on an old agent image or non-JSON pi output. */
133
+ usage?: TokenUsage;
134
+ /** Length of the reply the run produced, before any send-path suppression. */
135
+ replyChars: number;
136
+ /** Files the run produced. A no-op reply with files still counts as posted. */
137
+ files: number;
138
+ }
139
+
140
+ /** One completed attempt, as stored in `task_runs`. */
141
+ export interface TaskRun {
142
+ id: number;
143
+ taskId: number;
144
+ spaceId: string;
145
+ startedAt: number;
146
+ finishedAt: number;
147
+ status: TaskRunStatus;
148
+ /** Null when the handler reported no detail. */
149
+ outcome: TaskRunOutcome | null;
150
+ /** The model that actually ran, not the leg index — see the spec. */
151
+ model: string | null;
152
+ inputTokens: number | null;
153
+ outputTokens: number | null;
154
+ totalTokens: number | null;
155
+ cost: number | null;
156
+ replyChars: number | null;
157
+ files: number | null;
158
+ /** 1 when the run came from a "Run now" preview rather than the schedule. */
159
+ manual: number;
160
+ /** Attempt number within the current due cycle, starting at 1. */
161
+ attempt: number;
162
+ error: string | null;
163
+ }
164
+
109
165
  export interface Space {
110
166
  /** User-chosen slug, primary key. e.g. "family", "work" */
111
167
  id: string;