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
@@ -3,8 +3,8 @@
3
3
  This is the how-to for turning Mercury into *a particular assistant*: a
4
4
  barber's booking line, a copywriter's studio assistant, a football reporter in
5
5
  a friends' group, a trial-tier business helper. It is written from the
6
- profiles that exist — the four in `tagula-agents/tagula-agent-profiles`
7
- (barber, lawyers, copywriter, trial) and the football reporter in this repo —
6
+ profiles that exist — the five in `tagula-agents/tagula-agent-profiles`
7
+ (barber, lawyers, copywriter, trial, investor) and the football reporter in this repo —
8
8
  and from what went wrong while building them. Where it states how Mercury
9
9
  behaves, that was verified against the code and the running agent image on
10
10
  2026-08-21; the pointers are there so you can re-verify.
@@ -44,7 +44,7 @@ image — not against the diff.
44
44
  | | Applicative profile (`mercury-profile.yaml`) | Space profile (`config.yaml`) |
45
45
  |---|---|---|
46
46
  | Scope | the whole deployment: every space, every DM auto-space | one space |
47
- | What it carries | global `AGENTS.md`, extensions (capabilities, CLIs, skills), **exhaustive `member_permissions`**, `profile_prompt`, required capabilities, env | the space `AGENTS.md`, task prompts by live id, extension config keys, preferences to delete, workspace files to remove |
47
+ | What it carries | global `AGENTS.md`, extensions (capabilities, CLIs, skills), **exhaustive `member_permissions`**, `profile_prompt`, required capabilities, env | the space `AGENTS.md`, task prompts by live id, extension config keys (incl. `role.member.permissions`, `persona.exclusive`), preferences to delete, workspace files to remove, **workspace files to seed once** (`MEMORY.md`, episode notes, a napkin addendum) |
48
48
  | Applied with | `mercury profiles apply <dir>` (non-interactive) or `mercury setup --profile`; needs a restart, and an image rebuild if it adds CLIs | `bun scripts/space-profile.ts apply --project <dir> --profile <dir>`; no restart |
49
49
  | Verified with | `mercury doctor`, a DM from a non-admin number, the startup warning about excluded member permissions | `space-profile check` (exit 1 + diff on drift); runs on every `~/sync-mercury.sh` |
50
50
  | Loader | `src/core/profiles.ts` | `src/profile/space-profile.ts` |
@@ -134,9 +134,20 @@ reads top-down and the first sections frame the rest.
134
134
  ## Silence when to say nothing and how (NO_UPDATE, "אין חדש")
135
135
  ## Integrity about what was done "I wrote it" only after a successful write; who can change what
136
136
  ## Dedupe / memory routine which file to read before posting and append after
137
+ ## Privacy "you may see it, you may not keep it" — never a denial of what is visible
138
+ ## What this space does NOT do one line each; the reader's expectations, closed explicitly
137
139
  ## Overrides of platform text the explicit sentences (see 4.4)
