mercury-agent 0.23.0-beta.1 → 0.23.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/container/Dockerfile.base +14 -0
- package/docs/goals/feed-watch-social-sources/decisions.md +8 -0
- package/docs/pending-verification.md +35 -22
- package/examples/extensions/longview/render/telegraph-nodes.ts +51 -9
- package/examples/profiles/football-reporter/AGENTS.md +10 -2
- package/examples/profiles/football-reporter/seed/names.md +2 -0
- package/examples/profiles/football-reporter/tasks/memes.md +7 -3
- package/package.json +5 -5
- package/src/cli/build-context.ts +119 -0
- package/src/cli/mercury.ts +24 -64
- package/src/core/heartbeat.ts +17 -1
- package/src/core/reporter-lint-on-send.ts +2 -1
|
@@ -95,6 +95,20 @@ RUN while IFS= read -r ext || [ -n "$ext" ]; do \
|
|
|
95
95
|
RUN echo '#!/bin/sh\nbun run /app/src/cli/mrctl.ts "$@"' > /usr/local/bin/mrctl && \
|
|
96
96
|
chmod +x /usr/local/bin/mrctl
|
|
97
97
|
|
|
98
|
+
# Same stamp as `container/Dockerfile`, generated from THIS file so the
|
|
99
|
+
# manifest cannot drift from the tier that was actually built. The host's
|
|
100
|
+
# drift check hashes its own `container/Dockerfile`; an image cut from this
|
|
101
|
+
# tier will report that as a difference, which is true and is the point.
|
|
102
|
+
ARG MERCURY_VERSION=unknown
|
|
103
|
+
COPY container/Dockerfile.base /app/container/Dockerfile.base
|
|
104
|
+
COPY src/agent/image-manifest.ts /app/src/agent/image-manifest.ts
|
|
105
|
+
RUN bun run /app/src/agent/image-manifest.ts \
|
|
106
|
+
/app/container/Dockerfile.base "$MERCURY_VERSION" > /app/image-manifest.json && \
|
|
107
|
+
grep -q 'src/agent/container-entry.ts' /app/image-manifest.json
|
|
108
|
+
LABEL org.opencontainers.image.version="${MERCURY_VERSION}" \
|
|
109
|
+
org.opencontainers.image.title="mercury-agent" \
|
|
110
|
+
com.mercury.version="${MERCURY_VERSION}"
|
|
111
|
+
|
|
98
112
|
# Fix ownership of all mercury home dir artifacts before switching user
|
|
99
113
|
# `mkdir` here rather than leaving it to Docker: the host mounts the global dir
|
|
100
114
|
# into PI_CODING_AGENT_DIR entry by entry, so Docker would create this dir as
|
|
@@ -589,6 +589,11 @@ is decomposed:
|
|
|
589
589
|
taste would have silently defeated the feature.
|
|
590
590
|
- **Revisit if:** D-018's trigger fires, or the standard's scan-line length
|
|
591
591
|
changes (the 300-char line clip assumes the 200-char cap).
|
|
592
|
+
- **Read 2026-09-14 (match weekend 09-12 → 09-14):** `truncated=true` 65 times
|
|
593
|
+
in 4 days, every one on a verify run's block — `lines` 64–101 clipped to
|
|
594
|
+
`shown=60`, `chars` 6,474–7,648 (under the 10,000 cap). No roundup or article
|
|
595
|
+
slot truncated. The 60-line cap is the one doing work; the corpus's worst of
|
|
596
|
+
54 was a quiet week.
|
|
592
597
|
- **Used by:** feed-watch-verify-dedup-against-posted
|
|
593
598
|
|
|
594
599
|
## D-020: The host-side drop compares canonical **links** only, and reads them from the rows the group received
|
|
@@ -620,6 +625,7 @@ is decomposed:
|
|
|
620
625
|
- **Reasoning:** the buffer already dedups by id and by title; what is left
|
|
621
626
|
at the link level is narrow, cheap and honest, and the absent case (no
|
|
622
627
|
host method, a throwing read) degrades to today's behaviour with a warn.
|
|
628
|
+
- **Read 2026-09-14:** `items dropped, links already posted` — 0 lines in 7 days.
|
|
623
629
|
- **Revisit if:** the standard ever puts URLs in scan lines — then the drop
|
|
624
630
|
starts firing on scan posts and the per-tick info line becomes a per-tick
|
|
625
631
|
fixture of the journal; decide then whether it stays at info.
|
|
@@ -649,6 +655,8 @@ is decomposed:
|
|
|
649
655
|
key; under 48 KB at 90 keys with every count at three digits, which the
|
|
650
656
|
test measures and no real fortnight reaches), and the one thing the counters
|
|
651
657
|
must never do is cost the tick or the run their bookkeeping.
|
|
658
|
+
- **Row growth, read 2026-09-14 21:20 IDT (48 h):** 9,303 bytes, 79 sources, three
|
|
659
|
+
UTC day buckets.
|
|
652
660
|
- **Revisit if:** a fortnight is too short to judge a match-week-only feed —
|
|
653
661
|
then the constant moves, not the shape; or if a second writer needs an
|
|
654
662
|
`await` between read and write, in which case it re-reads first.
|
|
@@ -1532,7 +1532,7 @@ need that row to exist.
|
|
|
1532
1532
|
- [x] MANUAL-VERIFY(2026-09-10): **the cursor advanced on the roundup's own key, and only there.** After that run: `journalctl --user -u mercury --since '-15min' | grep 'digest cursor advanced'` shows a line whose meta carries `lane=roundup` (the unit is **user-scoped** — a system-scope `journalctl -u mercury` comes back empty and reads as a false all-clear). Then the `extension_state` query above returns **two** rows: `last_digest_at:football-friends` unchanged, and a new `last_digest_at:football-friends:roundup`. *Done 2026-09-11 13:30 IDT. `digest cursor advanced spaceId=football-friends lane=roundup until=2026-09-11T07:00:00.673Z items=336` at 10:02:46 and `… until=2026-09-11T10:00:02.978Z items=50` at 13:01:39 — no other `lane=` cursor line today. `extension_state` holds exactly two rows: `last_digest_at:football-friends` = 2026-09-11T06:00:01.578Z (the 09:00 article's own `until`, moved only by task 24 at 09:03:35) and `last_digest_at:football-friends:roundup` = 2026-09-11T10:00:02.978Z.*
|
|
1533
1533
|
- [x] MANUAL-VERIFY(2026-09-10): **the lane wrote its own workspace files and left the article's alone.** In the space directory on the box: `knowledge/feed-digest-window.roundup.md` and `knowledge/feed-digest.roundup.md` exist and are dated by that run, while `knowledge/feed-digest-window.md`'s mtime is still the last 09:00 article's. Two files with the same content under different names would mean `digestWindowFileFor` is being ignored. *Done 2026-09-11 13:30 IDT. `knowledge/feed-digest-window.roundup.md` (18 KB, mtime 13:00:02) and `knowledge/feed-digest.roundup.md` (209 KB, mtime 13:01:39) carry the 13:00 run; `knowledge/feed-digest-window.md` (162 KB, mtime 09:00:01) and `feed-digest.md` (mtime 09:03:35) are the article's, untouched by both roundups. Different names, different sizes.*
|
|
1534
1534
|
- [x] MANUAL-VERIFY(2026-09-10): **the next 09:00 article's window starts at the previous article, not at a roundup.** The morning after the roundup task goes live, read the `digest appended` info line for task 24's run: its `since` is the *previous article's* `until`, and its `items` count is not the small number a three-hour window would give. This is the whole story in one observation — the finish line's item 6. *Done 2026-09-11 13:30 IDT. Task 24's 09:00 run: `digest appended … items=497 … since=2026-09-09T06:00:00.890Z until=2026-09-11T06:00:01` — `since` is the article key's previous value, 48 hours and 497 items, not a three-hour window; neither the marker-carrying chat run of 03:03 nor the 10:00 roundup touched it.*
|
|
1535
|
-
- [ ] MANUAL-VERIFY(2026-09-10): **an empty window really says nothing.** Watch one roundup slot on a quiet afternoon: the `digest appended` line reports `items=0` and **no message reaches the group** (`task_runs` has the row, the space has no new outbound message). A `"Done."` in the group instead of silence means the reply was empty rather than `NO_UPDATE` — the placeholder the prompt cannot prevent, fixed host-side only. *Note 2026-09-11 13:30 IDT: not seen yet — the 10:00 slot had 336 items (the burst) and 13:00 had 50. Watch the 16:00 / 19:00 slots; the pass reads `items=0` with no message after it.*
|
|
1535
|
+
- [ ] MANUAL-VERIFY(2026-09-10): **an empty window really says nothing.** Watch one roundup slot on a quiet afternoon: the `digest appended` line reports `items=0` and **no message reaches the group** (`task_runs` has the row, the space has no new outbound message). A `"Done."` in the group instead of silence means the reply was empty rather than `NO_UPDATE` — the placeholder the prompt cannot prevent, fixed host-side only. *Note 2026-09-11 13:30 IDT: not seen yet — the 10:00 slot had 336 items (the burst) and 13:00 had 50. Watch the 16:00 / 19:00 slots; the pass reads `items=0` with no message after it.* *(2026-09-14 21:20 IDT: still no `items=0` roundup slot on the box — the quietest was 50. The half that matters is measured instead: all five `no_update` roundup runs (09-11 19:00, 09-12 13:00/16:00, 09-13 13:00, 09-14 13:00) were followed by **zero** assistant rows in the space within 3 minutes — no `"Done."`, silence.)*
|
|
1536
1536
|
- [x] MANUAL-VERIFY(2026-09-10): **the profile is not in drift after the task entry lands.** Once `config.yaml` carries `{id: <n>, name: roundup, prompt: ./tasks/roundup.md}`, `space-profile check` for `football-friends` reports no drift — in particular no `task-prompt` drift, which would mean the live row's prompt and `tasks/roundup.md` disagree byte-for-byte. *Done 2026-09-11 03:06 IDT. `config.yaml` carries `{id: 621, name: roundup, prompt: ./tasks/roundup.md}` (commit 38ce19b); `apply` set the prompt of task 621 once (the chat-created row differed) and `check: clean` afterwards.*
|
|
1537
1537
|
|
|
1538
1538
|
## football-match-day-sources (M1.7) — live checks (added 2026-09-10, deploy-gated)
|
|
@@ -1572,7 +1572,7 @@ box does not emit at `log_level: info` (D-009) — nothing below relies on it.
|
|
|
1572
1572
|
- [x] MANUAL-VERIFY(2026-09-10): **the 302 drill — one dead channel backs off alone.** Temporarily add `{"type":"telegram","channel":"sport5","outlet":"drill"}` to the manifest (Sport 5's official handle answers 302 — it has no public preview, which is the whole reason the spec names it), `apply`, and on the next tick expect exactly one `feed-watch: source failed, backing off` with `source: "telegram:sport5"` and `error: "HTTP 302"`, a `nextAttemptAt` on that key **only**, every other key still `lastOkAt`-fresh, and `ss -ltnp | grep 8787` still showing the listener. Then revert the manifest byte-identically, `apply`, `check` clean. The recovery half is not stageable live and is covered by the extension's own tests. The drill leaves a stale `telegram:sport5` key in the row forever — harmless, and the same leftover the M1.5 spec recorded. *Done 2026-09-11 03:19 IDT. `sport5` planted via a temporary profile copy: on the next tick exactly one `source failed, backing off … source=telegram:sport5 error=HTTP 302`, `nextAttemptAt` on that key only (plus the pre-existing MEN 403), every other key `lastOkAt`-fresh, `ss -ltnp` still showing 8787. Manifest reverted byte-identically, `apply`, `check: clean`. The stale `telegram:sport5` key stays in the row, as this line says.*
|
|
1573
1573
|
- [x] MANUAL-VERIFY(2026-09-10): **one verify run per new kind, with the right attribution.** Three separate observations, each read from `task_runs` / the trace rather than from chat (a run may answer `NO_UPDATE`): (a) a **Telegram** item's prompt line carries the post's own time, its `t.me` permalink and the configured `outlet` label — not `t.me/<channel>`; (b) a **YouTube** item's line carries the channel name as the outlet, taken from the feed's own `<title>` (a YouTube entry has no `<source>`); (c) a **Reddit** item's line carries the subreddit feed's title as the outlet. On a covered match day (a) is minutes away on any evening; (b) needs a match, so if none falls on deploy day it is satisfied by the YouTube keys sitting at `failures: 0` and checked properly at the first match. This is finish-line item 4. *Done 2026-09-11 13:35 IDT, read from the traces of runs that answered `NO_UPDATE` — those leave no stored turn, so `messages` alone shows only rss/gnews items and reads as "no Telegram ever verified". (a) Telegram: the 10:09, 10:34, 11:19 and 13:17 verify runs carried t.me lines — `- 2026-09-11 09:53 | Fabrizio Romano | 🎧 New pod out … | https://t.me/FabrizioRomanoTG/66289 | terms: Barcelona`, `… | Beitar News | … | https://t.me/BeitarNews/32315 | …`, `… | HaKol MeHaYatzia | … | https://t.me/maccabitlv/39209 | …` — post time, permalink, configured outlet label. (c) Reddit: the 08:19, 10:56, 11:19 and 11:40 runs carried `- 2026-09-11 11:09 | newest submissions : Barca | Champions leauge match … | https://www.reddit.com/r/Barca/comments/… | terms: Barcelona` — the subreddit feed's own title as outlet. (b) YouTube: no verify run carried one yet; the two YouTube items that matched today fell in quiet hours and went into the 09:00 article's block as `- 2026-09-11 00:27 | TNT Sports Football | Benjamin Sesko on a good night … | https://www.youtube.com/shorts/… | terms: Manchester United` — channel name as outlet, from the feed's `<title>` — and all 11 YouTube keys sit at `failures: 0`, the fallback this line allows; re-read at the first match. Aside: `r/soccer/new` answered one 429 at 10:39 in 129 ticks, backed off once and was back by 13:28 — the rotation holds but is not immune.*
|
|
1574
1574
|
- [ ] MANUAL-VERIFY(2026-09-10): **the leads rule survives contact.** On the first posted item that came from WhoScored, a Reddit match thread or a repost channel (`BeitarNews`, the Walla mirror, `MundoDeportivo1906`, `DMQRealMadrid`): the parenthetical names the **outlet the item pointed at**, not Reddit / WhoScored / the channel, and a lineup taken from one of them is written `משוער` with the name of whoever guessed it. A posted line citing the aggregator itself is a D-013 failure and wants a lint rule rather than more prose. *Note 2026-09-11 13:35 IDT: no posted line has come from WhoScored, a Reddit thread or a repost channel yet — today's five posted lines cite Walla, ynet, ONE/AS, Marca and The Independent by name, none an aggregator, but none was this line's case either; the `BeitarNews` and `r/Barca` items above all landed in `NO_UPDATE` runs. Re-read on the first match day. WhoScored's feed answers 403 on roughly every other attempt from the box (backs off, recovers), so it will be thin until that settles.*
|
|
1575
|
-
- [ ] MANUAL-VERIFY(2026-09-10): **a highlights video is one line.** On the first posted highlights item: one line in the scan shape — opponent, score, the channel that uploaded it, the link — with no description of the video's contents and no list of goals. § פורמט owns this and the sentence is new on 2026-09-10. *Note 2026-09-11 13:35 IDT: no highlights item has been posted yet — the matched YouTube shorts fell in quiet hours. The on-demand turn in the line below produced the one-line shape (link, title, channel, length), which is the neighbouring rule, not this one.*
|
|
1575
|
+
- [ ] MANUAL-VERIFY(2026-09-10): **a highlights video is one line.** On the first posted highlights item: one line in the scan shape — opponent, score, the channel that uploaded it, the link — with no description of the video's contents and no list of goals. § פורמט owns this and the sentence is new on 2026-09-10. *Note 2026-09-11 13:35 IDT: no highlights item has been posted yet — the matched YouTube shorts fell in quiet hours. The on-demand turn in the line below produced the one-line shape (link, title, channel, length), which is the neighbouring rule, not this one.* *First posted highlights 2026-09-14 00:19 IDT (row 5199, verify run): two lines, `👹 תקציר רשמי לדרבי מול סיטי, ערוץ מנצ'סטר יונייטד, 2:06: <link>` and the Barcelona one — opponent, channel, link, no description, no goal list, **but no score and a duration instead**. Three of four elements; the score is the one § פורמט names and the line omitted. Partial — open until a highlights line carries the score.*
|
|
1576
1576
|
- [x] MANUAL-VERIFY(2026-09-10): **the deferred M1.5 check, on the space's own copy.** `grep -c 'משימת חיפוש שמבצעים עכשיו' ~/whatsapp-bot/.mercury/spaces/football-friends/AGENTS.md` is non-zero (the path is `<project>/<dataDir>/spaces/<space>/AGENTS.md`, and `dataDir` is `.mercury` on this box) — that is the on-demand video sentence from `football-highlights-on-demand-rule`, merged 2026-09-07 and never confirmed to have reached the space. Grep the **space directory**, not the repo and not the image. Then in chat: "תביא לי וידאו של התקציר" returns a YouTube link with the channel and the length in one turn, one `token_usage` row, and none of `standard.json`'s `deflectionPhrases`. *Note 2026-09-11 03:08 IDT: first half done — `grep -c` on the space's own AGENTS.md is 1 (the 03:05 `apply` rewrote it). The chat half (the on-demand video turn) is not run yet.* *Done 2026-09-11 13:33 IDT, the chat half. The bare ask, sent as the owner's principal through `mercury chat` the morning after two covered matches, got one clarifying question — "של איזה משחק, יונייטד-סבאח או ברצלונה-פיינורד?" (one `token_usage` row, $0.08) — and the answer "יונייטד-סבאח" got, in one turn and one row ($0.15): `הנה 👹 https://www.youtube.com/watch?v=ch_4efPW8xc — "Champions League Is BACK! Man Utd 4-0 Sabah", ערוץ יוטיוב הרשמי של יונייטד, 2:20 דקות.` — link, channel, length. Neither reply contains `תחפש ביוטיוב`. A one-turn answer needs an unambiguous ask; the question is the right move when two matches qualify.*
|
|
1577
1577
|
- [x] MANUAL-VERIFY(2026-09-10): **the burst is a burst, not a fault.** The first tick after `apply` is **not** a seed tick — `pollSpace` seeds silently only when the `seen` set is empty, and this space has been watching since August — so every item inside the 36 h buffer window from 68 new sources is matched at once. Expect several hours of verify runs at the `max_per_hour: 3` ceiling and a large `knowledge/feed-digest-window.md`, and confirm the ceiling actually held: count `outcome=posted` ledger rows plus `NO_UPDATE` runs in the hour after `apply` and expect **no more than 3**. Apply after the 09:00 article, never before it. *Note 2026-09-11 03:20 IDT: `apply` ran at 03:05 IDT, inside quiet hours (00:30–07:30) and **before** the 09:00 article, against this line's last sentence — the coordinator followed the round note's §4 order and read this line after the apply. Consequence: the burst is being folded into the article's digest (`quiet hours, batch folded into the digest`; 0 verify runs and 0 `token_usage` rows since the apply), so the 2026-09-11 09:00 article will carry ~36 h of 68 new sources instead of verify runs spending the ceiling. The ceiling check itself moves to the first hour after 07:30.* *Done 2026-09-11 13:35 IDT, the ceiling. Quiet hours ended 07:30; verify runs opened 07:48 (posted) and 08:18 (`NO_UPDATE`) — two in the first hour, under 3. The 09:00–10:00 hour hit exactly 3 (09:03, 09:24, 09:45) and no hour today exceeded it: 15 verify runs and two roundups by 13:30. The burst itself went into the 09:00 article (497 items), as the note above says.*
|
|
1578
1578
|
- [x] MANUAL-VERIFY(2026-09-10): **ESPN is or is not a silent zero on the box.** `curl -s -o /dev/null -w '%{http_code} %{size_download}\n' -A 'mercury-feed-watch/1.0' --max-time 10 https://www.espn.com/espn/rss/soccer/news` from the box. It answered `202` with an **empty body** from the Windows host on 2026-09-10, which parses to zero items and reports `failures: 0` forever — a source watched since 2026-08-21 that may have been delivering nothing for weeks. If the box sees the same, say so in the manifest comment and drop the entry; if the box gets a real body, the difference is the Windows host's IP reputation and the entry stays. *Done 2026-09-11 03:08 IDT. From the box: `202 0` — the same silent zero. Recorded in the manifest comment; the entry is **not** dropped yet because removing an entry ahead of the six `gnews` entries re-keys them under the array-index bug — drop it with that fix.*
|
|
@@ -1590,7 +1590,7 @@ box does not emit at `log_level: info` (D-009) — nothing below relies on it.
|
|
|
1590
1590
|
|
|
1591
1591
|
- [x] MANUAL-VERIFY(2026-09-12): **the zone table is in the snapshot the poller runs.** After the restart, from WSL: `grep -c 'NAMED_ZONE_OFFSETS' ~/whatsapp-bot/.mercury/extensions/feed-watch/feeds.ts` ≥ 2 and `grep -c '"BST", "+0100"' …/feeds.ts` = 1. The installed snapshot, never the repo and never the image. *Done 2026-09-12 17:47 IDT. `NAMED_ZONE_OFFSETS` = 2, `"BST", "+0100"` = 1 on the installed snapshot.*
|
|
1592
1592
|
- [x] MANUAL-VERIFY(2026-09-12): **the three Sky keys carry `newestItemAt`.** After the first poll tick following the restart (≤ 3 min), read the live `source_status:football-friends` row through the coordinator's read-only probe: every entry whose key is a `skysports.com` feed (`rss/11095`, `rss/11661`, `rss/11667`) has `newestItemAt` set to an ISO instant, where before the deploy it was absent or stale — the bug doc measured 0 of 20 dated items on each. A `newestItemAt` in the last 24 h is the positive form. *Done 2026-09-12 17:47 IDT, first tick after the 17:44:55 restart: `rss/11095` newestItemAt 14:24Z, `rss/11667` 12:40Z, `rss/11661` 13:00Z — all inside the last 24 h, `failures: 0` on each.*
|
|
1593
|
-
- [
|
|
1593
|
+
- [x] MANUAL-VERIFY(2026-09-12): **a Sky item in the next digest carries a date.** In the next `knowledge/feed-digest-window.md` (or `.roundup.md`) written after the restart — `~/whatsapp-bot/.mercury/spaces/football-friends/knowledge/` — every line whose outlet is Sky Sports has a local timestamp, not a blank; and in the journal no `[WARN]` mentions `parseDate`. Read the space's own workspace file, not the repo. *(2026-09-12 22:02: not settled yet. The 22:00 roundup window (70 items) had no Sky Sports line; the article window's two Sky lines (12:32, 15:17) render `seen …` but are buffer rows from **before** the 21:15 restart, parsed by the old code — not evidence either way. No `parseDate` WARN in the journal since the restart. Needs the first Sky item buffered after 21:15.)* *(2026-09-14 21:20 IDT, read from the box after the 20:47 restart: the article window has one Sky Sports line, buffered after the 09-12 restart — `- 2026-09-13 20:11 | Sky Sports Football | Yamal bags brace…` — dated, not `seen`; zero `parseDate` WARN lines since 2026-09-12 21:15.)*
|
|
1594
1594
|
|
|
1595
1595
|
## feed-watch exclude after a maqaf (bug, S3) — live checks (added 2026-09-12, deploy-gated)
|
|
1596
1596
|
|
|
@@ -1615,11 +1615,11 @@ box does not emit at `log_level: info` (D-009) — nothing below relies on it.
|
|
|
1615
1615
|
- [x] MANUAL-VERIFY(2026-09-12): **the installed snapshot is the new one, before `apply`.** After the restart: `grep -c 'gnewsSourceKey' ~/whatsapp-bot/.mercury/extensions/feed-watch/config.ts` is non-zero, `grep -c 'migrateSourceStatus' ~/whatsapp-bot/.mercury/extensions/feed-watch/watch.ts` is non-zero, `grep -c 'stripInvisible' ~/whatsapp-bot/.mercury/extensions/feed-watch/items.ts` is non-zero, and `grep -c 'firstLine' ~/whatsapp-bot/.mercury/extensions/feed-watch/feeds.ts` is non-zero. Four greps, none of the repo. *Done 2026-09-12 17:47 IDT (the apply ran 17:46, after the 17:44:58 first tick — see the next line): `gnewsSourceKey` 1, `migrateSourceStatus` 2, `stripInvisible` 2, `firstLine` 2, all on the installed snapshot.*
|
|
1616
1616
|
- [x] MANUAL-VERIFY(2026-09-12): **the migration ran once, renamed 6 and deleted 4, before `apply`.** `journalctl --user -u mercury --since '-10min' | grep 'source status keys migrated'` shows exactly one line; its `renamed` list has six `{from: gnews:7N:q, to: gnews:<hl>:<gl>:<hash>:q}` pairs (76→`gnews:he:IL:659c944029:q`, 77→`gnews:en-GB:GB:4587a05e80:q`, 78→`gnews:he:IL:f0ebc3c078:q`, 79→`gnews:en-GB:GB:9b33dfe2fe:q`, 80→`gnews:en-GB:GB:34994726f0:q`, 81→`gnews:es:ES:013379780c:q`) and its `deleted` list is exactly `gnews:12:q`, `gnews:13:q`, `telegram:sport5`, `telegram:ILRentsTLV`. A `from`/`to` pair whose hash does not match the one in the manifest comment means `apply` ran first — stop and read the row. *Done 2026-09-12. One line at 17:44:58 (the first tick, restart 17:44:55, apply 17:46): `deleted=gnews:12:q,gnews:13:q,telegram:sport5,telegram:ILRentsTLV` exactly. The `renamed` list prints as six `[object Object]` — the text formatter cannot render a nested object (filed: `docs/bugs/text-logger-renders-a-nested-object-field-as-object-object.md`) — so the from/to pairs were read from the row instead: the six derived keys present are exactly the six named here, hash for hash. A second migration line at 17:47:58 (the tick after apply) deleted ESPN and MEN, `renamed=` empty — the prune doing its job on the new manifest.*
|
|
1617
1617
|
- [x] MANUAL-VERIFY(2026-09-12): **the row has 85 keys and no legacy shape, after `apply` and one tick.** Read the row: 85 keys; `grep -c '"gnews:[0-9]'` over its keys is 0; the six derived `gnews` keys above are present with a `lastOkAt` inside the last poll interval; `rss:https://www.espn.com/…` and `rss:https://www.manchestereveningnews.co.uk/…` are absent; `rss:https://bsky.app/profile/theathleticfc.bsky.social/rss` and `…/david-ornstein.bsky.social/rss` are present with `failures: 0`. Then `space-profile check` is clean. *Done 2026-09-12 17:48 IDT. 85 keys; 0 legacy; the six derived `gnews` keys all `lastOkAt` at the 17:47:58 tick; ESPN and MEN absent; both Bluesky keys present with `failures: 0` (Athletic FC newestItemAt 12:19Z; Ornstein's newest is 2026-09-01 and the key already carries `silentAt` — a quiet account, not a parse failure). `space-profile check`: clean.*
|
|
1618
|
-
- [
|
|
1619
|
-
- [ ] MANUAL-VERIFY(2026-09-12): **Bluesky items parse on the box and one reaches a run.** Within a day: the Athletic FC key has `newestItemAt` within the last 24 h, and `journalctl … | grep 'opened a verify run'` shows a run whose `terms` include a covered club on a tick where the trace's item lines carry a `bsky.app/profile/…/post/…` link with a title that is the post's first line, not a URL. David Ornstein's key writes one `source silent` warn on the first tick past 7 days of its newest post (2026-09-01 at probe time) — expected, D-009.
|
|
1620
|
-
- [
|
|
1618
|
+
- [x] MANUAL-VERIFY(2026-09-12): **`gnews:he:IL:659c944029:q` gains a `newestItemAt`, or is named as the one that never does.** Neither `gnews:12:q` nor `gnews:76:q` ever carried one on the box (the 2026-09-12 dump). Three ticks after `apply`, read the row: either the key has `newestItemAt` (the migration carried nothing because there was nothing, and the query has since served a dated item) or it still has none — in which case the Hebrew club query is answering 200 with zero items and that is the next finding to file, not this story's. *(2026-09-14 21:20 IDT, read from the box after the 20:47 restart: the key carries `newestItemAt` 2026-09-14 06:59Z, `failures=0` — the Hebrew club query serves dated items.)*
|
|
1619
|
+
- [ ] MANUAL-VERIFY(2026-09-12): **Bluesky items parse on the box and one reaches a run.** Within a day: the Athletic FC key has `newestItemAt` within the last 24 h, and `journalctl … | grep 'opened a verify run'` shows a run whose `terms` include a covered club on a tick where the trace's item lines carry a `bsky.app/profile/…/post/…` link with a title that is the post's first line, not a URL. David Ornstein's key writes one `source silent` warn on the first tick past 7 days of its newest post (2026-09-01 at probe time) — expected, D-009. *(2026-09-14 21:20 IDT, read from the box after the 20:47 restart: the Athletic FC key has `newestItemAt` 2026-09-14 19:44Z, inside 24 h, `failures=0`; Ornstein's newest is 2026-09-02 as D-009 expects. The second half — a `bsky.app/profile/…/post/…` line inside a verify trace — not read yet.)*
|
|
1620
|
+
- [x] MANUAL-VERIFY(2026-09-12): **nothing invisible reaches the digest window file.** After the next 09:00 article, from `~/whatsapp-bot`: `~/.bun/bin/bun -e 'const s=require("fs").readFileSync(process.argv[1],"utf8");console.log((s.match(/[\u{061C}\u{200B}-\u{200F}\u{202A}-\u{202E}\u{2060}\u{2066}-\u{2069}\u{FEFF}\u{E0000}-\u{E007F}]/gu)??[]).length)' ~/whatsapp-bot/.mercury/spaces/football-friends/knowledge/feed-digest.md` prints `0`. A `bun -e` one-liner on purpose: the shell's `tr` does not take these escapes and would count nothing while looking like it counted. Positive control on the same day: at least one Telegram item is in the window (a `t.me/` link), so the strip had something to strip through. *(2026-09-14 21:20 IDT, read from the box after the 20:47 restart: `feed-digest.md` (1,843,849 bytes) has 0 invisible carriers; positive control 231 `t.me/` links in the same file.)*
|
|
1621
1621
|
- [ ] MANUAL-VERIFY(2026-09-12): **an undated item renders `seen`, and the run reads it right.** The Maccabi TLV club feed (`rss:https://www.maccabi-tlv.co.il/feed`) has no dates. On the first tick its item matches (a `מכבי תל אביב` headline), the verify prompt in the trace carries `- seen YYYY-MM-DD HH:MM | Maccabi Tel Aviv official | …` and not `- ? |`; the run's reply either posts the item with the date it read on the page or answers `NO_UPDATE` — never with the `seen` time presented as a publication date.
|
|
1622
|
-
- [
|
|
1622
|
+
- [x] MANUAL-VERIFY(2026-09-12): **the age-out reader has a number.** Over the first week: `journalctl --user -u mercury --since '-7d' | grep -c 'deferred items dropped unverified'`. Zero is a valid answer and means the `verify_max_items` trade never bit; a non-zero count is the measurement the bug's "M" half (priority for the deferred tail) waits for — read the `reason` per item (`ttl` is the age-out the bug describes, `cap` is a flood day evicting the tail through `BUFFER_CAP`). Either way, note the number in `docs/bugs/feed-watch-deferred-items-can-age-out-unverified.md`. *(2026-09-14 21:20 IDT, read from the box after the 20:47 restart: `grep -c 'deferred items dropped unverified'` over 7 days = **0**. Noted in the bug doc.)*
|
|
1623
1623
|
- [ ] MANUAL-VERIFY(2026-09-12): **the eight exclude terms fire on real items and on nothing else.** Over the first week at `log_level: debug` for one hour on a match day, or from the digest window file otherwise: `grep 'dropped by an exclude term'` naming `How to watch`, `live stream`, `livestream`, `WATCHLIVE`, `Bet Builder`, `Betting Tips`, `פנטזי ליגת` or `NBA` — each `title` in those lines is a viewing guide, stream spam, a betting tip, the fantasy product or basketball. A title that is a real story is the silent direction and the term comes out the same day.
|
|
1624
1624
|
|
|
1625
1625
|
## reporter-voice — live checks (added 2026-09-12, deploy-gated)
|
|
@@ -1635,8 +1635,8 @@ every probe below runs there from the owner's phone (`football-space-is-a-test-b
|
|
|
1635
1635
|
- [ ] MANUAL-VERIFY(2026-09-12): **a factual question reads as V1.** From the phone, in the test bed, ask a one-club question (`מה חדש לריאל?`). Expect one or two lines of talk with `(source, dd/mm)` at the end — not a scan-shaped block, no formal connective (לפיכך / אולם / בנוסף), no scan emoji. Read the row from `messages` (pre-normalisation) and run `lintScan` on it offline with `replyKind: "chat"`: zero findings.
|
|
1636
1636
|
- [ ] MANUAL-VERIFY(2026-09-12): **a joke gets a joke, in one line.** From the phone: `יא אפס הבוט`. Expect one line in the same coin, at most one emoji, no explanation and no closer of the "אני כאן" kind.
|
|
1637
1637
|
- [ ] MANUAL-VERIFY(2026-09-12): **an opinion is marked and follows the fact.** From the phone: `מה דעתך, גולר חותם עד 2032 או לא?` Expect the fact first with its certainty grade and source, then one line opening with `לדעתי` or `אם תשאלו אותי`, no source on that line. The next scheduled roundup and article after this probe carry no opinion line (grep the rows for `לדעתי`).
|
|
1638
|
-
- [ ] MANUAL-VERIFY(2026-09-12): **a video request names the uploading channel.** Re-ask the open 2026-09-07 line: `תביא לי וידאו של התקציר של ברצלונה`. Open the link's YouTube page and read the channel under the title; the reply's channel name must be that channel, not the competition or a phrase from the title. This closes the 2026-09-07 line above (`LALIGA EA SPORTS` was the title, `DAZN Futbol` the uploader) if it passes.
|
|
1639
|
-
- [ ] MANUAL-VERIFY(2026-09-12): **the glossary is read before names are written.** With `agent.trace_runs` on, the next on-demand task 24 run's trace shows a `read` of `knowledge/names.md` before the reply, and the page spells Valverde `ואלוורדה` and Raphinha `ראפיניה` (the two the week drifted on). A page with a name from the glossary spelled another way, or a trace with no read, fails this line — and the residual is a host-side reader (D-025's revisit trigger), not more prose. *(2026-09-12 on-demand run: the trace shows `knowledge/names.md` read 3 times. Neither Valverde nor Raphinha appears on that page, so the spelling half was not exercised — the `ולוורדה` hits in the trace are inside the digest items, not the reply. Open until a page names one of them.)*
|
|
1638
|
+
- [ ] MANUAL-VERIFY(2026-09-12): **a video request names the uploading channel.** Re-ask the open 2026-09-07 line: `תביא לי וידאו של התקציר של ברצלונה`. Open the link's YouTube page and read the channel under the title; the reply's channel name must be that channel, not the competition or a phrase from the title. This closes the 2026-09-07 line above (`LALIGA EA SPORTS` was the title, `DAZN Futbol` the uploader) if it passes. *2026-09-13 21:53 and 22:03 IDT the owner asked for the Barcelona and the United highlights (rows 5184, 5188); both replies said no official highlights yet, named the channels checked (La Liga's, Barcelona's; the Premier League's, United's) and gave the expected time — the § תפקיד shape. The links arrived at 00:19 through the verify run, labelled `ערוץ ברצלונה` / `ערוץ מנצ'סטר יונייטד`, which are the uploading channels (FC Barcelona, Manchester United). Not a link-in-reply, so not ticked; re-ask once a highlights video exists at the time of asking.*
|
|
1639
|
+
- [ ] MANUAL-VERIFY(2026-09-12): **the glossary is read before names are written.** With `agent.trace_runs` on, the next on-demand task 24 run's trace shows a `read` of `knowledge/names.md` before the reply, and the page spells Valverde `ואלוורדה` and Raphinha `ראפיניה` (the two the week drifted on). A page with a name from the glossary spelled another way, or a trace with no read, fails this line — and the residual is a host-side reader (D-025's revisit trigger), not more prose. *(2026-09-12 on-demand run: the trace shows `knowledge/names.md` read 3 times. Neither Valverde nor Raphinha appears on that page, so the spelling half was not exercised — the `ולוורדה` hits in the trace are inside the digest items, not the reply. Open until a page names one of them.)* **FAILED 2026-09-14** (read from the box): the 09:00 IDT article run (trace `mercury-1789365605093-64.jsonl`) made 10 tool calls — `date`, six `pinchtab` navs, two episode `read`s — and none read `knowledge/names.md`; the page and summary spell Mbappé `מבאפה` four times against the glossary's `אמבפה` ("לא מבאפה"). The 15:37 scan (trace `…-1789389457567-90.jsonl`, 20 calls, no glossary read) wrote `ולוורדה` against `ואלוורדה` ("לא ולוורדה"). D-025's revisit trigger has fired; `docs/ideas/glossary-injected-into-the-prompt.md` carries the evidence. Left unticked on purpose: it fails, and the fix is the host-side reader, not another prose line.
|
|
1640
1640
|
- [ ] MANUAL-VERIFY(2026-09-12): **the next correction of an earlier post has no preface.** When one happens (it cannot be forced; read the scan rows daily), the correcting line is a plain scan line stating the right fact — no `תיקון`, no `טעות`, no `זה לא היה` at the start — and the offline `lintScan` reports no `no-correction-preface` on it. The 2026-09-11 21:26 row (`תיקון על מסטנטואונו…`) is the shape that must not recur.
|
|
1641
1641
|
- [ ] MANUAL-VERIFY(2026-09-12): **a re-post of an unchanged fact is silent.** On the next day a Tier-1 journalist re-posts an agreement the group already read (the Güler shape of 2026-09-10 → 09-12), the verify run for that item ends `NO_UPDATE` or omits it; if S5's `<recent_posts>` block landed, the trace shows the item inside it.
|
|
1642
1642
|
|
|
@@ -1665,7 +1665,7 @@ record of why.
|
|
|
1665
1665
|
- [ ] MANUAL-VERIFY(2026-09-12): **every source name on that page opens an article.** Tap each name in the footer and each inline reference: the target is the report that was read, never `espn.com`, `bbc.co.uk/sport`, `marca.com` or another home page. The ESPN name in the footer is a link, not the text `[ESPN](https://…`. *(2026-09-12: the footer has four `[name](https://…/2026/sep/12/…)` links to article pages and the ESPN-style bare-bracket shape is absent; tap-through is the owner's phone read.)*
|
|
1666
1666
|
- [ ] MANUAL-VERIFY(2026-09-12): **the stored reply lints clean, with zero false sourceless findings.** From Windows, `lintArticle(<the stored reply>, { now: <the run's created_at>, windowH: 24, standard })` with `examples/profiles/football-reporter/standard.json`: no `item-has-source` on a line that names its source, no `date-in-window` whose excerpt is part of a URL, no `article-structure`, no `article-block-length`, no `article-footer-links-page`. An `item-trailing-stop` warning is a prompt-drift finding, not a lint defect — record it and move on. Anything else is either a real page defect (fix the page's next run, not the lint) or a lint bug (file it). *(2026-09-12 offline, mirror `bun`, `now` = the row's `created_at`, `windowH` 24: **9 findings, none of them the false-positive classes** — no `item-has-source` on a sourced line, no `date-in-window` on a URL, no `article-footer-links-page`. Real page defects: `article-length-band` (2082), `article-block-length` ×3, `article-structure` (מה הלאה 2 lines); prompt drift: `date-format-dd-mm` ×2 (מחר); `transfer-has-certainty` on the Arteta/Jesus quote about a *completed* summer move — record as a possible lint misfire on retrospective transfer mentions, decide with S1; `name-provenance` `unknown` because no trace was supplied to the offline call. The host's send-time lint logged the same 8 rules.)*
|
|
1667
1667
|
- [ ] MANUAL-VERIFY(2026-09-12): **the next 09:00 page passes the same two reads.** The morning after the deploy: the page on the phone shows the picked shape and the stored reply lints as above. Two pages in the shape, one on demand and one scheduled, is the checkpoint; one is a sample.
|
|
1668
|
-
- [
|
|
1668
|
+
- [x] MANUAL-VERIFY(2026-09-12): **both runs sent no file.** `journalctl --user -u mercury --since '-1day' | grep 'Scheduled run finished'` shows `files=0` for the on-demand run and for the 09:00 run (A10 — the 09-12 page arrived with a `.md` attachment; the sentence under § מה מפיקים is the only fix). A `files=1` is the prompt sentence not being enough, and goes back to the story as a finding, not to the code. *(2026-09-12 21:35:58: the on-demand run's `Scheduled run finished taskId=24 … outcome=posted … files=0`. The 09:00 half is open.)* *(2026-09-14 21:20 IDT, read from the box after the 20:47 restart: 09:00 runs of 09-13 (`09:07:19`) and 09-14 (`09:03:43`): `taskId=24 outcome=posted files=0`. Every task 24/621/699 run of the two days is `files=0` except the one posted meme, whose file is the meme.)*
|
|
1669
1669
|
- [x] MANUAL-VERIFY(2026-09-12): **the profile is not in drift.** After `space-profile apply`, `space-profile check --project ~/whatsapp-bot --profile ~/mercury-check/<slug>/examples/profiles/football-reporter` is clean for `football-friends`: the task prompt and `AGENTS.md` on the box are the repo's, byte for byte. *(Ticked 2026-09-12 21:30 IDT: `check` clean against the mirror profile after apply-2 (21:17) and apply-3 (21:31, the memes id); the mirror is `~/mercury-check/mnt-d-Projects-mercury`.)*
|
|
1670
1670
|
|
|
1671
1671
|
## box-honesty — live checks (added 2026-09-12, deploy-gated)
|
|
@@ -1674,7 +1674,7 @@ record of why.
|
|
|
1674
1674
|
|
|
1675
1675
|
- [x] MANUAL-VERIFY(2026-09-12): **the keys are on the box and `env dump` names them without printing them.** `mercury env dump` shows **both** `heartbeatUrl` and `heartbeatTasks` as set (source `env`) with **no value** — neither is on `SAFE_CONFIG_KEYS`, because the one misconfiguration an operator makes is swapping the two and a printed task list would then be the URL. `mercury env dump --json | grep -c '<any 8 characters of the ping key>'` is 0. If either shows a value, stop — `SAFE_CONFIG_KEYS` in `src/env-dump/manifest.ts` has a wrong key on it. *(Ticked 2026-09-12 21:30 IDT: `env dump` shows both as `env (set, not shown)`; `--json` carries `"set":true` and no `value` for either; `grep -c hc-ping` on the JSON is 0.)*
|
|
1676
1676
|
- [x] MANUAL-VERIFY(2026-09-12): **preflight hears the box.** `mercury preflight` reports `host.heartbeat` as `pass` with evidence `listed: ["24@football-friends", "621@football-friends"]` and `perTaskTemplate: true`, and the report file under `.mercury/preflight/` contains no fragment of the URL. Negative control: run once with `MERCURY_HEARTBEAT_TASKS=24,999` in the shell env — `host.heartbeat` is `warn` naming `999` — then unset it. (`mercury doctor` shows the same line: the check is in the environment scope.) *(Ticked 2026-09-12 21:30 IDT: `host.heartbeat` pass, evidence `listed: ["24@football-friends","621@football-friends"]`, `perTaskTemplate: true`, `activeCronTasks: 3`; the report file has 0 `hc-ping` fragments. **Negative control not runnable on this box**: `mercury.ts` `runAction` merges `.env` over `process.env` (".env values win"), so a shell `MERCURY_HEARTBEAT_TASKS=24,999` is overwritten and the check still passes — the control needs a project dir whose `.env` lacks the key, or the line rewritten.)*
|
|
1677
|
-
- [
|
|
1677
|
+
- [x] MANUAL-VERIFY(2026-09-12): **the first delivered run pings, and only a delivered run.** After the on-demand task 24 run (§5 step 12) or the next 09:00: `journalctl --user -u mercury --since '-1h' | grep -i heartbeat` shows exactly one `Heartbeat pinged taskId=24 spaceId=football-friends status=200` line for that run, **after** its `Scheduled run finished … outcome=posted` line, and the check on the service flips to up at the same minute. A verify one-shot (an extension-created `at` task) or a `NO_UPDATE` roundup slot in the same window must show **no** heartbeat line. The line carries no URL. *(2026-09-12: exactly one `Heartbeat pinged taskId=24 spaceId=football-friends status=200 durationMs=796` at 21:35:59, after the 21:35:58 `Scheduled run finished … outcome=posted`; no URL on the line. The verify one-shot 698 (21:15, posted) and the 21:35 verify run produced no heartbeat line. The `NO_UPDATE` roundup half waits for a quiet slot.)* *(22:01:59: exactly one `Heartbeat pinged taskId=621 spaceId=football-friends status=200 durationMs=831`, after the 22:01:58 `Scheduled run finished taskId=621 … outcome=posted`; no URL. The memes run 699 (posted 21:57, not in `MERCURY_HEARTBEAT_TASKS`) produced no heartbeat line — the list, not the outcome alone, gates the ping. The `NO_UPDATE` roundup half still waits for a quiet slot.)* *(2026-09-14 21:20 IDT, read from the box after the 20:47 restart: 09-13 09:07:20 and 09-14 09:03:44 each exactly one `Heartbeat pinged taskId=24 … status=200`, one second after `outcome=posted`; the `no_update` slots (621 at 16:01 both days, 699 at 13:02/21:00) and the verify one-shots have no heartbeat line; no URL on any line.)*
|
|
1678
1678
|
- [ ] MANUAL-VERIFY(2026-09-12): **the service alerts when the ping is late, and nothing on the box has to be alive for it.** No drill — the first live morning is the test. If the 09:00 article posts, the check stays up and the journal shows the ping; if it does not, the owner's phone hears it by 09:30 while the box is still silent, which is the whole point (a drill that pauses the check on the service proves only the service). Record which happened, with the time the ping or the alert arrived. *(2026-09-12 22:28–22:46 IDT — an involuntary drill of the second half: the WSL distro was torn down at 22:28:45 (a session's trace `grep` reached 27.5 GB RSS, the kernel OOM-killed it inside `init.scope`, WSL then killed init — `journalctl -b -1`), the `mercury-anchor` check went late and **the owner received the email alert while the box was dead**; the owner restarted the distro at 22:46 and the bot came up with it. So the alert path is proven end to end with nothing alive on the box; email is the channel (Telegram deliberately not connected). The 09:00 half — the article's own check — is still the first live morning. Anchor + ping loop were gone after the reboot (the logon task fires at logon, not at a distro restart) and were restored at 22:58 with `Start-ScheduledTask 'Start Mercury WSL'`.)*
|
|
1679
1679
|
- [ ] MANUAL-VERIFY(2026-09-12): **napkin's run is bounded, and the bound is readable.** On the next nightly distillation, `journalctl --user -u mercury --since '-12h' | grep 'Distilling space'` carries `timeoutMs=1200000 bashTimeoutSeconds=300` (the values the box actually resolved), and the same window has **no** `bash-timeout extension not resolvable` warn — that warn means the `-e` leg did not load and only the wall-clock bound applies. Then `grep -c 'awaitBounded' ~/whatsapp-bot/.mercury/extensions/napkin/index.ts` is non-zero on the **installed snapshot** (the repo and the image prove nothing). If the warn is there, `ls -l ~/whatsapp-bot/.mercury/extensions/node_modules/mercury-agent` is the first thing to read.
|
|
1680
1680
|
- [x] MANUAL-VERIFY(2026-09-12): **`check` opens the live database read-only.** With the bot running: `bun ~/mercury-src/scripts/space-profile.ts check --project ~/whatsapp-bot --profile ~/mercury-src/examples/profiles/football-reporter` exits as before (0 clean / 1 drift), and `ls -la --time-style=full-iso ~/whatsapp-bot/.mercury/state.db*` before and after shows `state.db` and `state.db-wal` with **unchanged** mtimes (the `-shm` may change; it is the shared-memory index, not data). Before this change the same command, run from a tree with a newer migration, rewrote the schema under the running bot. *(Ticked 2026-09-12 21:30 IDT: with the bot running, `check` exited 0 and `state.db` / `state.db-wal` mtimes were identical before and after (21:24:36.865 / .901).)*
|
|
@@ -1691,7 +1691,7 @@ Host-side; needs the restart of deploy 2 (`src/` moved, nothing baked) and the l
|
|
|
1691
1691
|
- [ ] MANUAL-VERIFY(2026-09-12): **a feed-watch failure reaches the owner's DM and not the group.** The next `Scheduled task handler failed … attempt=2` line for a task whose `name` is `feed-watch` (journal) is followed by `Task failure routed to the operator channel` and `Operator alerted … recipientThreadId=whatsapp:<owner jid>`, the owner's DM shows the ⚠️ "Reported here rather than in the space" message, and `select count(*) from messages where space_id='football-friends' and content like '%נכשלה ולא הפיקה%' and created_at > <restart epoch ms>` is 0. Two such failures happened in the week of 09-04→09-11 on their own; if none happens in the first live week, keep the line open rather than forcing one.
|
|
1692
1692
|
- [ ] MANUAL-VERIFY(2026-09-12): **a person's task still reports into the space.** The only person-created tasks on the box are 24 and 621 and neither is expected to fail, so this is pinned by `tests/task-failure-notice.test.ts` ("a person's task keeps today's notice in the space") until observed: the first `Scheduled task handler failed … attempt=2` for task 24 or 621 is followed by the ⚠️ "נכשלה ולא הפיקה" notice in the group and **no** `Task failure routed to the operator channel` line. Keep open; tick when observed.
|
|
1693
1693
|
- [ ] MANUAL-VERIFY(2026-09-12): **`longview.sources` renders on the dashboard and defaults to `both`.** Open the football space page → Extension settings for this space: a `sources` row with effective value `both` and source "default"; setting it to `all` is refused by the validator; `select value from space_config where space_id='football-friends' and key='longview.sources'` is empty unless the owner set it (§5 step 9).
|
|
1694
|
-
- [
|
|
1694
|
+
- [x] MANUAL-VERIFY(2026-09-12): **the article is still published under the default.** The first task 24 run after the restart: `longview: published a long reply` in the journal, the group gets the summary + link, `files=0`. If the owner set `scheduled`: a long chat answer from the owner's phone (ask for a five-paragraph history of a club) stays inline in chat with no Telegraph link, and the journal has `longview: reply origin is outside the space's sources … source=chat-sdk` at debug (set `log_level: debug` for the probe, back to `info` after). *(2026-09-12 on-demand run: the group got the summary and the stored row is the 3,391-char article, `files=0` — but **`longview: published a long reply` is logged at debug** (`hook.ts:650` in the installed snapshot) and the box runs `info`, so the journal cannot show it; the line as written cannot pass at `info`. Evidence used instead: the stored reply's `[longview:summary]` block and longview's `recent` store row, whose newest entry is this page — title `מחאת אוהדים מול קאריק…`, 2,735 chars, published 21:35 IDT. Either the line or the log level moves; noted for S7.)* *(2026-09-14 21:20 IDT, read from the box after the 20:47 restart: the info journal cannot carry the debug line, as noted; evidence used: the 09-13 and 09-14 09:00 runs are `outcome=posted files=0`, the stored assistant rows are the 3,507- and 4,480-char articles opening with `[longview:summary]`, and the owner read the 09-14 page — the RTL note of 12:52 was written from it.)*
|
|
1695
1695
|
- [x] MANUAL-VERIFY(2026-09-12): **`reporter.standard` is set and the file is seeded.** After `space-profile apply` + `check` clean: `select value from space_config where space_id='football-friends' and key='reporter.standard'` is `standard.json`; `~/whatsapp-bot/.mercury/spaces/football-friends/standard.json` exists and `diff` against the check mirror's `examples/profiles/football-reporter/standard.json` is empty (it will not be after S1/S2's next edit to the repo file — the seed is write-once; delete the box copy and re-`apply` when the values change). *(Ticked 2026-09-12 21:30 IDT: row = `standard.json`; `diff` of the box file against the mirror's is empty.)*
|
|
1696
1696
|
- [x] MANUAL-VERIFY(2026-09-12): **every scheduled reply gets its lint lines.** After the next posted verify run and the next article: `journalctl --user -u mercury --since '-1h' | grep 'reporter-lint:'` shows, per posted run, zero or more `reporter-lint: finding … rule=<id> taskId=<n> kind=scan|article` lines and exactly one `reporter-lint: <n> findings … kind=…` line with the same `taskId`; the article's line says `kind=article`; a `NO_UPDATE` run produces none. The posted text in the group is byte-identical to the `messages` row (the lint changed nothing) — compare one scan line by eye. *(2026-09-12 21:35:58, the on-demand article: 8 `reporter-lint: finding … taskId=24 kind=article` lines and exactly one `reporter-lint: 8 findings … taskId=24 kind=article errors=6 warnings=2 unknown=1 replyChars=3391`; the posted text is the stored row by construction (the lint only logs). Verify-run half waits for the next posted verify run.)* *(21:57:09, the first memes run: three `reporter-lint: finding … taskId=699 kind=scan` lines and exactly one `reporter-lint: 3 findings … taskId=699 kind=scan errors=3` line — the shape holds, the kind is wrong for a caption; bug filed, see the M1.11 block.)* *(Ticked 22:09 IDT: the 22:00 roundup (a scan reply, the shape the verify one-shot shares) logged zero finding lines and exactly one `reporter-lint: 0 findings … taskId=621 kind=scan errors=0`; the two `NO_UPDATE` verify one-shots 700 and 701 (21:36, 21:36) produced no lint line at all. A *posted* verify one-shot has not happened since the restart — when one does, its lines should look like the roundup's.)*
|
|
1697
1697
|
- [ ] MANUAL-VERIFY(2026-09-12): **the lint's first-week numbers are read.** Seven days after deploy 2: `journalctl --user -u mercury --since '-7d' | grep 'reporter-lint: finding' | grep -o 'rule=[a-z-]*' | sort | uniq -c | sort -rn` — the per-rule counts D-008 asked for. Paste them into `docs/goals/football-reporter-profile/roadmap.md` under M3.1's row and decide, per rule, whether the false-positive rate justifies the gate (`docs/ideas/reporter-lint-as-send-gate.md`).
|
|
@@ -1714,9 +1714,9 @@ Host-side; needs the restart of deploy 2 (`src/` moved, nothing baked) and the l
|
|
|
1714
1714
|
- [x] MANUAL-VERIFY(2026-09-12): **the next roundup slot gets both blocks; the 09:00 article gets one.** At the next `10:00`/`13:00`/`16:00`/`19:00`/`22:00` roundup: the journal has `digest appended … lane=roundup` **and** `recent posts appended … kind=lane lane=roundup` within the same minute, and the trace's prompt carries `<feed_watch_digest lane="roundup"` then a blank line then `<recent_posts`. At the next 09:00 (task 24): `digest appended … lane=null` and **no** `recent posts appended` within ±2 min; the trace carries `<feed_watch_digest ` and no `<recent_posts`. One block missing from the roundup, or one present on the article, is the failure. *(Ticked 2026-09-12 22:02 IDT. Roundup 22:00: `digest appended … lane=roundup` 22:00:03.319 and `recent posts appended … kind=lane lane=roundup posts=41 lines=58` 22:00:03.333 — the same second; the trace (`mercury-1789239603411-9`) carries `<feed_watch_digest lane="roundup" since=… until=… items="70">` and `<recent_posts hours="36" posts="41" lines="58">`. Article (the 21:31 on-demand run, trace `mercury-1789237883586-4`): `<feed_watch_digest since=… until=… items="180">` and **no** `<recent_posts` anywhere in the trace; the journal's only `recent posts appended` near it is 21:35:57 `kind=verify`, four and a half minutes later and the verify one-shot's. Observation, not a failure: the memes lane also gets a `kind=lane lane=memes` block (7,060 chars) by the same lane rule.)*
|
|
1715
1715
|
- [ ] MANUAL-VERIFY(2026-09-12): **the article's rows are out of the block; the roundup's are in.** In the first verify trace after a 09:00 article: the `<recent_posts>` block carries no line from the article's body (no line beginning `**`, no `מקורות:` footer line, no `[longview:summary]` marker line — the bare-marker rule drops those) and its `posts=` count does not include the article's reply; the same block **does** carry the most recent roundup's lines (a line whose text matches a roundup post of the last 36 h). The article's *summary* lines — the chat message that carried the Telegraph link — are the article's reply too and are absent with it; that is D-018's trade, said here so it is not read as a bug. *(2026-09-12 22:09, first look on the on-demand article: the article's lead sentence occurs the same number of times (3) in the verify traces of 21:35/21:51 and the lane traces of 21:55/22:00 as in the 21:55 chat-turn trace that carries no `<recent_posts>` at all — so the block adds none of the article's lines; the one extra `longview:summary` string in the lane traces is the task prompt's own mention. `posts=` went 39 (21:35:58, verify 701) → 40 (21:55, memes) → 41 (22:00, roundup, = +the meme); the +1 at 21:55 is either the article's summary row or the CLI chat reply stored 27 ms before the block was built — read the block itself in the first verify trace after the 09-13 article to settle the count half.)*
|
|
1716
1716
|
- [ ] MANUAL-VERIFY(2026-09-12): **the prefix grew by the measured amount, not more.** Seven days after the restart, from `~/whatsapp-bot` with a read-only `bun:sqlite` one-liner: average `task_runs.prompt_prefix_tokens` over the verify runs (`task_name = 'feed-watch'` or the verify one-shots' rows) of the seven days **before** the restart vs the seven days **after**. The corpus predicts +2,200 tokens on average (+7 %) and +3,600 at worst; a rise past ~+5,000 on average means the block is carrying something the measurement did not see (a chatty linked group; a long chat reply escaping the per-reply cap) and D-018's revisit trigger fires. Write both averages into the archive doc's Retrospective.
|
|
1717
|
-
- [
|
|
1717
|
+
- [x] MANUAL-VERIFY(2026-09-12): **the caps hold on a match weekend.** Over the Beitar / Maccabi Haifa (09-12), Manchester derby (09-13) and Tel Aviv derby (09-14) matches: `journalctl --user -u mercury --since '-4d' | grep 'recent posts appended' | grep -c 'truncated=true'`. Zero means 60 lines / 10,000 chars held (the corpus's worst was 54 / 7,552 and no slot truncated); a non-zero count is not a failure — read the `lines` and `shown` of those lines and note the numbers under D-019, which is where the caps would be raised. *(2026-09-14 21:20 IDT, read from the box after the 20:47 restart: `truncated=true` **65** times over 4 days — every one `kind=verify`, `lines` 64–101 → `shown=60`, `chars` 6,474–7,648; no roundup or article slot truncated. Numbers noted under D-019; not a failure by the line's own terms.)*
|
|
1718
1718
|
- [ ] MANUAL-VERIFY(2026-09-12): **a re-posted fact stays silent; a changed detail posts the detail only.** Cannot be forced without a second outlet re-posting on cue; observe over the first week instead. For every posted verify run (a `Scheduled run finished` with a reply, not `NO_UPDATE`): open the trace, read the `<recent_posts>` block and the posted line side by side — the posted line's fact must not already appear in the block unless a concrete detail changed, and then the line must carry the detail (a date, a fee, "official") and not restate the story. The V6 class (Güler ×3 in 25 h) recurring with the block present in the trace is the failure, and it is a **prompt** failure (the sentence in `verify.md`), not a mechanism one — the block was there.
|
|
1719
|
-
- [
|
|
1719
|
+
- [x] MANUAL-VERIFY(2026-09-12): **the link drop has a number, even if it is zero.** Over the first week: `journalctl --user -u mercury --since '-7d' | grep 'items dropped, links already posted'`. Zero lines is the expected answer (0 hits on the week's 1,132 item lines) and means no feed served a page the bot had already linked; a line means a lagging feed served an article page after the 09:00 article linked it (S1's A3 footer fix is what makes those links real) — read its `count` and note it under D-020. Positive control that the drop *can* fire is the test suite (`tests/feed-watch-watch.test.ts`, "an item whose link the bot already posted"), not the box. *(2026-09-14 21:20 IDT, read from the box after the 20:47 restart: `items dropped, links already posted` over 7 days = **0** lines. Noted under D-020.)*
|
|
1720
1720
|
|
|
1721
1721
|
## football-meme-lane (M1.11) — live checks (added 2026-09-12, deploy-gated)
|
|
1722
1722
|
|
|
@@ -1728,9 +1728,9 @@ Host-side; needs the restart of deploy 2 (`src/` moved, nothing baked) and the l
|
|
|
1728
1728
|
- [x] MANUAL-VERIFY(2026-09-12): **the article and the roundup see no meme.** The next `feed-watch: digest appended` lines for `lane=null` (task 24) and `lane=roundup` (task 621) carry `sourceLane=undefined`, and `~/whatsapp-bot/.mercury/spaces/football-friends/knowledge/feed-digest-window.md` and `…window.roundup.md` contain no `image:` and no `top scoring links` line. The `<feed_watch_digest>` header of the article's trace is byte-for-byte the pre-lane shape (no `lane=` attribute). *(2026-09-12 21:31 article half: the trace header is `<feed_watch_digest since=… until=… items="180">` with no `lane=` attribute; `feed-digest-window.md` has 0 `image:` and 0 `top scoring links` lines while the buffer held 25 meme rows. The logger drops undefined fields, so `sourceLane=undefined` is not printed — absence of the field is the pre-lane shape. Roundup half at the 22:00 slot.)* *(Ticked 2026-09-12 22:02 IDT, roundup half: `digest appended … lane=roundup items=70` with no `sourceLane` field (dropped as undefined, the pre-lane shape); `feed-digest-window.roundup.md` written 22:00 has 70 items, 0 `image:`, 0 `top scoring links`; the roundup's trace header is `<feed_watch_digest lane="roundup" …>` with no meme rows while the buffer held 26 laned meme rows that the memes run had just consumed.)*
|
|
1729
1729
|
- [x] MANUAL-VERIFY(2026-09-12): **the memes lane sees only memes.** The first run of the memes task (on demand from chat as the owner, or the 13:00 slot): `feed-watch: digest appended … lane=memes sourceLane=memes items=<n>`, and `knowledge/feed-digest-window.memes.md` in the space directory lists only `top scoring links : multi` rows with `image:`/`rank:` and no `terms:`; `feed-watch: digest cursor advanced … lane=memes` follows, `last_digest_at:football-friends:memes` is set and `last_digest_at:football-friends` (the article's) is unchanged. *(Ticked 2026-09-12 21:57 IDT, first memes run on demand (`mrctl tasks run 699` from chat, 21:55:28): `digest appended … lane=memes sourceLane=memes items=26 blockChars=6080`, then `digest cursor advanced … lane=memes … items=26` at 21:57:06; `feed-digest-window.memes.md` (26 items) has 26 `top scoring links` rows, 26 `rank:`, 19 `image:`, 0 `terms:`; `last_digest_at:football-friends:memes` = 18:55:39Z and the article's `last_digest_at:football-friends` still 18:31:23Z (the 21:31 run), roundup's unchanged.)*
|
|
1730
1730
|
- [x] MANUAL-VERIFY(2026-09-12): **a posted meme is one image with one caption line.** The group receives one image with a caption of at most ~100 characters in the space's register — no source parenthetical, no link, no "Reddit", no `[longview:summary]` — and `Scheduled run finished … outcome=posted files=1` in the journal. `mercury trace` on that run shows the download went to `/tmp/…` first and one file was moved into `outbox/`. *(Ticked 2026-09-12 21:57 IDT: `Scheduled run finished taskId=699 … outcome=posted files=1 replyChars=67 cost=0.165 model=claude-sonnet-5 durationMs=91893`; the stored row (5007) is one 67-char Hebrew line ending in a facepalm emoji — no parenthetical, no link, no "Reddit", no `[longview:summary]`; the trace shows `curl` downloads to `/tmp/meme_1.jpeg`…`/tmp/meme_3b.jpeg` (several tries, one `curl might have failed` retry) and exactly one file in `outbox/` (`bqq4lp3boyoh1.jpeg`, 37 KB, 21:56:53). Side finding: the send-path lint read the caption as `kind=scan` and logged three errors (`emoji-allowlist`, `item-has-source`, `item-has-date`) — filed as `docs/bugs/reporter-lint-scan-rules-fire-on-a-meme-caption.md`, log-only. Owner still to see the image on the phone.)* *(Owner's read 22:05 IDT: the meme itself "kinda funny", the caption "a bit cringy" and `יואפה` should be `אופ"א`; asked whether it was recent — the source post was published 2026-09-12 00:44, 21 h before the run, from reddit's top-of-the-day feed, and the caption was a translation of the post's title. Owner's bar going forward: only very hot/trending memes, not every day — a follow-up for the memes prompt, recorded in the round note §10.)*
|
|
1731
|
-
- [
|
|
1732
|
-
- [
|
|
1733
|
-
- [
|
|
1731
|
+
- [x] MANUAL-VERIFY(2026-09-12): **a `NO_UPDATE` run leaves the outbox empty.** The first memes slot that answers `NO_UPDATE` (an empty window, or every candidate skipped): `Scheduled run finished … outcome=no_update files=0`. A `files=1` on a `no_update` run is the residual the spec names (`runtime.ts` sends outbox files uncaptioned) — file it as a bug with the trace, and read whether the prompt's scratch-first step was followed. *(2026-09-14 21:20 IDT, read from the box after the 20:47 restart: four `no_update` memes runs — 09-13 13:03, 09-13 21:00, 09-14 13:02, 09-14 21:00 — all `files=0`.)*
|
|
1732
|
+
- [x] MANUAL-VERIFY(2026-09-12): **the ration is 3 → 4, not 3 → 6.** `select value from extension_state where extension='feed-watch' and key='host_cursor:football-friends'` (read-only) advances one reddit.com target per tick, and over an hour the `source_status:football-friends` row's `lastOkAt` for `rss:https://www.reddit.com/r/soccer/new/.rss` moves roughly every 12 minutes (four reddit targets on a 3-minute tick), not every 18. *(2026-09-14 21:20 IDT, read from the box after the 20:47 restart: `host_cursor` = `{"www.reddit.com":1}`; the four reddit targets' `lastOkAt` are 180 s apart in sequence — r/soccer/new 21:02:52, r/Barca 21:05:52, memes 21:08:52, match-thread 21:11:52 — one target per tick, a 12-minute cycle.)*
|
|
1733
|
+
- [x] MANUAL-VERIFY(2026-09-12): **cost is in the band.** After the first two days: `select created_at, cost from task_runs where task_id=<memes id> order by id` — each run $0.15–0.30, day total $0.30–0.60. Write the number into the README's Cost paragraph if it differs. *(2026-09-14 21:20 IDT, read from the box after the 20:47 restart: five runs: $0.165 (posted), $0.062, $0.122, $0.103, $0.113 (no_update) — **under** the $0.15–0.30 band, day totals $0.18–0.22. The README's Cost paragraph is high; not changed here.)*
|
|
1734
1734
|
- [ ] MANUAL-VERIFY(2026-09-12): **a meme never becomes a fact.** After the first posted meme, the next napkin distillation's `Distilling space` line and `knowledge/.napkin/…` on the box mention nothing from the meme (the distiller's allowlist is `chat-sdk`/`cli`, `distill-input.ts:34-37`; a scheduled post is `scheduler`), and `knowledge/` in the space directory has no file the memes run wrote (`ls -lt --time-style=full-iso ~/whatsapp-bot/.mercury/spaces/football-friends/knowledge/ | head` — the newest entries are the digest files). *(2026-09-12 21:58, `knowledge/` half: the only files newer than the run's window file are `feed-digest.memes.md` (feed-watch's own digest) — nothing the run wrote. Napkin half waits for tonight's distillation.)*
|
|
1735
1735
|
|
|
1736
1736
|
## feed-watch-source-scorecard (M1.12) — live checks (added 2026-09-12, deploy-gated)
|
|
@@ -1738,13 +1738,13 @@ Host-side; needs the restart of deploy 2 (`src/` moved, nothing baked) and the l
|
|
|
1738
1738
|
> Goal `feed-watch-social-sources`, S9 of `docs/notes/2026-09-12-group-link-round.md`. Everything reaches the box in the installed snapshot under `~/whatsapp-bot/.mercury/extensions/feed-watch/`, refreshed by the restart that lands the round (no `apply`, no image rebuild, no `tasks` row). The counters accrue from that restart; a review needs a fortnight, the checks below need a day. The reader is `scripts/feed-watch-scorecard.ts`, run from the check mirror against the live DB **read-only** (`readonly: true`; the test hashes the file before and after). Every DB read here is `select … ` through `sqlite3 -readonly` or the script — never a write.
|
|
1739
1739
|
|
|
1740
1740
|
- [x] MANUAL-VERIFY(2026-09-12): **the snapshot carries the counters.** `grep -c 'source_yield' ~/whatsapp-bot/.mercury/extensions/feed-watch/yield.ts` ≥ 1, `grep -c 'writeYield' ~/whatsapp-bot/.mercury/extensions/feed-watch/watch.ts` ≥ 2 (definition + two call sites), `grep -c 'recordPosted' ~/whatsapp-bot/.mercury/extensions/feed-watch/index.ts` ≥ 1, and `ls ~/whatsapp-bot/.mercury/extensions/feed-watch/links.ts` says *No such file* — the join uses S5's `posted.ts`. A snapshot with `writeYield` but without `recordPosted` in `index.ts` is a half deploy. *(Ticked 2026-09-12 21:30 IDT: `source_yield` in `yield.ts`=2, `writeYield` in `watch.ts`=5, `recordPosted` in `index.ts`=2, `links.ts` absent.)*
|
|
1741
|
-
- [ ] MANUAL-VERIFY(2026-09-12): **the row appears on the first tick and is bounded.** Within five minutes of the restart: `select length(value) from extension_state where extension='feed-watch' and key='source_yield:football-friends'` returns a number, and after 24 h it is **under 8 KB** (85 keys × one day ≈ 30 bytes each plus keys); after 14 days **under 40 KB** (the test's worst case at ninety keys, three-digit counts everywhere, is 47.7 KB — a real fortnight is far smaller). Write the 24 h and 7-day numbers into `docs/goals/feed-watch-social-sources/decisions.md` D-024 (**Row growth**). A row over 40 KB is the revisit trigger.
|
|
1742
|
-
- [
|
|
1743
|
-
- [
|
|
1741
|
+
- [ ] MANUAL-VERIFY(2026-09-12): **the row appears on the first tick and is bounded.** Within five minutes of the restart: `select length(value) from extension_state where extension='feed-watch' and key='source_yield:football-friends'` returns a number, and after 24 h it is **under 8 KB** (85 keys × one day ≈ 30 bytes each plus keys); after 14 days **under 40 KB** (the test's worst case at ninety keys, three-digit counts everywhere, is 47.7 KB — a real fortnight is far smaller). Write the 24 h and 7-day numbers into `docs/goals/feed-watch-social-sources/decisions.md` D-024 (**Row growth**). A row over 40 KB is the revisit trigger. *(2026-09-14 21:20 IDT, read from the box after the 20:47 restart: 48 h after the 09-12 restart the row is **9,303 bytes** for 79 sources over three UTC days — above the 8 KB one-day estimate because it holds three days, on track for well under 40 KB. 7-day and 14-day reads still open; 48 h number written to D-024.)*
|
|
1742
|
+
- [x] MANUAL-VERIFY(2026-09-12): **the buckets are UTC days and nothing older than 14 survives.** `select value from extension_state where … key='source_yield:football-friends'` — every day key is `YYYY-MM-DD`, the newest is today's UTC date (at 01:00 IDT that is *yesterday's* calendar day — expected, not a bug), and on day 15 the oldest key present is 13 days back. *(2026-09-14 21:20 IDT, read from the box after the 20:47 restart: day keys are exactly `2026-09-12`, `2026-09-13`, `2026-09-14`; the day-15 prune cannot be read before 09-26.)*
|
|
1743
|
+
- [x] MANUAL-VERIFY(2026-09-12): **`seen` counts distinct items, not parses.** After 24 h, the `seen` column of the scorecard for a 25-item RSS feed polled every 3 minutes is in the tens (its new items that day), **not** ~12,000 (25 × 480 ticks). If it is in the thousands, the first-sight guard is not in the snapshot. *(2026-09-14 21:20 IDT, read from the box after the 20:47 restart: over ~2.5 days: r/soccer/new 405, BBC football 310, AS portada 171 — tens per day, not thousands.)*
|
|
1744
1744
|
- [x] MANUAL-VERIFY(2026-09-12): **the buffer rows carry their source.** `select value from extension_state where … key='buffer:football-friends'` — every row written after the restart has `"sourceKey":"rss:…"` / `"telegram:…"` / `"gnews:…"`; rows without it are older than the restart and gone within 36 h. The keys are the ones `source_status:football-friends` uses (S4's stable `gnews` keys), not a `gnews:<n>:` legacy shape. *(Ticked 2026-09-12 21:30 IDT: every row written after the restart carries `sourceKey` (6 of 450 at 21:25, shapes `rss`/`telegram`/`gnews`, the stable `gnews:<hl>:<gl>:<hash>:q` keys); the rest predate the restart.)*
|
|
1745
1745
|
- [x] MANUAL-VERIFY(2026-09-12): **an echo is recorded once and the line says so.** On the first day a story two watched sources both carry (a transfer every outlet runs) has one buffer row with `"alsoFrom":["…"]`, and the next digest's `knowledge/feed-digest-window.md` (or the `<feed_watch_digest>` block in the run trace) carries that story's line ending `| sources: 2` (or more) while every singleton line ends in `terms: …` exactly as before. `grep -c 'sources: ' ~/whatsapp-bot/.mercury/spaces/football-friends/knowledge/feed-digest-window.md` ≥ 1 once such a story has occurred; a day with none is not a failure — say so and re-check the next day. *(Ticked 2026-09-12: the 21:31 article window `feed-digest-window.md` (186 lines) carries one line ending `sources: 2`-style (`grep -c 'sources: '` = 1) and every other line ends in `terms: …`; the roundup window of 19:00 had none yet, as expected before the restart.)*
|
|
1746
|
-
- [
|
|
1747
|
-
- [
|
|
1746
|
+
- [x] MANUAL-VERIFY(2026-09-12): **`released` moves on a verify run and on the digest.** After the next `feed-watch: opened a verify run … verified=N`, the scorecard's `rel` column summed over all sources grows by N; after the next `feed-watch: digest appended … items=M` (task 24 or 621), it grows by M (or by `shown` when the line says the cap bit). *(2026-09-12: after the 21:31 `digest appended … items=180` the `rel` sum is 22, not 180 — `creditYield` counts only shown rows that carry a `sourceKey`, i.e. rows buffered after the restart; the other 158 predate it. Expected to converge within 36 h; re-check on the 09-13 09:00 digest.)* *(21:57, memes digest: the meme feed's row went from `no-data` to `[26,26,0,26,0]` — `released` grew by exactly the 26 items the `lane=memes` digest showed, all post-restart rows, so the lane digest credits like the main one.)* *(22:09: `rel` sum 74 = 22 (article, 21:31) + 26 (memes digest, all post-restart rows) + 26 of the roundup's 70 (the post-restart share); 10 keys now carry `released`, 0 `posted`. Still converging; the 09-13 09:00 digest is the first that can grow by its full `items=`.)* *(2026-09-14 21:20 IDT, read from the box after the 20:47 restart: `rel` summed over all sources is **1,830** (was 22 on 09-12 22:09); the last 24 h's `opened a verify run` lines sum `verified=` to 259 — the pre-restart rows have aged out and the column now moves with every hand-off.)*
|
|
1747
|
+
- [x] MANUAL-VERIFY(2026-09-12): **`posted` moves on the article and says which sources.** After the next 09:00 article (a non-error run whose reply carries item links), `journalctl --user -u mercury --since '-1h' | grep 'posted links credited to their sources'` shows one info line with `kind=digest`, `links=<n>`, `credited=<m>` and a `sources` map; the scorecard's `post` column for those keys is ≥ 1. **On a verify post the line is absent by construction** — scan lines carry no URL (measured 0 of 89 in the week of 09-04 → 09-12); the footer of the scorecard says so. A `NO_UPDATE` run writes no line at all. *(2026-09-12 21:35 on-demand article: **no line, by construction on day one.** Three of the four footer links match buffer rows exactly (Guardian, Mundo Deportivo, Walla), but those rows predate the 21:15 restart and carry no `sourceKey`, so `recordPosted` skips them and logs `posted links match no buffered item` at debug. The line appears once the buffer has turned over (≤ 36 h) — re-read after the 09-14 article. The fourth link is an `i.guim.co.uk` image, never an item.)* *(2026-09-14 21:20 IDT, read from the box after the 20:47 restart: 09-14 09:03:41 `posted links credited to their sources … kind=digest links=11 credited=9 sources={"rss:https://www.one.co.il/rss":3,"rss:https://www.skysports.com/rss/11095":2,…}`; `post` summed = 16. The 09-13 line on the previous host build printed `sources=[object Object]`; the 09-14 line prints JSON.)*
|
|
1748
1748
|
- [ ] MANUAL-VERIFY(2026-09-12): **a chat reply credits nothing.** Post the string `[feed-watch:verify] https://www.one.co.il/` into the test group as the owner. The `source_yield` row's `posted` slots are unchanged and the journal has no `posted links credited` line for that run (the guard is `source === scheduler`, absence denies; the same predicate as S3's digest guard).
|
|
1749
1749
|
- [x] MANUAL-VERIFY(2026-09-12): **the reader is read-only and prints the table.** From the check mirror: `cd ~/mercury-check/<slug> && ~/.bun/bin/bun scripts/feed-watch-scorecard.ts --project ~/whatsapp-bot --space football-friends --profile examples/profiles/football-reporter` exits 0 and prints one row per manifest key (≈ 85), each with its `↳ reason` line from the manifest comment, a header naming the window, and a footer with the row's byte size and the `posted` / `gnews` / `released` caveats. Run `sha256sum ~/whatsapp-bot/.mercury/state.db` immediately before and after: **identical**. Also `--days 3` clamps the window and `--days 99` prints 14. *(Ticked 2026-09-12 21:30 IDT: from the mirror: exit 0, 86 rows each with `↳`, header names the 14-day window, footer present; `sha256sum state.db` identical before and after (`3763c2db2686b24a…`); `--days 3` prints `last 3 day(s)`, `--days 99` prints `14`.)*
|
|
1750
1750
|
- [x] MANUAL-VERIFY(2026-09-12): **the flags name the right things on the first day.** On day one the scorecard flags almost every key `no-posted` (the article has not run) — expected. After the first article: sources whose items the article linked lose the flag; `silent` appears on exactly the keys `source_status` has `silentAt` for (compare with `select value from extension_state where … key='source_status:football-friends'`); `orphan` appears on **no** key (S4's prune ran) — an `orphan` flag here is S4's migration having left a row, report it to the coordinator rather than fixing it here; `no-data` appears on a key only until its first tick with items. *(Ticked 2026-09-12 21:35 IDT, first read at 21:26: `silent` on exactly the one key `source_status` has `silentAt` for (david-ornstein bsky, silent since 09-12); `orphan` on no key; `no-data` on the meme feed alone — it was polled for the first time at 21:27, one minute after the read; `never-matched` on seven. **`no-posted` appeared on zero keys**, not "almost every" — the flag evidently needs a released item first, so on a day with no article it is empty; re-read after the 09-13 article to see it move.)*
|
|
@@ -1863,3 +1863,16 @@ Shadow-side unless a line says otherwise: nothing here touches a live conversati
|
|
|
1863
1863
|
- [x] MANUAL-VERIFY(2026-09-13): **a body that claims a flag is refused before anything runs.** The same POST with `"isBotMentioned":true` added to `event` is **400** with `"fields":["isBotMentioned"]` and an error mentioning *derived*; the shadow's `outbox.jsonl` byte size is unchanged afterwards (`stat -c %s` before and after). (ticked 2026-09-13 ~14:45 IDT, deploy 3: host `45d1f61`, base `mercury-agent:0.22.0` = `b37bafe6c7f7`, derived `mercury-agent-ext:44e6ae55a84b`) 400 `{"error":"isBotMentioned: derived by the simulator from the stanza … never accepted from a caller …","fields":["isBotMentioned"]}`; `outbox.jsonl` 0 bytes before and after.
|
|
1864
1864
|
- [ ] MANUAL-VERIFY(2026-09-13): **a refusal is a status, not a stream.** A POST whose `chatJid` has no `conversations` row in the snapshot is **404** with `"refusal":"unknown-conversation"` and `content-type: application/json`; a second POST on the same thread while the first of the streamed check above is still running is **409** `thread-already-recording`. *(2026-09-13: the 404 half holds — `404 application/json`, body `no conversations row for … in this snapshot. Injecting would create one …`; the 409 `thread-already-recording` half waits on the streamed check below.)*
|
|
1865
1865
|
- [ ] MANUAL-VERIFY(2026-09-13): **multipart lands a file in the shadow's inbox.** `curl -sN -X POST … -F 'event={"chatJid":…,"senderJid":…,"text":"<the trigger> look"}' -F 'file=@/tmp/probe.png;type=image/png'` streams to a `result`; `ls -t ~/mercury-shadow/current/.mercury/spaces/<space>/inbox/ | head -1` is a `<ms>-0-probe.png` written in the last minute, and the trace's prompt names it. `shadow down` afterwards.
|
|
1866
|
+
|
|
1867
|
+
## longview-rtl-lead — live checks (added 2026-09-14, deploy-gated)
|
|
1868
|
+
|
|
1869
|
+
`examples/extensions/longview/render/telegraph-nodes.ts`: `withRtlLead` puts a
|
|
1870
|
+
U+200F before a block whose text is Hebrew but whose first strong character is
|
|
1871
|
+
Latin — the page title (`<header dir="auto">` on telegra.ph) and every block.
|
|
1872
|
+
Owner's note 2026-09-14 12:52 IDT on the `Pro Ref מודה בטעות…` headline.
|
|
1873
|
+
Reaches the box as the installed extension snapshot on the next restart — no
|
|
1874
|
+
rebuild, but not restart-free.
|
|
1875
|
+
|
|
1876
|
+
- [x] MANUAL-VERIFY(2026-09-14): **the installed snapshot carries it.** ✅ 2026-09-14 20:48 IDT: restart at 20:47:50 in a proven gap (0 containers, 0 pi); journal `Bundled extension source updated — reinstalling name=longview`; `grep -c withRtlLead` on the installed snapshot = 3. After the restart: `grep -c withRtlLead ~/whatsapp-bot/.mercury/extensions/longview/render/telegraph-nodes.ts` is 2 or more (definition plus the `parseBlock` call). The repo is not the evidence.
|
|
1877
|
+
- [x] MANUAL-VERIFY(2026-09-14): **a Latin-led headline lays out right-to-left on the phone.** On the next article whose `#` line opens with a Latin token (an outlet or a body such as `Pro Ref`, `VAR`, `UEFA` in Latin): open the page; the title reads as Hebrew, right-aligned, punctuation in place. `curl -s <page url> | grep -c $'\xe2\x80\x8f'` from the box is non-zero — the byte probe, not `$'\u200f'`, which the Bash tool strips. A Hebrew-first title is untouched (count 0 is correct there, not a failure). *(2026-09-14 21:35 IDT — mechanism verified on live telegra.ph through the **installed** snapshot rather than by waiting: `deriveTitle` → `publish` imported from `~/whatsapp-bot/.mercury/extensions/longview/`, throwaway account, title `VAR, UEFA, Pro Ref — בדיקת תצוגה של כותרת שנפתחת באנגלית`. Fixed page: `<header class="tl_article_header" dir="auto">`, its `<h1>` carries exactly one U+200F and opens `VAR`; the control page — same markdown, the lead stripped from the title — has 0 in its `<h1>` and opens `VAR`, i.e. the LTR layout of the 12:52 note. Body blocks carry 5 leads. The two pages are `telegra.ph/VAR-UEFA-Pro-Ref--…-09-14` (fixed) and `…-09-14-2` (control); the owner's phone glance at the first is the visual half — **confirmed by the owner 2026-09-14 21:45 IDT: "looks right on the phone".** The bot itself would **not** publish a test article: one-shot tasks 786 and 787 answered "לא כותב את זה. זו כתבה מזויפת…" and "לא מפרסם תוכן שנראה כמו ידיעה אמיתית, גם בתור בדיקה" — the standard's own answer, and why the renderer was driven directly. Both one-shots deleted themselves; task 24's cursor untouched.)*
|
|
1878
|
+
|
|
@@ -83,6 +83,42 @@ export function truncateByCodePoint(text: string, max: number): string {
|
|
|
83
83
|
return points.slice(0, max).join("");
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
const HEBREW_RE = /[\u0590-\u05FF]/;
|
|
87
|
+
// The first strong-directional character. Latin letters read left-to-right,
|
|
88
|
+
// Hebrew right-to-left; digits, punctuation, quotes and emoji are neutral and
|
|
89
|
+
// take their direction from whichever strong character comes first.
|
|
90
|
+
const FIRST_STRONG_RE = /[A-Za-z\u00C0-\u024F\u0590-\u05FF]/;
|
|
91
|
+
const RLM = "\u200F";
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Lead a Hebrew block that *opens* with Latin so it still lays out
|
|
95
|
+
* right-to-left.
|
|
96
|
+
*
|
|
97
|
+
* A `dir="auto"` element takes its direction from its first strong character,
|
|
98
|
+
* and Telegraph's page title is exactly that element. `Pro Ref מודה בטעות…`
|
|
99
|
+
* (2026-09-14) opens with two Latin words, so the whole headline went
|
|
100
|
+
* left-to-right and the Hebrew read scrambled on the phone. The standard
|
|
101
|
+
* permits Latin at the head of a line — an outlet name, a player whose Hebrew
|
|
102
|
+
* spelling is unclear — so this is the renderer's job, not the writer's: a
|
|
103
|
+
* right-to-left mark before the first character is a strong RTL letter that
|
|
104
|
+
* draws nothing, and the block's own Hebrew then decides its direction.
|
|
105
|
+
*
|
|
106
|
+
* Applied only where the block contains Hebrew *and* its first strong
|
|
107
|
+
* character is Latin. An English page and a Hebrew-first line come back
|
|
108
|
+
* untouched, so a page that never needed the mark is byte-identical.
|
|
109
|
+
*/
|
|
110
|
+
export function withRtlLead(text: string): string {
|
|
111
|
+
if (!HEBREW_RE.test(text)) return text;
|
|
112
|
+
const first = FIRST_STRONG_RE.exec(text);
|
|
113
|
+
if (!first || HEBREW_RE.test(first[0])) return text;
|
|
114
|
+
return RLM + text;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** `parseInline` for a block's own text: the bidi lead is decided per block. */
|
|
118
|
+
function parseBlock(text: string): TelegraphNode[] {
|
|
119
|
+
return parseInline(withRtlLead(text));
|
|
120
|
+
}
|
|
121
|
+
|
|
86
122
|
/**
|
|
87
123
|
* Derive a page title.
|
|
88
124
|
*
|
|
@@ -162,7 +198,11 @@ export function deriveTitle(markdown: string, max = 80): string {
|
|
|
162
198
|
lastSpace > max * 0.6 ? cut.slice(0, lastSpace) : cut
|
|
163
199
|
).trimEnd();
|
|
164
200
|
}
|
|
165
|
-
|
|
201
|
+
// The mark is added after the cap: it is one code point Telegraph counts
|
|
202
|
+
// like any other, and a title cut to the limit must still lead right-to-left.
|
|
203
|
+
return withRtlLead(
|
|
204
|
+
truncateByCodePoint(candidate, Math.min(max, TITLE_MAX - 1)),
|
|
205
|
+
);
|
|
166
206
|
}
|
|
167
207
|
|
|
168
208
|
/**
|
|
@@ -362,7 +402,7 @@ const IMAGE_LINE = /^\s{0,3}!\[([^\]]*)\]\(([^)\s]*)(?:\s+"[^"]*")?\)\s*$/;
|
|
|
362
402
|
|
|
363
403
|
/** Map a markdown heading level onto Telegraph's available tags. */
|
|
364
404
|
function headingNode(level: number, text: string): TelegraphElement {
|
|
365
|
-
const children =
|
|
405
|
+
const children = parseBlock(text);
|
|
366
406
|
if (level === 1) return { tag: "h3", children };
|
|
367
407
|
if (level === 2) return { tag: "h4", children };
|
|
368
408
|
// Telegraph stops at h4 — deeper headings become a bold paragraph so the
|
|
@@ -392,7 +432,9 @@ export function markdownToTelegraphNodes(markdown: string): TelegraphNode[] {
|
|
|
392
432
|
const children: TelegraphNode[] = [];
|
|
393
433
|
parts.forEach((part, idx) => {
|
|
394
434
|
if (idx > 0) children.push({ tag: "br" });
|
|
395
|
-
|
|
435
|
+
// Each `br`-separated part is its own line on the page and its own bidi
|
|
436
|
+
// paragraph, so the lead is decided per part, not once per block.
|
|
437
|
+
children.push(...parseBlock(part));
|
|
396
438
|
});
|
|
397
439
|
nodes.push({ tag: "p", children });
|
|
398
440
|
};
|
|
@@ -465,11 +507,11 @@ export function markdownToTelegraphNodes(markdown: string): TelegraphNode[] {
|
|
|
465
507
|
if (src) {
|
|
466
508
|
const children: TelegraphNode[] = [{ tag: "img", attrs: { src } }];
|
|
467
509
|
if (caption) {
|
|
468
|
-
children.push({ tag: "figcaption", children:
|
|
510
|
+
children.push({ tag: "figcaption", children: parseBlock(caption) });
|
|
469
511
|
}
|
|
470
512
|
nodes.push({ tag: "figure", children });
|
|
471
513
|
} else if (caption) {
|
|
472
|
-
nodes.push({ tag: "p", children:
|
|
514
|
+
nodes.push({ tag: "p", children: parseBlock(caption) });
|
|
473
515
|
}
|
|
474
516
|
i++;
|
|
475
517
|
continue;
|
|
@@ -479,7 +521,7 @@ export function markdownToTelegraphNodes(markdown: string): TelegraphNode[] {
|
|
|
479
521
|
const boldLine = BOLD_LINE.exec(line);
|
|
480
522
|
if (boldLine?.[2]?.trim()) {
|
|
481
523
|
flushParagraph();
|
|
482
|
-
nodes.push({ tag: "h4", children:
|
|
524
|
+
nodes.push({ tag: "h4", children: parseBlock(boldLine[2].trim()) });
|
|
483
525
|
i++;
|
|
484
526
|
continue;
|
|
485
527
|
}
|
|
@@ -488,7 +530,7 @@ export function markdownToTelegraphNodes(markdown: string): TelegraphNode[] {
|
|
|
488
530
|
const pull = PULLQUOTE.exec(line);
|
|
489
531
|
if (pull?.[1]?.trim()) {
|
|
490
532
|
flushParagraph();
|
|
491
|
-
nodes.push({ tag: "aside", children:
|
|
533
|
+
nodes.push({ tag: "aside", children: parseBlock(pull[1].trim()) });
|
|
492
534
|
i++;
|
|
493
535
|
continue;
|
|
494
536
|
}
|
|
@@ -509,7 +551,7 @@ export function markdownToTelegraphNodes(markdown: string): TelegraphNode[] {
|
|
|
509
551
|
}
|
|
510
552
|
nodes.push({
|
|
511
553
|
tag: "blockquote",
|
|
512
|
-
children:
|
|
554
|
+
children: parseBlock(quoted.join(" ").trim()),
|
|
513
555
|
});
|
|
514
556
|
continue;
|
|
515
557
|
}
|
|
@@ -529,7 +571,7 @@ export function markdownToTelegraphNodes(markdown: string): TelegraphNode[] {
|
|
|
529
571
|
if (!m) break;
|
|
530
572
|
// Nested lists are flattened to one level — Telegraph renders nested
|
|
531
573
|
// <ul> poorly on mobile and v1 does not need the hierarchy.
|
|
532
|
-
items.push({ tag: "li", children:
|
|
574
|
+
items.push({ tag: "li", children: parseBlock((m[1] ?? "").trim()) });
|
|
533
575
|
i++;
|
|
534
576
|
}
|
|
535
577
|
nodes.push({ tag: isBullet ? "ul" : "ol", children: items });
|
|
@@ -190,8 +190,9 @@
|
|
|
190
190
|
|
|
191
191
|
- Tier-1: Romano, Ornstein, Stone, Di Marzio. ברצלונה: Romero, Condis, Polo.
|
|
192
192
|
ריאל: Cortegana, J.F. Díaz.
|
|
193
|
-
- אתרים רציניים: BBC, Sky, ESPN, The Athletic, ONE, ספורט 5, וואלה, ynet,
|
|
194
|
-
Mundo Deportivo, Sport או Relevo לעובדות משחק ולסיקור מספרד.
|
|
193
|
+
- אתרים רציניים: BBC, Sky, ESPN, The Athletic, Guardian, ONE, ספורט 5, וואלה, ynet,
|
|
194
|
+
ומרקה, AS, Mundo Deportivo, Sport או Relevo לעובדות משחק ולסיקור מספרד.
|
|
195
|
+
WhoScored לסטטיסטיקה וציונים בלבד, לא לחדשות.
|
|
195
196
|
- אסור לצטט: Sun, Mirror, Star, Express, Mail, 90min, Caught Offside,
|
|
196
197
|
Football Insider, TeamTalk, Sport Witness, Don Balón, Fichajes,
|
|
197
198
|
El Chiringuito, Bleacher Report, ואגרגטורים.
|
|
@@ -227,6 +228,13 @@
|
|
|
227
228
|
ולכן הכלל לא חל עליו.
|
|
228
229
|
- תאריך בסוגריים: יום וחודש בלבד (למשל 25/08), בלי שנה. השנה עדיין חובה
|
|
229
230
|
לוודא במקור לפני הכתיבה — היא פשוט לא מוצגת.
|
|
231
|
+
- תוצאה: כמו בעיתונות הישראלית, המספר הגדול מימין — הקורא קורא מימין
|
|
232
|
+
לשמאל וקורא "2:0" גם כשהספרות כתובות 0:2. תמיד בצורת פועל שאומר מי
|
|
233
|
+
ניצח: "הפועל ב"ש ניצחה 0:2 את הפועל פ"ת" (ספורט 1, הארץ, 13/09), "יונייטד
|
|
234
|
+
הפסידה 0:1 לסיטי", "תיקו 1:1 בין X ל-Y". לא "ניצחה 2:0", לא "מחקה 4:2",
|
|
235
|
+
ולא הצורה קבוצה–תוצאה–קבוצה ("לבאנטה 0:2 ברצלונה") — חבר בקבוצה קרא אותה
|
|
236
|
+
כ-2 ללבאנטה (13/09), ובאותה כתבה הופיעו "ניצחה 0:1" ו"ניצחה 2:0" זו ליד
|
|
237
|
+
זו (14/09). כלל אחד לכל הודעה: הגדול מימין, והפועל אומר של מי הוא.
|
|
230
238
|
- אימוג'י מועדון קבוע, בתחילת השורה: בסריקה, ובכותרת סעיף של הכתבה (לא
|
|
231
239
|
בצ'אט):
|
|
232
240
|
מנצ'סטר יונייטד 👹, ברצלונה ❤️💙, ריאל מדריד 🤍, הפועל תל אביב ❤️,
|
|
@@ -47,8 +47,10 @@
|
|
|
47
47
|
|
|
48
48
|
- מנצ'סטר (Manchester) — הכרעת המנהל, 12/09 (הכתבה של אותו יום כתבה "מנצ'שר"). לא "מנצ'שר", לא "מנצסטר". יונייטד וסיטי שתיהן "מנצ'סטר".
|
|
49
49
|
- אופ"א (UEFA) — הכרעת המנהל, 12/09 (כיתוב של מם כתב "יואפה"). בגרשיים: לא "יואפה", לא "אופא".
|
|
50
|
+
- בארסה (Barça) — הכרעת המנהל, 13/09 (תשובה בצ'אט כתבה "ברצה"). הכינוי של ברצלונה: לא "ברצה", לא "בארצה". השם המלא נשאר "ברצלונה".
|
|
50
51
|
|
|
51
52
|
## מונחים
|
|
52
53
|
|
|
53
54
|
- צמד, לא "דאבל": שני שערים של שחקן באותו משחק. הכרעת המנהל, 11/09.
|
|
54
55
|
- שלושער: שלושה שערים של שחקן באותו משחק (וואלה, 12/09). "האטריק" מקובל גם; לא "הטריק".
|
|
56
|
+
- דרבי: רק משחק בין שתי קבוצות מאותה עיר — הפועל ת"א–מכבי ת"א, בית"ר–הפועל ירושלים, מכבי–הפועל חיפה, יונייטד–סיטי (דרבי מנצ'סטר), ריאל–אתלטיקו (דרבי מדריד). ברצלונה–ריאל הוא "קלאסיקו". כל משחק אחר הוא משחק ליגה, גם כשהוא גדול: מכבי חיפה–קריית שמונה נכתב "דרבי" בטעות (14/09). הכרעת המנהל, 14/09.
|
|
@@ -53,7 +53,11 @@
|
|
|
53
53
|
|
|
54
54
|
## מה מפיקים
|
|
55
55
|
|
|
56
|
-
הודעה אחת: **התמונה,
|
|
56
|
+
הודעה אחת: **התמונה, ושתי שורות של טקסט** שנשלחות ככיתוב שלה — שורת
|
|
57
|
+
הכיתוב, ומתחתיה, בשורה משלה, הקישור לפוסט כפי שהוא מופיע בשורת הדייג'סט
|
|
58
|
+
(`https://www.reddit.com/r/.../comments/...`). הקישור הוא התשובה ל"מאיפה זה?"
|
|
59
|
+
(הכרעת המנהל, 14/09, אחרי ששאלו והבוט לא ידע). הוא לא מקבל הקדמה ולא
|
|
60
|
+
סוגריים — שורת קישור עירומה ותו לא. שורת הכיתוב:
|
|
57
61
|
|
|
58
62
|
- **נשענת על הפוסט, לא על דמיון**: הבסיס הוא כותרת הפוסט שבשורה (ואם
|
|
59
63
|
פתחת את הפוסט — גם הטקסט שלו). מתרגמים או מנסחים אותה מחדש בעברית של
|
|
@@ -63,8 +67,8 @@
|
|
|
63
67
|
מצחיק יותר מהתמונה יוצא מביך.
|
|
64
68
|
- בסגנון של § סגנון — אחד מהחבר'ה שמעביר מם, לא כתב. עד 100 תווים. אפשר
|
|
65
69
|
אימוג'י אחד, לא חובה. שמות — לפי `knowledge/names.md`.
|
|
66
|
-
- בלי מקור
|
|
67
|
-
הבדיחה. אם הטקסט שבתמונה באנגלית, מותר **לתרגם** את הפאנץ' שבתמונה
|
|
70
|
+
- בלי מקור בסוגריים בתוך הכיתוב, בלי "מרדיט", בלי "מם", ובלי להסביר את
|
|
71
|
+
הבדיחה. המקור הוא שורת הקישור שמתחת — לא הטקסט. אם הטקסט שבתמונה באנגלית, מותר **לתרגם** את הפאנץ' שבתמונה
|
|
68
72
|
לעברית — בקצרה, ורק כשהכותרת לא עושה את זה כבר. תרגום, לא פאנץ' חדש.
|
|
69
73
|
- בלי `[longview:summary]` ובלי כותרות. זה לא התוצר של המשימה הזאת.
|
|
70
74
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mercury-agent",
|
|
3
|
-
"version": "0.23.0
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "Personal AI assistant for chat platforms (WhatsApp, Slack, Discord, Telegram)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Avishai Tsabari",
|
|
@@ -90,15 +90,15 @@
|
|
|
90
90
|
"typecheck:examples": "bunx tsc --noEmit -p tsconfig.examples.json",
|
|
91
91
|
"typecheck:resources": "bunx tsc --noEmit -p tsconfig.resources.json",
|
|
92
92
|
"typecheck:tests": "bunx tsc --noEmit -p tsconfig.tests.json",
|
|
93
|
-
"lint": "bunx biome check src/ tests/ scripts/space-profile.ts scripts/football-harness.ts scripts/football-audit.ts scripts/ops-preflight.ts scripts/archive-erase-space.ts scripts/check-image-sources.ts scripts/check-sync-calls.ts scripts/feed-watch-scorecard.ts examples/extensions/archive/ examples/extensions/feed-watch/ examples/extensions/gws/ examples/extensions/napkin/ examples/extensions/pinchtab/ examples/extensions/overview/ examples/extensions/longview/ examples/extensions/yahoo-mail/ examples/extensions/morning/ resources/",
|
|
94
|
-
"lint:fix": "bunx biome check --fix src/ tests/ scripts/space-profile.ts scripts/football-harness.ts scripts/football-audit.ts scripts/ops-preflight.ts scripts/archive-erase-space.ts scripts/check-image-sources.ts scripts/check-sync-calls.ts scripts/feed-watch-scorecard.ts examples/extensions/archive/ examples/extensions/feed-watch/ examples/extensions/gws/ examples/extensions/napkin/ examples/extensions/pinchtab/ examples/extensions/overview/ examples/extensions/longview/ examples/extensions/yahoo-mail/ examples/extensions/morning/ resources/",
|
|
93
|
+
"lint": "bunx biome check src/ tests/ scripts/check.ts scripts/space-profile.ts scripts/football-harness.ts scripts/football-audit.ts scripts/ops-preflight.ts scripts/archive-erase-space.ts scripts/check-image-sources.ts scripts/check-sync-calls.ts scripts/feed-watch-scorecard.ts examples/extensions/archive/ examples/extensions/feed-watch/ examples/extensions/gws/ examples/extensions/napkin/ examples/extensions/pinchtab/ examples/extensions/overview/ examples/extensions/longview/ examples/extensions/yahoo-mail/ examples/extensions/morning/ resources/",
|
|
94
|
+
"lint:fix": "bunx biome check --fix src/ tests/ scripts/check.ts scripts/space-profile.ts scripts/football-harness.ts scripts/football-audit.ts scripts/ops-preflight.ts scripts/archive-erase-space.ts scripts/check-image-sources.ts scripts/check-sync-calls.ts scripts/feed-watch-scorecard.ts examples/extensions/archive/ examples/extensions/feed-watch/ examples/extensions/gws/ examples/extensions/napkin/ examples/extensions/pinchtab/ examples/extensions/overview/ examples/extensions/longview/ examples/extensions/yahoo-mail/ examples/extensions/morning/ resources/",
|
|
95
95
|
"check:silent-catch": "bun run scripts/check-silent-catch.ts",
|
|
96
96
|
"check:sync-calls": "bun run scripts/check-sync-calls.ts",
|
|
97
97
|
"check:dep-floors": "bun run scripts/check-dep-floors.ts",
|
|
98
98
|
"check:image-sources": "bun run scripts/check-image-sources.ts",
|
|
99
99
|
"check:no-hijack-verbs": "bun run scripts/check-no-hijack-verbs.ts src",
|
|
100
|
-
"check": "bun run check
|
|
101
|
-
"check:fix": "bun run check
|
|
100
|
+
"check": "bun run scripts/check.ts",
|
|
101
|
+
"check:fix": "bun run scripts/check.ts --fix",
|
|
102
102
|
"smoke:image": "bun run scripts/smoke-image.ts"
|
|
103
103
|
},
|
|
104
104
|
"dependencies": {
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The build context `mercury build` hands to Docker.
|
|
3
|
+
*
|
|
4
|
+
* `mercury build` does not build from a checkout: it runs from the installed
|
|
5
|
+
* package, so it stages the files the image needs into a temp directory and
|
|
6
|
+
* builds from there. Until 0.23.0 the list of files to stage was typed by hand
|
|
7
|
+
* here, next to the `COPY` lines in `container/Dockerfile` that name the same
|
|
8
|
+
* files. Commit a4d7128 added a `COPY` and not the list entry, and
|
|
9
|
+
* `mercury build` failed at that step for every user while CI — which builds
|
|
10
|
+
* from the full repo via `container/build.sh` — stayed green.
|
|
11
|
+
*
|
|
12
|
+
* There is no list here now. The Dockerfile's own `COPY` instructions are the
|
|
13
|
+
* only statement of what the image carries, parsed by the same
|
|
14
|
+
* `parseCopyInstructions` that stamps `/app/image-manifest.json` and that
|
|
15
|
+
* `scripts/check-image-sources.ts` gates on. A `COPY` added to the Dockerfile
|
|
16
|
+
* is staged on the next build with no second edit.
|
|
17
|
+
*
|
|
18
|
+
* Kept in its own module because `mercury.ts` calls `program.parse()` at
|
|
19
|
+
* import time and so cannot be imported from a test.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
copyFileSync,
|
|
24
|
+
existsSync,
|
|
25
|
+
lstatSync,
|
|
26
|
+
mkdirSync,
|
|
27
|
+
readdirSync,
|
|
28
|
+
} from "node:fs";
|
|
29
|
+
import { dirname, join } from "node:path";
|
|
30
|
+
import { parseCopyInstructions } from "../agent/image-manifest.js";
|
|
31
|
+
|
|
32
|
+
/** The Dockerfile `mercury build` builds, as a build-context path. */
|
|
33
|
+
export const DOCKERFILE_SOURCE = "container/Dockerfile";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build-context paths the Dockerfile reads, de-duplicated, in first-seen
|
|
37
|
+
* order. `--from=<stage>` copies are already dropped by the parser: they come
|
|
38
|
+
* from another image, not from the context.
|
|
39
|
+
*/
|
|
40
|
+
export function buildContextSources(dockerfile: string): string[] {
|
|
41
|
+
return [...new Set(parseCopyInstructions(dockerfile).map((e) => e.source))];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Copy a directory tree, skipping `node_modules` and symlinks. Bun's `cpSync`
|
|
46
|
+
* is broken on Windows, hence the manual walk.
|
|
47
|
+
*/
|
|
48
|
+
export function copyDirRecursive(src: string, dest: string): void {
|
|
49
|
+
mkdirSync(dest, { recursive: true });
|
|
50
|
+
for (const entry of readdirSync(src)) {
|
|
51
|
+
if (entry === "node_modules") continue;
|
|
52
|
+
const srcPath = join(src, entry);
|
|
53
|
+
const destPath = join(dest, entry);
|
|
54
|
+
const stat = lstatSync(srcPath);
|
|
55
|
+
if (stat.isSymbolicLink()) continue;
|
|
56
|
+
if (stat.isDirectory()) {
|
|
57
|
+
copyDirRecursive(srcPath, destPath);
|
|
58
|
+
} else {
|
|
59
|
+
copyFileSync(srcPath, destPath);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Stage every `COPY` source of `dockerfile` from `packageRoot` into
|
|
66
|
+
* `contextDir`, at the same relative path, so the Dockerfile builds unchanged.
|
|
67
|
+
*
|
|
68
|
+
* A source that is not in the package is a hard error naming it, raised
|
|
69
|
+
* before Docker runs: the alternative is Docker's own "failed to compute cache
|
|
70
|
+
* key … not found" after thirty cached steps, which is the message this
|
|
71
|
+
* module exists to retire. Returns the staged sources for the caller to print.
|
|
72
|
+
*/
|
|
73
|
+
export function stageBuildContext(
|
|
74
|
+
packageRoot: string,
|
|
75
|
+
contextDir: string,
|
|
76
|
+
dockerfile: string,
|
|
77
|
+
): string[] {
|
|
78
|
+
// The Dockerfile is staged first and unconditionally: `docker build -f`
|
|
79
|
+
// reads it from the context. Today it also happens to COPY itself (for the
|
|
80
|
+
// manifest stamp), but the build's own input must not depend on a COPY that
|
|
81
|
+
// is there for another reason and could be removed with it.
|
|
82
|
+
const sources = [
|
|
83
|
+
DOCKERFILE_SOURCE,
|
|
84
|
+
...buildContextSources(dockerfile).filter((s) => s !== DOCKERFILE_SOURCE),
|
|
85
|
+
];
|
|
86
|
+
for (const source of sources) {
|
|
87
|
+
const src = join(packageRoot, source);
|
|
88
|
+
const dest = join(contextDir, source);
|
|
89
|
+
if (!existsSync(src)) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`build context: the Dockerfile copies "${source}" but it is not in the package at ${src}. ` +
|
|
92
|
+
`The installed package may be corrupt (try: npm install -g mercury-agent), ` +
|
|
93
|
+
`or a COPY was added to container/Dockerfile for a file the npm tarball does not ship.`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
if (lstatSync(src).isDirectory()) {
|
|
97
|
+
copyDirRecursive(src, dest);
|
|
98
|
+
} else {
|
|
99
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
100
|
+
copyFileSync(src, dest);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return sources;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The `--build-arg` that stamps the image with its version. The Dockerfile
|
|
108
|
+
* defaults `MERCURY_VERSION` to `unknown`, and an image labelled `unknown`
|
|
109
|
+
* fails the host's `image.contract` preflight — so a build that cannot read
|
|
110
|
+
* its own version refuses, exactly as `container/build.sh` does.
|
|
111
|
+
*/
|
|
112
|
+
export function versionBuildArgs(hostVersion: string | null): string[] {
|
|
113
|
+
if (!hostVersion) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
"build context: could not read the package version — refusing to build an image labelled 'unknown'.",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return ["--build-arg", `MERCURY_VERSION=${hostVersion}`];
|
|
119
|
+
}
|
package/src/cli/mercury.ts
CHANGED
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
chmodSync,
|
|
7
7
|
copyFileSync,
|
|
8
8
|
existsSync,
|
|
9
|
-
lstatSync,
|
|
10
9
|
mkdirSync,
|
|
11
10
|
readdirSync,
|
|
12
11
|
readFileSync,
|
|
@@ -132,6 +131,12 @@ import {
|
|
|
132
131
|
import { Db } from "../storage/db.js";
|
|
133
132
|
import { DbIntegrityError } from "../storage/integrity.js";
|
|
134
133
|
import { removeSpaceWorkspace } from "../storage/memory.js";
|
|
134
|
+
import {
|
|
135
|
+
copyDirRecursive,
|
|
136
|
+
DOCKERFILE_SOURCE,
|
|
137
|
+
stageBuildContext,
|
|
138
|
+
versionBuildArgs,
|
|
139
|
+
} from "./build-context.js";
|
|
135
140
|
import {
|
|
136
141
|
LOCAL_BUILD_IMAGE_TAG,
|
|
137
142
|
resolveBuildImageTags,
|
|
@@ -164,22 +169,6 @@ const TEMPLATES_DIR = join(PACKAGE_ROOT, "resources/templates");
|
|
|
164
169
|
const PROFILES_DIR = join(PACKAGE_ROOT, "resources/profiles");
|
|
165
170
|
const VALID_EXT_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
166
171
|
|
|
167
|
-
function copyDirRecursive(src: string, dest: string): void {
|
|
168
|
-
mkdirSync(dest, { recursive: true });
|
|
169
|
-
for (const entry of readdirSync(src)) {
|
|
170
|
-
if (entry === "node_modules") continue;
|
|
171
|
-
const srcPath = join(src, entry);
|
|
172
|
-
const destPath = join(dest, entry);
|
|
173
|
-
const stat = lstatSync(srcPath);
|
|
174
|
-
if (stat.isSymbolicLink()) continue;
|
|
175
|
-
if (stat.isDirectory()) {
|
|
176
|
-
copyDirRecursive(srcPath, destPath);
|
|
177
|
-
} else {
|
|
178
|
-
copyFileSync(srcPath, destPath);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
172
|
function isPortInUse(port: string): boolean {
|
|
184
173
|
if (process.platform === "win32") {
|
|
185
174
|
const result = spawnSync("netstat", ["-ano"], {
|
|
@@ -576,66 +565,37 @@ function buildAction(): void {
|
|
|
576
565
|
mkdirSync(tmpDir, { recursive: true });
|
|
577
566
|
|
|
578
567
|
try {
|
|
579
|
-
//
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
"src/extensions/reserved.ts",
|
|
592
|
-
"src/extensions/permission-guard.ts",
|
|
593
|
-
"src/extensions/bash-timeout.ts",
|
|
594
|
-
"src/types.ts",
|
|
595
|
-
];
|
|
596
|
-
|
|
597
|
-
for (const file of filesToCopy) {
|
|
598
|
-
const src = join(PACKAGE_ROOT, file);
|
|
599
|
-
const dest = join(tmpDir, file);
|
|
600
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
601
|
-
copyFileSync(src, dest);
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
const resourcesSrc = join(PACKAGE_ROOT, "resources");
|
|
605
|
-
const resourcesDest = join(tmpDir, "resources");
|
|
606
|
-
if (!existsSync(resourcesSrc)) {
|
|
607
|
-
console.error(`❌ resources/ not found at ${resourcesSrc}`);
|
|
608
|
-
console.error(
|
|
609
|
-
"The installed package may be corrupt. Try: npm install -g mercury-agent",
|
|
610
|
-
);
|
|
611
|
-
process.exit(1);
|
|
612
|
-
}
|
|
613
|
-
// Bun's cpSync is broken on Windows — use manual recursive copy.
|
|
614
|
-
copyDirRecursive(resourcesSrc, resourcesDest);
|
|
615
|
-
if (!existsSync(resourcesDest)) {
|
|
616
|
-
console.error(`❌ Failed to copy resources/ into build context`);
|
|
617
|
-
console.error(
|
|
618
|
-
` Source: ${resourcesSrc} (exists: ${existsSync(resourcesSrc)})`,
|
|
568
|
+
// The Dockerfile's own COPY lines say what to stage — there is no second
|
|
569
|
+
// list to keep in step (see build-context.ts for the release that broke
|
|
570
|
+
// when there was one). The Dockerfile is itself a COPY source, so it lands
|
|
571
|
+
// in the context with everything else.
|
|
572
|
+
const dockerfilePath = join(PACKAGE_ROOT, DOCKERFILE_SOURCE);
|
|
573
|
+
let staged: string[];
|
|
574
|
+
let buildArgs: string[];
|
|
575
|
+
try {
|
|
576
|
+
staged = stageBuildContext(
|
|
577
|
+
PACKAGE_ROOT,
|
|
578
|
+
tmpDir,
|
|
579
|
+
readFileSync(dockerfilePath, "utf8"),
|
|
619
580
|
);
|
|
620
|
-
|
|
581
|
+
buildArgs = versionBuildArgs(readHostVersion(PACKAGE_ROOT));
|
|
582
|
+
} catch (err) {
|
|
583
|
+
console.error(`❌ ${err instanceof Error ? err.message : String(err)}`);
|
|
621
584
|
process.exit(1);
|
|
622
585
|
}
|
|
623
586
|
|
|
624
|
-
const examplesSrc = join(PACKAGE_ROOT, "examples", "extensions");
|
|
625
|
-
const examplesDest = join(tmpDir, "examples", "extensions");
|
|
626
|
-
copyDirRecursive(examplesSrc, examplesDest);
|
|
627
|
-
|
|
628
587
|
console.log(`📦 Building container image...`);
|
|
629
588
|
console.log(` Package root: ${PACKAGE_ROOT}`);
|
|
630
|
-
console.log(` Build context: ${tmpDir}`);
|
|
589
|
+
console.log(` Build context: ${tmpDir} (${staged.length} sources)`);
|
|
631
590
|
console.log(` Tags: ${imageTags.join(", ")}\n`);
|
|
632
591
|
const result = spawnSync(
|
|
633
592
|
"docker",
|
|
634
593
|
[
|
|
635
594
|
"build",
|
|
595
|
+
...buildArgs,
|
|
636
596
|
...imageTags.flatMap((tag) => ["-t", tag]),
|
|
637
597
|
"-f",
|
|
638
|
-
join(tmpDir,
|
|
598
|
+
join(tmpDir, DOCKERFILE_SOURCE),
|
|
639
599
|
tmpDir,
|
|
640
600
|
],
|
|
641
601
|
{ stdio: "inherit" },
|
package/src/core/heartbeat.ts
CHANGED
|
@@ -156,16 +156,30 @@ export type HeartbeatPingResult =
|
|
|
156
156
|
* A non-2xx is a failure — healthchecks-style services answer 200 for a
|
|
157
157
|
* known check and 404 for an unknown one, and "the ping went somewhere" is
|
|
158
158
|
* not "the ping was heard".
|
|
159
|
+
*
|
|
160
|
+
* The bound is an explicit controller + `setTimeout`, not
|
|
161
|
+
* `AbortSignal.timeout()`: on Bun 1.3.14 for Windows that signal's timer does
|
|
162
|
+
* not keep the event loop alive, so it only fires while *something else*
|
|
163
|
+
* does — a real socket, in production. Awaited with nothing else pending (a
|
|
164
|
+
* fake fetch that settles only from the abort) the loop went idle and the
|
|
165
|
+
* `await` never returned; the Linux build fires it regardless. A bound that
|
|
166
|
+
* relies on the thing it bounds to keep the clock running is not a bound;
|
|
167
|
+
* this one owns its timer and clears it.
|
|
159
168
|
*/
|
|
160
169
|
export async function pingHeartbeat(
|
|
161
170
|
url: string,
|
|
162
171
|
deps: { fetch: HeartbeatFetch; timeoutMs?: number },
|
|
163
172
|
): Promise<HeartbeatPingResult> {
|
|
164
173
|
const timeoutMs = deps.timeoutMs ?? HEARTBEAT_TIMEOUT_MS;
|
|
174
|
+
const controller = new AbortController();
|
|
175
|
+
const timer = setTimeout(
|
|
176
|
+
() => controller.abort(new DOMException("timed out", "TimeoutError")),
|
|
177
|
+
timeoutMs,
|
|
178
|
+
);
|
|
165
179
|
try {
|
|
166
180
|
const response = await deps.fetch(url, {
|
|
167
181
|
method: "GET",
|
|
168
|
-
signal:
|
|
182
|
+
signal: controller.signal,
|
|
169
183
|
redirect: "follow",
|
|
170
184
|
});
|
|
171
185
|
if (response.ok) return { ok: true, status: response.status };
|
|
@@ -184,5 +198,7 @@ export async function pingHeartbeat(
|
|
|
184
198
|
ok: false,
|
|
185
199
|
reason: typeof code === "string" && code !== "" ? code : name || "error",
|
|
186
200
|
};
|
|
201
|
+
} finally {
|
|
202
|
+
clearTimeout(timer);
|
|
187
203
|
}
|
|
188
204
|
}
|
|
@@ -214,7 +214,8 @@ export function lintScheduledReply(
|
|
|
214
214
|
windowH: SCAN_WINDOW_H,
|
|
215
215
|
standard,
|
|
216
216
|
// The caption is chat-shaped by contract (tasks/memes.md): one line
|
|
217
|
-
// in the group's voice
|
|
217
|
+
// in the group's voice plus the bare post link under it (owner,
|
|
218
|
+
// 2026-09-14), no source in the text, no date, one emoji allowed.
|
|
218
219
|
replyKind: kind === "caption" ? "chat" : "scan",
|
|
219
220
|
});
|
|
220
221
|
return { kind, findings };
|