138
140
  ```
139
141
 
142
+ Two of those sections are easy to skip and expensive to skip. **Privacy** phrased
143
+ as a denial makes the bot lie — the investor profile's line is the honest
144
+ shape: *"you do see the recent messages and the senders' names — do not deny
145
+ it and do not answer 'I have no information'; what is forbidden is to
146
+ **retain** it"* (no names in memory, notes or ledger). **What this space does
147
+ not do** closes the door the persona leaves open: no portfolio, no scheduled
148
+ posts, no legal advice, "you cannot send a message on your own initiative" —
149
+ each one a promise the bot would otherwise make.
150
+
140
151
  Write in the language the bot speaks. Keep every rule **testable from the
141
152
  transcript** ("never more than 3 time slots in one message") rather than
142
153
  aspirational ("be helpful").
@@ -154,6 +165,14 @@ Compare:
154
165
  The second names the tool, the trigger, the reason, and the edge case
155
166
  (volunteering). That is what survives contact with a model under pressure.
156
167
 
168
+ **Attach the failure mode, especially when it is invisible.** The most
169
+ consistent habit across the tagula profiles is a clause after the rule saying
170
+ what goes wrong and why nobody would notice: *"an invented date poisons the
171
+ ledger silently — the record looks perfectly fine, only its score is wrong"*;
172
+ *"no error, the skill just never fires"*; *"the file still looks right"*. A
173
+ model told *why* a shortcut is dangerous takes it less often than one told
174
+ not to; and the sentence doubles as the test case for the fixture harness.
175
+
157
176
  ### 4.3 The no-clock rule (every profile needs one)
158
177
 
159
178
  The model has a `Current date` line (UTC) and a shell; it has no reliable
@@ -191,10 +210,17 @@ win by being specific:
191
210
  default global `AGENTS.md` — a reporter that must give a full factual
192
211
  answer, or a bot that must not ask before acting, says so.
193
212
 
194
- Until [`one-voice-per-space`](backlog/one-voice-per-space.md) lands, also
195
- check the **global** `AGENTS.md` of the deployment: a space profile does not
196
- replace it, and `mercury init`'s template says "helpful AI assistant… ask
197
- before acting".
213
+ Two of the four voices can be removed instead of out-argued. A space that owns
214
+ its whole standard sets `persona.exclusive: "true"` in `extension_config`
215
+ (shipped 2026-08-25, `behavior-layers.md` §1): the host then leaves the
216
+ owner's global Bot Character out of the prompt and does not mount the global
217
+ `AGENTS.md` (`mercury init`'s "helpful AI assistant… ask before acting") for
218
+ that space. The platform paragraphs above still arrive and still need their
219
+ override sentences; and the two capability paragraphs ("set a preference",
220
+ "warn then mute") are already left out for a caller whose permissions cannot
221
+ use them, so a member without `prefs.set` is no longer told to ask the admin.
222
+ Removing a voice beats ranking it — precedence is a coin flip
223
+ (`whatsapp-bot-hardening` A-3) and a fifth voice does not fix four.
198
224
 
199
225
  ### 4.5 Contradictions inside the file
200
226
 
@@ -215,6 +241,13 @@ failure turned into a rule with the exact wording to use. That is how a
215
241
  profile improves: a fixture fails → one sentence in the file → the fixture
216
242
  passes. Keep the sentences, drop the narrative.
217
243
 
244
+ Date the rules that came from a decision — the investor skill writes
245
+ *"Actionable, not flattering (est. 2026-08-11)"* and *"Horizons (approved
246
+ 2026-08-22)"* in the headings. A dated rule can be revisited; an undated one
247
+ is argued from memory. The football profile keeps the dates in
248
+ `decisions.md` instead (every `D-0NN` names the day and its `Revisit if:`),
249
+ which works as long as the file's section names match the decision ids.
250
+
218
251
  ---
219
252
 
220
253
  ## 5. Accuracy: put the computation in code
@@ -257,6 +290,17 @@ And the honest fallback, in the global character and every profile: *"If you
257
290
  are not sure, say so instead of guessing. Never say you searched, read or
258
291
  changed anything unless you just ran the tool that does it."*
259
292
 
293
+ **Make the bot measurable, not just fluent.** A profile that issues
294
+ judgements — verdicts, predictions, "official / here-we-go / rumour" grades —
295
+ records each one as an append-only row with a horizon and a due date, and
296
+ re-analysis writes a *new* row that `supersedes` the old, never an edit. The
297
+ investor profile does it with a tracker (`skills/stock-analysis/SKILL.md` §8:
298
+ one `add` per verdict, a 14-day duplicate rule, "never record who asked");
299
+ the football reporter does it with the notebook's `## Current State` /
300
+ `## History` split plus the host's `task_runs` ledger. Either way the bot's
301
+ own claims become something a script can grade a month later, which is what
302
+ turns "the bot was wrong about X" from an anecdote into a rate.
303
+
260
304
  ---
261
305
 
262
306
  ## 6. Memory: design it, do not hope for it
@@ -296,12 +340,15 @@ What that means for a profile:
296
340
  in the engine. "The tracker is the source of truth — never answer 'what is
297
341
  the status?' from memory."
298
342
  6. **Size the history window to the space.** `context.mode` defaults to
299
- `clear` for a hand-created space; `context.window_size` counts user turns
300
- and scheduled runs are user turns. A space with several scheduled runs a
301
- day loses its own chat within hours — which is how the football bot
302
- contradicted its own lineup. Until `chat-window-and-silence` (M2.5)
303
- excludes scheduler rows, size the window for the scheduled volume, and
304
- never rely on the window for anything older than the current exchange.
343
+ `clear` for a hand-created space; `context.window_size` counts turns of
344
+ *conversation* — a person's message (reply included), or one of the bot's
345
+ own scheduled posts. Since `chat-window-and-silence` (M2.5) a scheduled
346
+ run's prompt is not in the window at all, so procedure can no longer crowd
347
+ out chat — which is how the football bot once contradicted its own lineup.
348
+ The bot's scheduled *posts* still hold a slot each, so a space that mostly
349
+ publishes still fills its window with its own articles: size the window
350
+ for how far back a follow-up should reach, and rely on the notebook, not
351
+ the window, for facts.
305
352
 
306
353
  ---
307
354
 
@@ -319,6 +366,24 @@ rules are:
319
366
  - When the host can do the work, prefer a **marker the host expands**
320
367
  (`[feed-watch:digest]`) over a skill the model must remember to run.
321
368
  - Verify use with a trace (`agent.trace_runs`), not with a correct answer.
369
+ - **Formatting is a skill plus a lint, sharing one rule list.** The investor
370
+ profile moves every platform-rendering rule out of `AGENTS.md` into
371
+ `skills/whatsapp-formatting/SKILL.md` — "load this before composing *any*
372
+ message; not optional" — and keeps a one-line digest in `AGENTS.md` so the
373
+ rule survives an unread file. The skill is **grounded in the send
374
+ pipeline** and says so: Mercury's `applyRtlDirection` (`src/text/rtl.ts`)
375
+ strips leading bidi marks and prepends its own RLM per line, so a skill
376
+ telling the model to add RLE/RLM is telling it to do work the host
377
+ discards; the rule that remains is "start every line with a Hebrew word or
378
+ an emoji, never a ticker or a number". Likewise `normalizeChatMarkdown`
379
+ (`src/text/markdown.ts`) rewrites `**` and headings at send — the DB row is
380
+ what the model wrote, not what WhatsApp showed. Before writing a formatting
381
+ rule, read those two files and state which rules the host already
382
+ enforces. The countable rules (line length, emoji policy, `(source, dd/mm)`
383
+ per item, no HTML, no `**`) live in exactly one list that both the skill's
384
+ "checklist before sending" and the output lint (§11) are generated from —
385
+ tagula has the skill and no lint; the football harness is the first lint,
386
+ and its spec must not drift from the `## פורמט` section it measures.
322
387
 
323
388
  ---
324
389
 
@@ -344,6 +409,23 @@ rules are:
344
409
  cron in the README; `check` reports a deleted row as drift.
345
410
  - Restarting the service kills in-flight runs and loses the retry — check
346
411
  the crons before a restart.
412
+ - **A quiet run is not a cheap run.** Every scheduled run — a verify one-shot
413
+ that answers `NO_UPDATE`, a scan that finds nothing — assembles the same
414
+ prompt stack as chat and pays for it before it says a word: the space
415
+ `AGENTS.md` (17 KB for the football reporter), `MEMORY.md`, the injected
416
+ notes, the digest, plus whatever the notebook rule makes it read. Feed-watch
417
+ replaced five blind scans a day (~$3) with ~46 verify runs a day, 70 % of
418
+ them `no_update` at $0.25–0.75 each — **$14–20/day** for one space. Budget a
419
+ profile by *runs per day × cost per run* from the ledger after the first
420
+ day, and cap the number of runs (`max_per_hour`, `batch_minutes`, a tighter
421
+ `exclude`, quiet hours) before touching prompt length. A host-side
422
+ pre-filter that opens no container is the only run that is free.
423
+ - **Model is per space and changes without a deploy.** `model.active` is a
424
+ space-config row the owner can flip from chat, and a profile tuned on one
425
+ model behaves differently on another — Sonnet narrates "Now sending the
426
+ chat reply." into the sent text where Opus did not. The ledger records the
427
+ model per run; read it before blaming the prompt, and re-run the fixtures
428
+ after a switch.
347
429
 
348
430
  ---
349
431
 
@@ -400,8 +482,37 @@ live `AGENTS.md` or a task row by hand — `check` calls it drift and the next
400
482
  tests/ runbook + fixtures
401
483
  ```
402
484
 
403
- Lessons from four deployments of this shape:
404
-
485
+ Lessons from five deployments of this shape (barber, lawyers, copywriter,
486
+ trial, investor):
487
+
488
+ - **Split every file by one question — *does the agent write this at
489
+ runtime?*** Profile-owned files (`AGENTS.md`, task prompts, skills,
490
+ preferences, config keys) are replaced on every install: shipping a newer
491
+ one *is* the upgrade. Agent-writable files (`MEMORY.md`, `knowledge/`, a
492
+ registry, a ledger) are seeded once and never clobbered — overwriting one
493
+ destroys data that exists nowhere else, and there is no flag that makes
494
+ that safe. In a space profile the line is `agents_md`/`tasks`/
495
+ `extension_config` versus `workspace_seed`; `check` reports a changed
496
+ profile-owned file as drift and a changed seed as the feature working.
497
+ - **Ledgers and databases never ship.** A profile creates them empty at
498
+ install and a gate fails on any committed `*.db` — that is the exact
499
+ mechanism by which one client's records would be installed into another
500
+ client's space. Paths the runtime creates (`outbox/`, `trackers/`) are
501
+ declared per profile (`runtime-paths.txt`) so the "every referenced path
502
+ exists" check can exempt them *and print the exemption count*.
503
+ - **A profile installed into several spaces authors owner-dependent prose
504
+ twice**, in `<!-- OWNER:BEGIN -->…<!-- NOOWNER:BEGIN -->…` blocks, and the
505
+ installer deletes the losing block — never substitutes an empty string into
506
+ a sentence that then reads as a bug. A gate checks the blocks balance,
507
+ scanning only the file set the installer rewrites.
508
+ - **Stage, verify, then write.** The investor installer builds the whole tree
509
+ in a temp dir, asserts no placeholder and no marker survives and every
510
+ absolute `/spaces/<id>/…` path it names exists in the stage, and only then
511
+ touches the live space — a failure leaves the space untouched rather than
512
+ half-installed. It also reads the last `messages` row from `state.db` and
513
+ refuses to overwrite while a turn is open: the space is mounted per run, so
514
+ a mid-turn write lands half the old persona and half the new one on a
515
+ request already executing, and nothing reports it.
405
516
  - **Typecheck the extension against the deployed layout, not the source
406
517
  tree.** Mercury copies only the extension dir; a deep relative import into
407
518
  a sibling engine compiles locally and fails on the box. tagula's
@@ -428,12 +539,35 @@ Lessons from four deployments of this shape:
428
539
  runbook and `test-full.sh`; the football goal has
429
540
  `football-reporter-fixture-harness` planned. Probe callers must be
430
541
  dedicated — probes spend the same per-caller daily budget as the test.
542
+ 1b. **Two tiers, named as such.** The deterministic tier (engine unit tests,
543
+ the output lint) is the *done gate*; the live conversational tier is
544
+ *post-deploy evidence* — a deferred live run is then a tracked residual
545
+ risk, not an assumed pass. The tagula scenario suite is explicit that it
546
+ is "a data gatherer, NOT a pass/fail test suite": `id|severity|category|
547
+ message|expected_behavior` rows (196 for the barber), one dedicated caller
548
+ per stateful flow so conversations do not pollute each other, a cleanup
549
+ flow for anything that creates real state, a circuit breaker after three
550
+ consecutive failures, incremental skipping via `passed-ids.txt`, and a
551
+ JSON report carrying reply + raw response + timing per scenario. A row is
552
+ "passed" when a reply was *collected*; judging it is a separate step —
553
+ parallel LLM graders into PASS / PARTIAL / FAIL per category, findings
554
+ split into **engine bugs** (get a test) and **persona fixes** (get a line
555
+ in `AGENTS.md`). Every scenario the harness cannot really exercise (a
556
+ voice note sent as text, a state-dependent slot) is labelled so the
557
+ report says "state-dependent, not a bug" instead of red.
431
558
  2. **Trace the runs you are tuning** (`agent.trace_runs: true`, image
432
559
  rebuild, bounded window). A trace answers "which sources did it open, did
433
560
  it read the skill, where did the time go"; a reply does not.
434
561
  3. **Drift-check after every deploy** (`space-profile check`,
435
562
  `compose-grounding.sh check`, `check-extensions.sh`). A gate whose
436
- coverage is a hand-maintained list will shrink; derive it from disk.
563
+ coverage is a hand-maintained list will shrink; derive it from disk, and
564
+ require every profile to be either covered or exempted *with a written
565
+ reason* in the gate itself. Two checks tagula's `check-profile-skills.sh`
566
+ runs that `space-profile check` does not yet: every absolute
567
+ `/spaces/<id>/…` path an `AGENTS.md` or `SKILL.md` names must resolve
568
+ ("an agent told to load this finds nothing, and NOTHING reports it"), and
569
+ every shipped skill directory must be named by the file that indexes it
570
+ (`ideas/space-profile-check-paths-and-inflight-guard.md`).
437
571
  4. **Read the rendered output the way the user does** — on a phone, in the
438
572
  group, right-to-left. A mirrored RTL grid and a three-paragraph "short"
439
573
  answer both pass every structural test.
@@ -442,10 +576,143 @@ Lessons from four deployments of this shape:
442
576
  restart).
443
577
  6. **Before calling it done**, the manual check is in
444
578
  `docs/pending-verification.md` and the change has run on the live bot.
579
+ 7. **Read status from the box, never from the planning docs.** On 2026-08-31
580
+ three docs said "deploy not yet run" and "space paused" while
581
+ `space_config` said feed-watch had been on for six days, the ledger held
582
+ 250 runs, and `check` was red on an owner edit nobody had recorded. The
583
+ status of a profile is: `space-profile check`, `SELECT … FROM task_runs`
584
+ grouped by day and outcome, `model.active`, and the newest assistant rows
585
+ — then the docs are corrected to match.
586
+
587
+ ---
588
+
589
+ ## 12. The plan shape — from audit to regression
590
+
591
+ The football profile was built as a four-milestone goal, and the sequence is
592
+ the reusable part. It is the order in which each step makes the next one
593
+ *measurable*; skipping ahead is how a change ships that nobody can tell
594
+ worked.
595
+
596
+ ```
597
+ 0. Audit read N hours of the real chat; one row per defect with the
598
+ message id, the rule it broke, and the layer that owned it
599
+ 1. One owner profile folder + AGENTS.md + apply/check; zero code change
600
+ to the bot; every rule now has one home and a drift check
601
+ 2. Deterministic put code in front of the model: a feed, an engine, a
602
+ input host-expanded marker — so the model verifies instead of
603
+ discovering
604
+ 3. Proof a ledger (one row per run: model, cost, outcome) BEFORE the
605
+ change it measures; a pure output lint; fixtures through the
606
+ real prompts in a private space; numbers reported, not gated
607
+ 4. Regression one check per audit defect inside `bun run check`; a
608
+ re-audit note two weeks later, read from the ledger first
609
+ ```
610
+
611
+ Rules that came with it, each from a decision that was tested against a real
612
+ failure (`docs/goals/football-reporter-profile/decisions.md`):
613
+
614
+ - **Instrument before the change** (D-012): the ledger was built ahead of
615
+ feed-watch so the before existed. It is what showed, ten days later, that
616
+ the deterministic feed cost five times the blind scans.
617
+ - **Mechanisms over rules** (D-016): a defect that comes from *what the model
618
+ could see* is fixed by changing what it can see — a notebook it reads by
619
+ rule, a voice removed from the stack — never by a rule about the case. The
620
+ test: if the fix is a sentence in `AGENTS.md` describing the failure, it is
621
+ edge-case chasing.
622
+ - **Measure before gating** (D-008): a lint reports for two weeks before it
623
+ blocks anything; thresholds come from the measured low end, not the median.
624
+ - **Bugs on a parallel track** (D-007): a defect found while building a story
625
+ is filed, not fixed in place; the story's regression check lands in M4.
626
+ - **Checkpoints are countable** ("0 emoji", "1 `#` title", "one model call per
627
+ run confirmed in `token_usage`", "$/day reported") and are passed on
628
+ evidence from the box, with the failures written down next to the passes —
629
+ the M2 checkpoint records a failed cost direction, which is what makes it
630
+ worth having.
631
+ - **A decision log with `Revisit if:`** on every entry. Half the football
632
+ decisions were revisited within ten days (D-013 refined D-003, D-019 re-cut
633
+ M2.3, D-020 reversed the M1 emoji rule) — a log without the trigger written
634
+ down is a log that gets argued from memory.
635
+
636
+ ---
637
+
638
+ ## 13. Operating a live profile
639
+
640
+ What changes once the profile is running and someone else is in the room.
641
+
642
+ - **The owner will edit the live file.** It is the fastest tuning loop there
643
+ is — a Hebrew sentence on the box, read by the next run — and it happened
644
+ four days after "never edit the live `AGENTS.md`" was written. When `check`
645
+ goes red on the standard itself, **adopt, do not revert**: copy live → repo,
646
+ `git diff` to see exactly what changed, read the *whole* file for the
647
+ contradiction the edit introduced (the 08-27 edit allowed club emoji in
648
+ `## פורמט` while `## סגנון` still said zero), fix that in the repo, commit,
649
+ then `apply` — which now writes back only the reconciliation (D-020).
650
+ `apply` over an owner edit deletes four days of judgement to restore a
651
+ stale copy.
652
+ - **A prose rule that appears on the box is a bug report in disguise.** "No
653
+ English working sentence as a first line" is the owner describing a host
654
+ defect in the only layer they can reach. Adopt the sentence (it records
655
+ intent), file the mechanism (`docs/bugs/…`), and expect the sentence not to
656
+ hold — four of ten leaks came after it.
657
+ - **Switches with a setter stay out of the manifest, and stay recorded.**
658
+ `feed-watch.enabled` was deliberately left to a named admin (a verify run
659
+ executes as whoever set the key). That is right — and it is also how the
660
+ extension went live with none of its 19 runbook checks walked. When the
661
+ switch is flipped from chat, the ledger is the record: the first run's
662
+ timestamp, model and cost go into `pending-verification.md` the same day.
663
+ - **Cost is a per-day number, per space, per outcome.** Read it every few
664
+ days for the first two weeks:
665
+ ```sql
666
+ SELECT date(started_at/1000,'unixepoch') d, outcome, count(*) n, round(sum(cost),2) usd
667
+ FROM task_runs WHERE space_id='<space>' GROUP BY d, outcome ORDER BY d DESC;
668
+ ```
669
+ A space that costs more after the deterministic feed than before it has a
670
+ configuration problem (run cap, batching, exclusions, a cheaper leg for the
671
+ quiet path), not a prompt problem.
672
+ - **The model pin is part of the profile's contract but not of its files.**
673
+ `model.active` lives in `space_config` and the owner switches it from chat.
674
+ A profile README should say which model it was tuned on; a fixture result
675
+ should carry the model; a behaviour change that coincides with a switch is
676
+ the switch until proven otherwise.
677
+ - **Memory needs hygiene, not re-seeding.** After a week the football space had
678
+ 19 episode notes, five of them seeded and the rest napkin's. `check` cannot
679
+ see a wrong note (a seed is compared for existence only), so the notebook's
680
+ correctness is verified the way the audit verified the chat: open a sample,
681
+ check the `## Current State` lines carry `(source, date)`, and look for two
682
+ notes on one topic — the split-topic failure the seeds were designed to
683
+ avoid.
684
+ - **Keep the planning docs behind the box, never ahead of it.** The roadmap
685
+ row says "deployed <date>", the checkpoint block says what passed and what
686
+ failed with the numbers, and `pending-verification.md` holds only what a
687
+ person still has to look at. A "deploy not yet run" line that survives the
688
+ deploy by six days costs the next session an hour.
689
+ - **Ask who is in the room before reading engagement out of the numbers.** On
690
+ 2026-08-31 every mechanical signal on the box — a ledger of successful runs,
691
+ ambient counts falling to zero, `conversations.last_seen_at` frozen four days
692
+ back, another space as a live control on the same connection — supported the
693
+ conclusion that the audience had walked out under the bot's output volume. The
694
+ space had been re-pointed at a private testing group with one member in it.
695
+ The box records what was *sent* and what *arrived*; it has no column for what
696
+ the room is **for**. Membership, purpose and whether a space is a test harness
697
+ are owner facts. Read the counts, then ask — and write the answer into the
698
+ profile README, because the next session will re-derive the same wrong story
699
+ from the same true numbers.
700
+ - **Price the prefix, not the run.** On the Anthropic leg a run's cost is
701
+ `prefix tokens × $2.50/M` for the first call, then ~1/10 of that per tool
702
+ round — so `cost/day ≈ runs/day × prefix tokens`. Every byte that reaches the
703
+ per-run prompt is paid for on every run, whether or not the run reads it. Two
704
+ things put unbounded content there on `football-friends` in the same week: an
705
+ extension's `promptAppend` that the host persisted and then replayed through
706
+ the history window (103 KB), and episode notes whose `## Current State` had no
707
+ working ceiling (11 KB). Same failure, two doors. The rule: anything injected
708
+ per run — digests, notebook sections, history — needs a cap that **logs when it
709
+ bites**, and the stored copy of a turn must be the marker, never the
710
+ expansion. And read the cost from `cache_write_tokens`, not `input_tokens`;
711
+ the latter is the uncached sliver and explains nothing.
445
712
 
446
713
  ---
447
714
 
448
- ## 12. Anti-patterns (each one has happened)
715
+ ## 14. Anti-patterns (each one has happened)
449
716
 
450
717
  | Anti-pattern | What it did | Do instead |
451
718
  |---|---|---|
@@ -463,10 +730,20 @@ Lessons from four deployments of this shape:
463
730
  | Blanket-`cp` of a skill over its deployed copy | `{SPACE}` placeholders reverted; commands broke silently | copy → substitute → diff |
464
731
  | Verifying a deploy against the repo | fixes "deployed" that were never built into the image | grep the image / the installed copy |
465
732
  | A restart to deploy `container-entry.ts` changes | tracing silently did nothing | image rebuild, then verify in the image |
733
+ | `apply` over an owner's live edit | would have reverted four days of scan-format tuning | adopt live → repo, fix the contradiction, then `apply` (D-020) |
734
+ | A prose rule against a host defect ("no English first line") | four leaks after the rule | file the mechanism; keep the sentence as a record of intent |
735
+ | "Empty runs are cheap" | 46 verify runs/day, 70 % `NO_UPDATE`, $14–20/day for one space | cap runs per hour, batch, exclude; a host pre-filter is the only free run |
736
+ | Assuming the model the profile was tuned on | Sonnet narrated into the sent text where Opus had not | read `model.active` and the ledger's model column first; fixtures carry the model |
737
+ | Status from the planning docs | "deploy not yet run" six days into the deploy | `check` + ledger + `space_config`, then correct the docs |
738
+ | Copying a DM space's permission row into a group | members held `tasks.delete` on the daily article | start from the live row and remove; audit per room |
739
+ | A formatting rule the send pipeline undoes | the model added RLM/RLE marks the host strips before adding its own | read `src/text/rtl.ts` and `markdown.ts`; write only the rules the host does not enforce |
740
+ | "Passed" = a reply was collected | 196 green rows, quality unjudged | grade separately (PASS / PARTIAL / FAIL); split engine bugs from persona fixes |
741
+ | Writing to a live space dir mid-turn | half the old persona, half the new, on a running request | check `state.db`'s last `messages` row before any live write |
742
+ | A ledger or `.db` committed with the profile | one client's records installed into another client's space | create it empty at install; a gate fails on any committed `*.db` |
466
743
 
467
744
  ---
468
745
 
469
- ## 13. Checklist
746
+ ## 15. Checklist
470
747
 
471
748
  - [ ] Kind chosen (applicative vs space) and the layer map written in the README.
472
749
  - [ ] Facts inventory done; every fact has a source of truth; every "no answer" has its sentence.
@@ -481,3 +758,12 @@ Lessons from four deployments of this shape:
481
758
  - [ ] Extension config owned by the profile where a hand edit would matter; the `enabled`-style switch deliberately not.
482
759
  - [ ] Fixtures exist for the rules that matter; a traced run shows the tools/skills being used.
483
760
  - [ ] Deployed and verified inside the artefact; `check` clean; the change has run on the live bot.
761
+ - [ ] The ledger existed before the change it measures; the first day's runs × cost per run is written down next to the target.
762
+ - [ ] `persona.exclusive` decided deliberately (owns its standard → on; should still sound like the deployment → off).
763
+ - [ ] Seeds (`workspace_seed`) carry facts and indexes only — no rule that `AGENTS.md` already owns; thin where evidence is thin.
764
+ - [ ] The README names the model the profile was tuned on; the fixture report records the model per result.
765
+ - [ ] Every switch the profile does not own (`*.enabled`) has a line in `pending-verification.md` saying who flipped it and when, taken from `space_config.updated_by` and the ledger.
766
+ - [ ] Every file is classified profile-owned or agent-writable; nothing agent-writable is re-asserted; no `*.db` ships.
767
+ - [ ] Formatting rules are checked against `src/text/rtl.ts` and `src/text/markdown.ts`; the skill's checklist and the lint read one rule list.
768
+ - [ ] `AGENTS.md` has a "what this space does not do" section and a privacy rule phrased as no-retention, not denial.
769
+ - [ ] The bot's own judgements land in an append-only record with a date (notebook `## History`, a tracker, the ledger) so they can be graded later.
@@ -99,6 +99,23 @@ The `description` is the trigger. It is limited to 1024 characters, and the
99
99
  - **Declare state the skill owns** — a DB path, a registry file, a seed —
100
100
  and the rule for it ("register every new tracker in `registries.json` or it
101
101
  disappears").
102
+ - **A formatting skill states what the host already does, then only what it
103
+ does not.** The investor `whatsapp-formatting` skill opens by saying that
104
+ `applyRtlDirection` (`src/text/rtl.ts`) strips leading bidi marks and adds
105
+ its own RLM per line — "verified against this install's runtime" — so the
106
+ model must *not* add RLE/RLM; what remains its job is to start every line
107
+ with a Hebrew word or an emoji, never a ticker or a number. Same for
108
+ `normalizeChatMarkdown` (`src/text/markdown.ts`): headings and `**` are
109
+ rewritten at send, so a rule about them is a rule about the stored row,
110
+ not the rendered message. End the skill with a short checklist the model
111
+ runs before sending; the same list is what the output lint asserts
112
+ (`profile-guide.md` §7, §11) — one list, two readers.
113
+ - **Exit codes are a table with a "what you do" column.** The investor
114
+ `chart` skill lists every code, says which are structural (never retry —
115
+ "a chartless analysis is a fine answer, a stalled reply is not") and adds
116
+ a "how it works, for whoever maintains this" section so the next editor
117
+ does not "simplify" it into a different data source with silently wrong
118
+ numbers.
102
119
 
103
120
  ## 5. Making a profile actually use its skills
104
121
 
@@ -152,3 +169,10 @@ then `diff` and confirm the only difference is the placeholder.
152
169
  a container (`docker run --rm --entrypoint ls <image> /home/mercury/.pi/agent/skills`
153
170
  for global ones) or check `<available_skills>` in a trace.
154
171
  - [ ] A trace of one real run shows the `read` of `SKILL.md`.
172
+ - [ ] Every absolute path the skill or `AGENTS.md` names resolves inside the
173
+ space (tagula gates this with `check-profile-skills.sh`; for a space
174
+ profile, `ls` them from the box until `space-profile check` learns to —
175
+ `docs/ideas/space-profile-check-paths-and-inflight-guard.md`).
176
+ - [ ] If the skill is about formatting, it names the host passes that already
177
+ run at send (`src/text/rtl.ts`, `src/text/markdown.ts`) and only rules
178
+ beyond them.
@@ -21,6 +21,16 @@ export const MAX_TERMS = 50;
21
21
  export const MAX_PER_HOUR_CEILING = 12;
22
22
  export const MAX_BATCH_MINUTES = 30;
23
23
 
24
+ /**
25
+ * Most items the daily digest will carry. A bound, not a tuning knob: the
26
+ * buffer is already capped at `BUFFER_CAP` items, but that ceiling was chosen
27
+ * for the store, not for a prompt, and 264 items came to 103 KB before anyone
28
+ * measured it. Whatever the watcher collects, the article's prompt stops here
29
+ * — and says so in the log when it does.
30
+ */
31
+ export const DEFAULT_DIGEST_MAX_ITEMS = 200;
32
+ export const DIGEST_MAX_ITEMS_CEILING = 400;
33
+
24
34
  /** An RSS/Atom URL, polled as-is. */
25
35
  export interface RssSource {
26
36
  type: "rss";
@@ -60,6 +70,8 @@ export interface WatchConfig {
60
70
  quietHours: QuietHours | null;
61
71
  maxPerHour: number;
62
72
  batchMinutes: number;
73
+ /** Hard ceiling on how many items the daily digest hands the article. */
74
+ digestMaxItems: number;
63
75
  /** IANA zone, or "" to fall back to the deployment default and then UTC. */
64
76
  timezone: string;
65
77
  }
@@ -158,6 +170,14 @@ export function isValidBatchMinutes(value: string): boolean {
158
170
  return isIntInRange(value, 0, MAX_BATCH_MINUTES);
159
171
  }
160
172
 
173
+ /**
174
+ * At least one: a digest of zero items is indistinguishable from "the watcher
175
+ * is not running", which the article is explicitly told to treat differently.
176
+ */
177
+ export function isValidDigestMaxItems(value: string): boolean {
178
+ return isIntInRange(value, 1, DIGEST_MAX_ITEMS_CEILING);
179
+ }
180
+
161
181
  /**
162
182
  * An IANA zone name, checked by asking `Intl` to use it. Empty means "unset",
163
183
  * which is a valid state — the poller falls back to the deployment default.
@@ -270,6 +290,16 @@ export function readSpaceConfig(
270
290
  log,
271
291
  ),
272
292
  ),
293
+ digestMaxItems: Number(
294
+ read(
295
+ getConfig,
296
+ spaceId,
297
+ "digest_max_items",
298
+ String(DEFAULT_DIGEST_MAX_ITEMS),
299
+ isValidDigestMaxItems,
300
+ log,
301
+ ),
302
+ ),
273
303
  timezone: read(
274
304
  getConfig,
275
305
  spaceId,
@@ -344,6 +374,12 @@ export const CONFIG_KEYS: ConfigKeyDef[] = [
344
374
  default: String(DEFAULT_BATCH_MINUTES),
345
375
  validate: isValidBatchMinutes,
346
376
  },
377
+ {
378
+ key: "digest_max_items",
379
+ description: `Most items the daily digest carries into the article's prompt, newest first (1–${DIGEST_MAX_ITEMS_CEILING}, default ${DEFAULT_DIGEST_MAX_ITEMS}). Older matches stay in the workspace digest file. A bound on prompt size, not a filter.`,
380
+ default: String(DEFAULT_DIGEST_MAX_ITEMS),
381
+ validate: isValidDigestMaxItems,
382
+ },
347
383
  {
348
384
  key: "timezone",
349
385
  description: