mercury-agent 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/container/Dockerfile +1 -0
  2. package/container/Dockerfile.base +1 -0
  3. package/docs/behavior-layers.md +4 -2
  4. package/docs/configuration.md +57 -0
  5. package/docs/container-lifecycle.md +6 -0
  6. package/docs/deployment.md +21 -0
  7. package/docs/extensions.md +33 -0
  8. package/docs/goals/feed-watch-social-sources/blocked-sources-research.md +289 -0
  9. package/docs/goals/feed-watch-social-sources/decisions.md +332 -0
  10. package/docs/goals/feed-watch-social-sources/goal.md +217 -0
  11. package/docs/goals/feed-watch-social-sources/roadmap.md +212 -0
  12. package/docs/goals/football-match-day-mode/decisions.md +378 -0
  13. package/docs/goals/football-match-day-mode/goal.md +109 -0
  14. package/docs/goals/football-match-day-mode/roadmap.md +267 -0
  15. package/docs/goals/football-reporter-profile/roadmap.md +4 -4
  16. package/docs/goals/rehearsal-bench/decisions.md +13 -0
  17. package/docs/goals/rehearsal-bench/goal.md +2 -2
  18. package/docs/goals/rehearsal-bench/roadmap.md +9 -7
  19. package/docs/goals/release-gate/README.md +4 -2
  20. package/docs/goals/release-gate/goal.md +2 -2
  21. package/docs/goals/release-gate/roadmap.md +34 -10
  22. package/docs/live-testing.md +64 -11
  23. package/docs/memory.md +1 -1
  24. package/docs/pending-verification.md +619 -40
  25. package/docs/profile-guide.md +2 -2
  26. package/docs/subagents.md +64 -9
  27. package/examples/extensions/archive/queue.ts +93 -5
  28. package/examples/extensions/feed-watch/config.ts +56 -3
  29. package/examples/extensions/feed-watch/digest.ts +180 -24
  30. package/examples/extensions/feed-watch/feeds.ts +246 -13
  31. package/examples/extensions/feed-watch/index.ts +137 -24
  32. package/examples/extensions/feed-watch/skill/SKILL.md +1 -1
  33. package/examples/extensions/feed-watch/watch.ts +443 -10
  34. package/examples/extensions/longview/hook.ts +192 -42
  35. package/examples/profiles/football-reporter/AGENTS.md +14 -0
  36. package/examples/profiles/football-reporter/README.md +60 -1
  37. package/examples/profiles/football-reporter/config.yaml +458 -15
  38. package/examples/profiles/football-reporter/standard.json +23 -1
  39. package/examples/profiles/football-reporter/tasks/roundup.md +45 -0
  40. package/package.json +8 -6
  41. package/resources/agents/explore.md +7 -0
  42. package/resources/agents/worker.md +0 -1
  43. package/resources/pi-extensions/subagent/agents.ts +123 -99
  44. package/resources/pi-extensions/subagent/index.ts +1179 -885
  45. package/resources/pi-extensions/subagent/spawn.ts +251 -0
  46. package/resources/templates/mercury.example.yaml +5 -0
  47. package/src/adapters/slack.ts +2 -0
  48. package/src/adapters/whatsapp-db-lookups.ts +95 -0
  49. package/src/adapters/whatsapp-egress.ts +135 -0
  50. package/src/adapters/whatsapp-ingress.ts +62 -13
  51. package/src/adapters/whatsapp-mentions.ts +307 -0
  52. package/src/adapters/whatsapp-thread.ts +54 -0
  53. package/src/adapters/whatsapp.ts +100 -28
  54. package/src/agent/container-entry.ts +36 -3
  55. package/src/agent/container-error.ts +13 -1
  56. package/src/agent/container-runner.ts +454 -137
  57. package/src/agent/image-contract.ts +1 -0
  58. package/src/agent/image-refresh.ts +25 -1
  59. package/src/agent/instance-id.ts +185 -0
  60. package/src/bridges/whatsapp.ts +110 -30
  61. package/src/cli/mercury.ts +1358 -246
  62. package/src/config-file.ts +74 -1
  63. package/src/config.ts +65 -1
  64. package/src/core/commands.ts +5 -1
  65. package/src/core/conversation.ts +8 -1
  66. package/src/core/handler.ts +222 -13
  67. package/src/core/operator-alerts.ts +33 -15
  68. package/src/core/permissions.ts +19 -0
  69. package/src/core/router.ts +27 -0
  70. package/src/core/routes/capability.ts +23 -1
  71. package/src/core/routes/chat.ts +1 -0
  72. package/src/core/routes/connections.ts +52 -4
  73. package/src/core/routes/dashboard.ts +9 -3
  74. package/src/core/runtime.ts +402 -60
  75. package/src/core/shadow-bridge.ts +554 -0
  76. package/src/core/shadow-outbox.ts +257 -0
  77. package/src/core/simulate-ingress.ts +949 -0
  78. package/src/env-dump/build-deps.ts +128 -0
  79. package/src/env-dump/build.ts +572 -0
  80. package/src/env-dump/deps.ts +84 -0
  81. package/src/env-dump/diff.ts +721 -0
  82. package/src/env-dump/manifest.ts +430 -0
  83. package/src/extensions/api.ts +29 -1
  84. package/src/extensions/bash-timeout.ts +112 -0
  85. package/src/extensions/image-builder.ts +66 -2
  86. package/src/extensions/loader.ts +15 -0
  87. package/src/extensions/types.ts +10 -0
  88. package/src/logger.ts +13 -0
  89. package/src/main.ts +153 -34
  90. package/src/ops/shadow-root.ts +60 -0
  91. package/src/ops/shadow-service.ts +932 -0
  92. package/src/ops/shadow-snapshot.ts +40 -1
  93. package/src/preflight/boot.ts +118 -0
  94. package/src/preflight/build-deps.ts +112 -0
  95. package/src/preflight/checks/credential.ts +8 -4
  96. package/src/preflight/checks/host-deps.ts +46 -21
  97. package/src/preflight/checks/roundtrip.ts +4 -0
  98. package/src/preflight/doctor-lines.ts +74 -0
  99. package/src/preflight/platform-path.ts +33 -0
  100. package/src/preflight/probe-container.ts +25 -3
  101. package/src/preflight/report.ts +116 -3
  102. package/src/preflight/run.ts +84 -6
  103. package/src/profile/space-profile.ts +16 -1
  104. package/src/server.ts +26 -0
  105. package/src/storage/db.ts +36 -0
  106. package/src/text/reporter-lint.ts +64 -0
@@ -0,0 +1,332 @@
1
+ # Decisions: feed-watch source adapters — social and messaging platforms as a ladder
2
+
3
+ **Goal**: [feed-watch-social-sources](goal.md)
4
+
5
+ > Inherits every decision in
6
+ > [`whatsapp-bot-hardening/decisions.md`](../whatsapp-bot-hardening/decisions.md)
7
+ > (in particular **D-014**: one instruction, one layer; countable rules) and
8
+ > in [`football-reporter-profile/decisions.md`](../football-reporter-profile/decisions.md)
9
+ > (in particular **D-001**: a space profile is a repo folder applied by
10
+ > `scripts/space-profile.ts`, so `feed-watch.sources` deploys through the
11
+ > manifest and a hand edit on the box is drift). Bare `D-0NN` ids below mean
12
+ > *this* goal's sequence.
13
+ >
14
+ > The goal was decomposed autonomously by `/f-feature-roadmap` on 2026-09-06
15
+ > and approved on 2026-09-07; D-001 to D-004 were recorded when Milestone 1's
16
+ > architecture was filled the same day. No conflict with either inherited
17
+ > goal's tech choices — this goal adds no storage, framework or dependency.
18
+
19
+ ## D-001: The parser seam is a closed `SourceKind` union in `feeds.ts`, dispatched by one exhaustive switch in `index.ts`
20
+ - **Category:** architecture
21
+ - **Decided:** `feeds.ts` exports `type SourceKind = "xml" | …` and stays a
22
+ leaf module; `FetchTarget.kind: SourceKind` is **required**; `fetchFeed`
23
+ gains optional `parse` (a body parser, default `parseFeed`) and `redirect`
24
+ (a `RequestRedirect`, default follow) options; `index.ts` implements
25
+ `PollDeps.fetchFeed` as `switch (target.kind)` with a `never` default, and
26
+ every case returns the **whole fetch** (`Promise<FeedItem[]>`).
27
+ - **Alternatives considered:** (a) an exported `parserFor` table keyed by
28
+ kind — a runtime registry the compiler cannot check, and a fourth mechanism
29
+ for a two-entry table; (b) `SourceKind` on `FetchTarget` in `watch.ts` —
30
+ an import cycle once `feeds.ts` needs it; (c) `kind?: SourceKind` with an
31
+ `?? "xml"` default — an optional discriminator whose absent case allows;
32
+ (d) a `parse` option only, no `kind` — closes the door on a non-HTTP kind
33
+ (the rung-1c mailbox bridge has no URL to fetch).
34
+ - **Reasoning:** the switch is the single place a parser is chosen, guaranteed
35
+ at compile time; a later kind is one union member and one case; the RSS
36
+ path is byte-identical because the `xml` case is today's call verbatim.
37
+ - **Revisit if:** a third HTTP kind wants the same `parse`/`redirect` pair
38
+ with a different transport (per-target user-agent, auth header) — then the
39
+ options bag grows, not the switch.
40
+ - **Used by:** feed-watch-parser-seam, feed-watch-telegram-parser,
41
+ feed-watch-telegram-source-config
42
+
43
+ ## D-002: Fixtures count toward the 500-line story budget
44
+ - **Category:** pattern
45
+ - **Decided:** a fixture is real markup cut to its head plus the 4–6 blocks
46
+ the tests need, with a leading comment naming the source, the capture date
47
+ and the cut; its line count is part of the story's diff budget.
48
+ - **Alternatives considered:** a verbatim page (~958 lines for `t.me/s/…`,
49
+ twice the budget on its own); a hand-written minimal fixture (agrees with
50
+ the parser by construction and proves nothing about the real page).
51
+ - **Reasoning:** the existing feed-watch fixtures are real bodies for the same
52
+ reason; a cut keeps the parser honest against real markup while keeping the
53
+ story reviewable in one session.
54
+ - **Revisit if:** a source's page cannot be cut without losing a shape the
55
+ parser must handle — then split the story rather than grow the fixture.
56
+ - **Used by:** feed-watch-telegram-parser, feed-watch-telegram-source-config
57
+
58
+ ## D-003: `MAX_SOURCES` stays at 20 for Milestone 1
59
+ - **Category:** scope — **superseded 2026-09-10 by D-006 and D-007**: the
60
+ cap is 150 and the rationing it implied is gone. Kept as written; the
61
+ revisit clause it names is exactly what fired.
62
+ - **Decided:** the football manifest holds 14 entries; M1.1 may add at most 3
63
+ and M1.5 at most 3. The cap constant, its validator and its description do
64
+ not move in this milestone.
65
+ - **Alternatives considered:** raising the constant now (a cap the owner has
66
+ not asked to raise, with the `gnews` fan-out warning behind it); a per-space
67
+ override (new config surface for a need nobody has yet).
68
+ - **Reasoning:** the budget fits; the owner ranks the rung-0 candidates into
69
+ 3 slots rather than the code absorbing an unranked list.
70
+ - **Revisit if:** the apartment profile or a second football channel list
71
+ needs more than 6 new entries — then a `config.ts` constant change with a
72
+ test, decided as its own story.
73
+ - **Used by:** feed-watch-rung0-manifest-sources,
74
+ feed-watch-telegram-football-deploy
75
+
76
+ ## D-004: A validator change deploys restart-first, apply-second
77
+ - **Category:** pattern
78
+ - **Decided:** when a story changes what `isValidSourceEntry` accepts, the
79
+ deploy order is sync → restart in a cron gap → verify the **installed copy**
80
+ under `~/whatsapp-bot/.mercury/extensions/feed-watch/` → `space-profile
81
+ apply` → `check`. One restart per milestone, carried by the last story.
82
+ - **Alternatives considered:** apply first and restart after (the manifest
83
+ is stored, the running validator rejects it, and `readSpaceConfig` blanks
84
+ the whole `sources` key on every poll until the restart — all 14 sources
85
+ dark); a config-key version guard (more machinery than the rule it
86
+ replaces).
87
+ - **Reasoning:** `space-profile apply` writes the key without the
88
+ extension's validator and `check` compares the exact string, so nothing on
89
+ the write path stops a shape the running code does not know. M1.4 asserts
90
+ the whole-key fallback with a test so the reason stays countable.
91
+ - **Revisit if:** the profile script starts running extension validators, or
92
+ the reader falls back per entry instead of per key.
93
+ - **Used by:** feed-watch-telegram-source-config,
94
+ feed-watch-telegram-football-deploy
95
+
96
+ ## D-006: `MAX_SOURCES` moves and the poll fetches concurrently, as one story
97
+ - **Category:** scope (revisits D-003; D-005 is reserved for the no-bypass
98
+ rule named under "Open" below, so this is D-006)
99
+ - **Decided:** 2026-09-10, by the owner: the football space is to watch
100
+ many sources, and `football-match-day-mode` D-010 dropped its fixtures
101
+ provider in favour of watched channels, so D-003's revisit clause fires.
102
+ One story, `feed-watch-source-cap-and-concurrent-poll` (M1.6 on the
103
+ roadmap; idea filed at
104
+ `docs/ideas/feed-watch-source-cap-and-concurrent-poll.md`; architecture
105
+ via `/f-feature-planning`), raises the constant and its description, makes
106
+ `pollSpace` fetch targets through a bounded pool instead of one after
107
+ another, and measures the tick against the poll interval at the new cap.
108
+ The number is decided in that story from the measurement, not here.
109
+ - **Alternatives considered:** rationing the six free slots between the
110
+ two goals (the 2026-09-07 shape); a per-space override of the cap (a
111
+ config surface for a bound that should simply be higher).
112
+ - **Reasoning:** the cap guards the tick, not the bill — sequential fetches
113
+ at `FETCH_TIMEOUT_MS = 5000` inside a 3-minute interval on the host's
114
+ single thread, and `gnews` fan-out per term. `verify_max_items`,
115
+ `digest_max_items`, `BUFFER_CAP` and `max_per_hour` bound everything that
116
+ costs tokens, so more sources cannot make a run dearer. A concurrent fetch
117
+ removes the reason the bound was low.
118
+ - **Revisit if:** the measured tick at the new cap still overruns the
119
+ interval — then the interval, not the cap, is the next knob.
120
+ - **Used by:** feed-watch-source-cap-and-concurrent-poll,
121
+ feed-watch-rung0-manifest-sources (its "stay within 20" clause relaxes
122
+ once M1.6 lands), football-match-day-mode M1.7
123
+
124
+ ## D-007: 150 sources, a pool of 8, and one Reddit fetch per tick
125
+ - **Category:** implementation (settles the numbers D-006 left to the story)
126
+ - **Decided:** 2026-09-10, in `feed-watch-source-cap-and-concurrent-poll`.
127
+ `MAX_SOURCES = 150`; `FETCH_POOL_SIZE = 8`, a module constant and not a
128
+ config key; `BUFFER_CAP = 1500`; hosts in `ONE_PER_TICK_HOSTS` (the three
129
+ Reddit spellings) get **one** fetch per tick, round-robin from
130
+ `host_cursor:<spaceId>`, the rest counted as `hostDeferred`.
131
+ - **Alternatives considered:** a pool of 6 (the idea file's first guess — the
132
+ ratio to the interval is what matters, and 8 makes the worst case a third
133
+ of it rather than half); making the pool a config key (a property of the
134
+ host process, not of a space, and no admin has the measurement to set it);
135
+ spacing same-host fetches 60 s apart *inside* the tick instead of rationing
136
+ across ticks (three Reddit feeds would put the tick's floor at 120 s, past
137
+ the slow-tick threshold, so every tick would report itself slow); giving
138
+ Reddit its own `SourceKind` (a rate limit is a property of the host, not of
139
+ the parser).
140
+ - **Reasoning:** the cap always guarded the tick, and the pool is what moves
141
+ the tick's worst case from `due x 5 s` to `ceil(due / 8) x 5 s`. 150 clears
142
+ the round's 85-entry manifest (§7.I of
143
+ `docs/notes/2026-09-10-feed-watch-round.md`) with room for the bench tier.
144
+ 1,500 buffered items is a *digest* bound: the daily article is built from
145
+ the buffer, so an overflow silently drops the oldest matched items of a
146
+ match day. Reddit's 60 s per-IP ceiling was measured on 2026-09-10 from the
147
+ box's own network, for both user-agents.
148
+ - **Revisit if:** the `feed-watch: slow poll tick` line appears on a healthy
149
+ box (then the interval or the pool, in that order, not the cap); or a
150
+ second space starts watching (see D-008).
151
+ - **Used by:** feed-watch-source-cap-and-concurrent-poll,
152
+ football-match-day-sources, feed-watch-rung0-manifest-sources
153
+
154
+ ## D-008: `inFlight` stays process-wide, and the tick's cost is now readable
155
+ - **Category:** scope
156
+ - **Decided:** 2026-09-10. The `inFlight` guard in `index.ts` keeps its
157
+ present meaning — **one tick at a time across every space** — and does not
158
+ become per space in this story. The reader that would justify changing it
159
+ ships instead: `feed-watch: slow poll tick` at `info` past half the
160
+ interval, and an unconditional `last_tick` store row carrying `lastTickMs`
161
+ and `lastTickAt`.
162
+ - **Alternatives considered:** a per-space `inFlight` map (one slow space
163
+ would stop costing the others their poll — but the fetch phase is now
164
+ concurrent *within* a space, so the sum of ticks is far smaller than it
165
+ was, and a per-space guard would let N spaces put N pools on the host's
166
+ single thread with nothing bounding the total).
167
+ - **Reasoning:** the football space is the only watcher on the box, so the
168
+ guard has never actually fired for the reason a per-space version would
169
+ fix. Splitting it now would trade a bound nobody has hit for an unbounded
170
+ total, and would do it without a measurement. The measurement is what this
171
+ story adds.
172
+ - **Revisit if:** a second space enables `feed-watch.enabled`, or the
173
+ `lastTickMs` row on the box climbs past half the interval.
174
+ - **Used by:** feed-watch-source-cap-and-concurrent-poll
175
+
176
+ ## D-009: liveness is `newestItemAt` plus a once-per-streak pair, and nothing else
177
+ - **Category:** implementation
178
+ - **Decided:** 2026-09-10, in `feed-watch-source-liveness` (M1.7).
179
+ `SourceStatus` gains `newestItemAt` — the running **maximum** of every
180
+ item's `publishedAt` a source has served — and `silentAt`. Past
181
+ `STALE_AFTER_MS = 7 days` with the fetch still succeeding, one
182
+ `feed-watch: source silent` at `warn`; a fresh item writes one
183
+ `feed-watch: source active again`, also at `warn`. Both fields are carried
184
+ through the failure branch untouched.
185
+ - **Alternatives considered:** recording only this tick's newest date (a feed
186
+ that rotates its window would read as freshly stale every time it dropped
187
+ its newest item); a boolean `silent` flag (the *time* the silence started is
188
+ the fact an operator wants, and a boolean cannot be read as a duration);
189
+ `info` for the recovery line (the pair it mirrors is both-at-warn, and two
190
+ levels for two ends of one streak makes `grep -c` return a number that reads
191
+ as unresolved incidents).
192
+ - **Reasoning:** `lastOkAt` is about the fetch, so a page serving a 2023 body
193
+ with HTTP 200 writes the same row as a live one — `t.me/s/ILRentsTLV` does
194
+ exactly this, and X's syndication widget did it for months. At 14 sources a
195
+ person notices; at the ~85 the round ships, the only symptom is an article
196
+ missing something nobody knew to expect.
197
+ - **Revisit if:** a low-volume source flaps silent/active often enough to be
198
+ noise — then a per-source threshold, which is config surface nobody needs
199
+ yet.
200
+ - **Used by:** feed-watch-source-liveness
201
+
202
+ ## D-010: a stale source is **not** treated as failed, for any kind
203
+ - **Category:** scope (answers the roadmap's "Source liveness" row, which
204
+ asked for stale-as-failed "for kinds known to serve stale caches")
205
+ - **Decided:** 2026-09-10. No backoff, no `nextAttemptAt`, no removal, no
206
+ exception for Telegram or for any other kind. Visibility only.
207
+ - **Alternatives considered:** treating a stale Telegram channel as failed
208
+ (the row's own wording); a per-kind flag on `SourceKind` saying whether its
209
+ transport can serve a stale 200.
210
+ - **Reasoning:** the roadmap row's premise does not survive contact with the
211
+ data. A *quiet* channel and a *dead* one are byte-identical from outside —
212
+ the same 200, the same well-formed body, the same old newest post — so
213
+ "known to serve stale caches" describes the transport, not the source, and
214
+ every Telegram channel would qualify including the ones that simply post
215
+ twice a month. Backing one off costs the story it eventually publishes, and
216
+ at the 96-minute backoff ceiling it would miss the return as well. The
217
+ failure the row is really about is *nobody noticing*, and a log line plus a
218
+ store field closes that without touching the poll.
219
+ - **Consequence:** the manual per-channel precondition in
220
+ `feed-watch-telegram-football-deploy` (M1.5 — "newest `<time datetime>`
221
+ inside the last 7 days", checked by hand before adding a channel) is
222
+ **redundant** the moment this deploys: the same week, checked every tick, by
223
+ the running code. The edit to that document is **not** made here —
224
+ `football-match-day-sources` owns it this round — and is owed to whoever
225
+ lands it.
226
+ - **Revisit if:** a watched kind appears whose transport can serve a stale
227
+ body *and* whose real publication rate is known to be daily, so that
228
+ silence is unambiguous.
229
+ - **Used by:** feed-watch-source-liveness, feed-watch-telegram-football-deploy,
230
+ football-match-day-sources
231
+
232
+ > **Numbering note (2026-09-10).** These three were written as D-007 to D-009
233
+ > by S2 of the 2026-09-10 feed-watch round while S1 claimed D-007 to D-010 in
234
+ > a parallel worktree; the coordinator renumbered them to D-011 to D-013 on
235
+ > landing, together with the citations in `feed-watch-roundup-lane`'s spec.
236
+
237
+ ## D-011: The digest is lane-addressed, and the default lane is the unnamed one
238
+
239
+ - **Category:** architecture
240
+ - **Decided:** `[feed-watch:digest]` keeps its exact behaviour and its exact
241
+ cursor key `last_digest_at:<spaceId>`. A second reader carries
242
+ `[feed-watch:digest:<lane>]` (`lane` matching `[a-z0-9-]{1,32}`) and reads
243
+ and advances `last_digest_at:<spaceId>:<lane>`, with its own pending mark
244
+ `digest_until:<spaceId>:<lane>`. The block of a named lane carries
245
+ `lane="…"`; the default block is byte-for-byte unchanged, so the article's
246
+ prompt does not move. A marker whose lane will not parse yields **no**
247
+ digest, no mark and no cursor movement — never a fallback to the default
248
+ lane — and is warned about once per hook.
249
+ - **Alternatives considered:** a second task on the plain marker (the reason
250
+ this story exists: `handleAfterContainer` advances the one cursor for any
251
+ run carrying the marker, so the 09:00 article would see only what arrived
252
+ since the last roundup — silently, reading as a thin news day); a per-task
253
+ cursor keyed on the task id (the id is a live row that does not exist until
254
+ the task is created, so the prompt could not name its own cursor, and a
255
+ re-created task would silently start from the beginning of the buffer); a
256
+ config key listing lanes (a second surface to keep in step with the prompts
257
+ that actually carry the markers).
258
+ - **Reasoning:** the marker is already the contract between a task prompt and
259
+ the extension, and it is the only thing both hooks can see. Extending it is
260
+ a suffix, not a mechanism. Making the unnamed lane the default is what keeps
261
+ the migration empty: nothing on the box has to be renamed.
262
+ - **Revisit if:** a lane ever needs to *read without consuming* (a preview
263
+ lane). Today every lane advances its own cursor, and that is stated as the
264
+ point rather than an accident.
265
+ - **Used by:** feed-watch-roundup-lane
266
+
267
+ ## D-012: Each lane owns its window file and its archive
268
+
269
+ - **Category:** architecture
270
+ - **Decided:** the default lane keeps `knowledge/feed-digest-window.md` and
271
+ `knowledge/feed-digest.md`; a named lane writes
272
+ `knowledge/feed-digest-window.<lane>.md` and
273
+ `knowledge/feed-digest.<lane>.md`. The trailer names the lane's own path.
274
+ - **Alternatives considered:** one shared pair of files with the lane written
275
+ inside each section. Rejected on two counts: the window file is *named to
276
+ the model in prose*, so it has to hold the window that model was handed
277
+ rather than whichever lane wrote last; and the archive is trimmed to
278
+ `DIGEST_FILE_WINDOWS = 30` sections, so six writers a day would cut the
279
+ article's readable history from thirty days to five without anything saying
280
+ so.
281
+ - **Reasoning:** the cost is one string; the failure it removes is silent.
282
+ - **Revisit if:** a space runs enough lanes for the workspace listing to
283
+ matter — then a `knowledge/feed-digest/<lane>.md` subdirectory, decided as
284
+ its own change.
285
+ - **Used by:** feed-watch-roundup-lane
286
+
287
+ ## D-013: The roundup releases what has not been reported, on five slots a day
288
+
289
+ - **Category:** scope
290
+ - **Decided:** the football space's roundup runs
291
+ `0 10,13,16,19,22 * * *` `Asia/Jerusalem` — five slots, all outside
292
+ `feed-watch.quiet_hours` (`00:30-07:30`) and none at 09:00, which is the
293
+ article's. Its window **overlaps** what the immediate verify runs already
294
+ posted, and that overlap is resolved by the editorial rule that already
295
+ exists (`AGENTS.md § מניעת כפילויות`: an item already in the notebook's
296
+ `## Current State` is not reported again unless a concrete detail changed),
297
+ not by a second cursor. The roundup is not subject to
298
+ `feed-watch.max_per_hour`, which governs verify runs only.
299
+ - **Alternatives considered:** a cursor that skips items a verify run
300
+ consumed (feed-watch does not record which items a run posted — only which
301
+ it was handed — so this would be a new bookkeeping surface for a rule the
302
+ standard already states); hourly slots (five was chosen against a `NO_UPDATE`
303
+ run's own cost, below); making the roundup obey `max_per_hour` (it is a
304
+ `tasks` row, not a batch, and a scheduled release nobody can predict is
305
+ worse than one that says nothing).
306
+ - **Reasoning:** cost is `runs/day × prefix × $2.50/M` and a run that answers
307
+ `NO_UPDATE` still pays its whole prefix — five slots is roughly
308
+ $0.75–1.75/day against the space's $16–20/day, and the slot count is the
309
+ lever if the 50-source manifest pushes the window up.
310
+ - **Revisit if:** the real group finds five too many or too few after the
311
+ link — the cron is a live row, so changing it needs no deploy.
312
+ - **Used by:** feed-watch-roundup-lane
313
+
314
+ ## Open — for the owner, raised by the 2026-09-06 blocked-sources research
315
+
316
+ Recorded in [blocked-sources-research.md](blocked-sources-research.md); none
317
+ is needed for Milestone 1 and each becomes a numbered decision when its rung
318
+ is decomposed:
319
+
320
+ - **which Facebook account** a rung-3 reader burns — the owner's own
321
+ (already a member of every group, checkpoint locks them out of the same
322
+ groups) or a second account that each group's admins must admit;
323
+ - **X: paid vendor or official API** — twitterapi.io at ~$2–3/month typical
324
+ (~$20 worst case) with a vendor whose own terms position is exposed, or the
325
+ official API at ~$50–90/month, clean;
326
+ - **accepting Realta as a single dependency** for the apartment adopter's
327
+ Yad2/Madlan/Facebook coverage, with Yad2's own alert mail as the fallback
328
+ leg;
329
+ - **no bot-protection bypass is built for any site whose terms forbid it**
330
+ (Yad2 §7, Madlan) — proposed as a standing rule; the approved plan builds
331
+ nothing that violates it, and it is recorded as D-005 the first time a
332
+ story would otherwise have to.
@@ -0,0 +1,217 @@
1
+ # feed-watch source adapters — social and messaging platforms as a ladder, not a feature
2
+
3
+ **Status**: Active — approved by the owner on 2026-09-07 after two critic rounds (decomposed by `/f-feature-roadmap` on 2026-09-06)
4
+ **Slug**: feed-watch-social-sources
5
+ **Created**: 2026-09-06
6
+ **Last updated**: 2026-09-07
7
+
8
+ ---
9
+
10
+ ## Goal Statement
11
+
12
+ > Autonomously decomposed from the idea `docs/ideas/feed-watch-social-sources.md` (deleted on approval, 2026-09-07; its content lives in the survey below). Approved by the owner on 2026-09-07 after two critic rounds.
13
+
14
+ > "Track and search social media and Telegram groups — useful for other
15
+ > profiles too, but a big feature to defer." — the owner, 2026-09-01, on the
16
+ > football space. And on 2026-09-06: "I also plan on using the social-sources
17
+ > ability for finding apartments in Tel Aviv."
18
+
19
+ `feed-watch` knows two source types, `rss` and `gnews`. For the football
20
+ space that misses where the news appears first — Israeli clubs and
21
+ journalists post on Instagram, Facebook and public Telegram channels before
22
+ any outlet writes it up. For an apartment hunter in Tel Aviv the gap is the
23
+ whole market: listings live on Facebook groups, WhatsApp groups, Telegram
24
+ channels and a handful of listing sites, none of which is an RSS feed.
25
+
26
+ The goal is **source adapters, one rung at a time**: each adapter is a new
27
+ `type` in `feed-watch.sources` that yields the same `{title, link,
28
+ publishedAt, source}` items the RSS adapter yields, so the seen-set, the term
29
+ match, the batching, the source status and the verify run are untouched and
30
+ every profile gets every rung for free. Milestone 1 opens the seam and climbs
31
+ the two cheapest rungs — plain-RSS sources that need only config, and public
32
+ Telegram channels through their server-rendered preview page — with the
33
+ football space as the first adopter. The apartment-hunting profile is the
34
+ **second adopter** and is what makes the later rungs (a page watcher with a
35
+ first-seen clock, a notification-email bridge for Facebook groups) worth
36
+ their place on the roadmap; it is not built in this milestone, but nothing
37
+ in this milestone may close a door it needs.
38
+
39
+ Two research inputs shaped the plan and are summarised under **Sources**
40
+ below: the owner's 2026-09-05 Gemini conversation on polling Facebook groups
41
+ (printed to PDF, kept outside the repo), and a 2026-09-06 web survey of the
42
+ Tel Aviv rental sources a host-side poller can actually read.
43
+
44
+ ## Constraints
45
+
46
+ - **No new dependencies.** `feeds.ts` is dependency-free by design; every
47
+ adapter parses with regex tag extraction over raw text, no cheerio/jsdom.
48
+ `feeds.ts` stays a leaf module (watch.ts and index.ts import it; it imports
49
+ neither).
50
+ - **The governing rule of `feeds.ts` holds for every adapter:** a malformed
51
+ body yields zero items and never throws; a transport failure (non-2xx,
52
+ including a manual-redirect 3xx, or a timeout) throws so `pollSpace`
53
+ backs the source off; an **oversize body is truncated, not thrown** — it
54
+ is cut to the head and reported once per key through the `bytes` warn,
55
+ because a throw would drop a feed that grew past the cap out of the
56
+ watchlist permanently (the comment in `feeds.ts` says so, and two football
57
+ feeds sit within 2× of the cap). A source `type` the validator does not
58
+ know is rejected, not ignored.
59
+ - **Read-only, public, no account.** Milestone 1 touches nothing that needs a
60
+ login, a cookie, a burner account, a proxy or a scraping API. Those belong
61
+ to the rung-3 goal with its own account, credential and stored-data policy.
62
+ - **Profile-owned keys deploy through the manifest.** `feed-watch.sources`
63
+ lands in `examples/profiles/football-reporter/config.yaml` and deploys with
64
+ `space-profile apply`; a hand edit on the box is drift. `feed-watch.terms`
65
+ and `feed-watch.enabled` are chat-seeded and are not touched.
66
+ - **Deploy order for a validator change is fixed:** sync → restart in a gap of
67
+ the active task crons → verify the installed copy → `space-profile apply` →
68
+ `space-profile check`. Applying a manifest the running validator rejects
69
+ makes `readSpaceConfig` fall back to `[]` for the whole key — all 14
70
+ existing sources go dark until the restart. One restart per milestone.
71
+ - **`MAX_SOURCES = 20` is a hard cap with 14 slots used.** The milestone fits
72
+ in the remaining 6 or explicitly decides to move the cap.
73
+ - **Every rule is countable by a test** (`whatsapp-bot-hardening` D-014).
74
+ Each story < 500 lines of change **including tests and fixtures** — a
75
+ verbatim Telegram preview page is ~958 lines, so the fixture is a real page
76
+ cut to its head plus 4–6 untouched message blocks.
77
+ - **Verification is against the running bot, never the diff.** The only
78
+ operator-visible status surfaces today are the extension store row
79
+ `source_status:<spaceId>` (keyed by `target.key`; fields `failures`,
80
+ `lastOkAt`, `nextAttemptAt`, `lastError`; table `extension_state`) and
81
+ the warn lines `feed-watch: source failed, backing off` /
82
+ `feed-watch: source recovered` / `feed-watch: source hit a cap, older
83
+ items dropped`. The `feed-watch: poll tick` line with its `failed` count
84
+ is **debug** and the live box runs `log_level: info`, so it is not a
85
+ live check. There is **no source-status widget or dashboard view**; no
86
+ story in this milestone adds one.
87
+ - **Extension deploys are a service restart plus the startup whole-tree sync
88
+ of `.mercury/extensions/feed-watch/`**, not an image rebuild. The
89
+ Dockerfile does `COPY examples/extensions/` into a temp path, but the
90
+ `RUN` after it installs only the names in
91
+ `resources/builtin-extensions.txt` (`yahoo-mail`, `gws`, `tradestation`)
92
+ — `feed-watch` is not among them, so nothing of it lives in the image.
93
+ Verified by grepping the installed copy under `~/whatsapp-bot`.
94
+
95
+ ## Preferences
96
+
97
+ - Regex-over-raw-text parsing in the house style of `feeds.ts` (required —
98
+ the header comment is explicit about not pulling a parser in).
99
+ - One transport-and-parser dispatch on `FetchTarget.kind` in `index.ts`, an
100
+ exhaustive `switch` with a `never` default (preferred — it is the single
101
+ place a parser is chosen today, and the closed union is the guarantee
102
+ actually available; the plan review rejected a runtime table).
103
+ - The dispatch chooses a **whole fetch**, not only a body parser, so a later
104
+ kind that is not an HTTP GET (the notification-email bridge reads an IMAP
105
+ folder, not a URL) plugs into the same switch (preferred).
106
+ - A per-channel `outlet` label on the Telegram source, so a journalist's own
107
+ channel and a repost channel can be told apart at the attribution rule
108
+ (preferred — the idea's own open question, adopted by the plan).
109
+ - Restart the live bot once per milestone, in a cron gap (required —
110
+ `CLAUDE.local.md`: a restart kills the running container and loses the
111
+ in-memory retry).
112
+
113
+ ## Target Users
114
+
115
+ - **The football-space owner and admins** (first adopter, Milestone 1): the
116
+ space is a one-member test bed until the real group is linked, so a noisy
117
+ new source costs ledger rows only while it is tuned. They name the Telegram
118
+ channels and the rung-0 feeds.
119
+ - **An apartment hunter in Tel Aviv** (second adopter, later milestones — the
120
+ owner, 2026-09-06): wants new rental posts from Telegram channels, listing
121
+ sites and (eventually) Facebook groups in the buffer within minutes, with
122
+ price, rooms and neighbourhood extracted from free Hebrew text by the verify
123
+ run. This is a new profile, not feed-watch code, except for the rungs it
124
+ needs: the page watcher (1b) with a first-seen clock, and the
125
+ notification-email bridge (1c).
126
+ - **Any other profile** that watches a topic: every rung is a `sources` entry,
127
+ so the value compounds across profiles (inferred).
128
+
129
+ ## Success Criteria
130
+
131
+ 1. A public Telegram channel can be watched by configuration alone —
132
+ `{"type":"telegram","channel":"<name>","outlet":"<label>"}` in
133
+ `feed-watch.sources` — and a matched post reaches the buffer and a verify
134
+ run with the channel's own publication time and the `https://t.me/<ch>/<id>`
135
+ permalink, never the poll time. Countable in tests over a real trimmed
136
+ fixture and observed once on the running bot.
137
+ 2. A dead or private channel backs off alone: a 302 from `t.me/s/<name>` is a
138
+ source failure for that key only, the other sources stay fresh and the
139
+ service stays up. Countable in a `pollSpace` test and drilled once live.
140
+ 3. The RSS/gnews path is byte-identical after the seam opens: every existing
141
+ fixture test passes unchanged, and every rss/gnews target carries
142
+ `kind: "xml"`.
143
+ 4. Rung 0 is measured, not assumed: Reddit's unauthenticated RSS and the
144
+ hard-coded `user-agent` are probed from the box and the verdict is written
145
+ in the manifest comment before any entry is committed.
146
+ 5. Nothing in Milestone 1 closes a door the apartment adopter needs: the
147
+ dispatch chooses a whole fetch per kind, `FetchTarget.url` is not
148
+ load-bearing outside HTTP kinds, and `seenAt` remains a first-class item
149
+ field (the page watcher's only clock).
150
+
151
+ ## Sources
152
+
153
+ ### The owner's Gemini conversation, 2026-09-05 ("Automated Facebook Group Polling")
154
+
155
+ Read from the PDF the owner printed on 2026-09-06 (kept outside the repo).
156
+ What it established, and what the plan took from it:
157
+
158
+ | Finding | Effect on this goal |
159
+ |---------|---------------------|
160
+ | Meta removed the Groups API (all versions, 2024-04-22); there is no official read path for group posts, public or private. Group RSS is long gone. | Facebook groups stay **rung 3**, a separate goal. Nothing in Milestone 1 pretends otherwise. |
161
+ | Unofficial reads are browser automation (Playwright with stealth patches such as `rebrowser-playwright`, a dedicated "burner" account, saved `storageState`, home IP, throttled to 15–30 min) or a managed scraper API (Apify Facebook Group Scraper — ~$5/month free credit, "a check every 30 minutes on 3–5 groups"; Scrape.do / ZenRows / ScraperAPI, 1,000–5,000 free requests/month). Free public proxy lists are near 0 % effective against Meta. `kevinzg/facebook-scraper` is unmaintained (last release 2022), cookie-walled and not recommended. | The rung-3 goal has two candidate designs to weigh — **managed API as a host-side capability** (delegates anti-bot, usage-billed, external dependency, clean JSON) versus **own-session Playwright in the container** (the image already ships Playwright + Chromium; the `pinchtab` extension drives it) — and a policy question it must answer first: which account, what is stored, and Meta's terms §3.2 (no automated collection without permission). Recorded here so the later goal does not re-derive it. *Corrected 2026-09-06:* `rebrowser-playwright` is abandoned (last commit 2024-09); the maintained 2026 options are Patchright and Camoufox; Apify's official groups actor refuses cookies and private groups, so managed APIs read public groups only; the maintained precedent for the own-session design captures Facebook's own feed query instead of parsing the DOM. |
162
+ | **"All Posts" group notifications → email → an IMAP parser** is a zero-risk read of groups the account is already a member of. Limits: new posts only, no history, and Facebook bundles high-volume groups into digests or skips mails. | A new rung the idea did not have, **1c — notification-email bridge**: a `sources` entry that reads a mailbox folder and yields one item per notification (post link, text, timestamp). Mercury already has host-side IMAP (`examples/extensions/yahoo-mail/lib/ymail.ts`, `imapflow`) and Gmail API scope (`gws`). This is the apartment adopter's cheapest Facebook path and the reason Milestone 1's dispatch must be able to choose a non-HTTP transport. Not built in M1. |
163
+ | Historical search is a different problem from live tracking: `facebook.com/groups/<id>/search/?q=…` under a browser, or an Apify historical run, backfills; notifications catch new posts. | Confirms the idea's split: **"track"** is feed-watch's job; **"search on demand"** (a member asks the bot to search a platform) is a separate tool, out of this goal. |
164
+ | Unstructured post text: price, rooms and neighbourhood are extracted by regex or an LLM; dedupe on the post id; notify with a direct link. | Maps one-to-one onto feed-watch: substring terms select, the verify run extracts and judges, the seen-set dedupes on the permalink. No new matching engine. |
165
+
166
+ ### Tel Aviv rental sources a host-side poller can read (web survey, 2026-09-06)
167
+
168
+ | Source | Verdict | Rung |
169
+ |--------|---------|------|
170
+ | **Yad2** | Unreachable *directly*: Radware "Verifying your browser" JS challenge even from a residential IP with a Chrome UA (plain fetch; commercial vendors report real browsers on Israeli residential IPs pass at 96–100 %); the feed gateway 302s to a ShieldSquare validator; sitemaps list only search pages; no RSS. Terms §7 forbids "תוכנות אוטומטיות (ובכלל זאת רובוטים, spiders, scrapers)" verbatim, and Yad2 has enforced against commercial republishers (2009, and Keyz in June 2026). Sanctioned signals: **Yad2's own saved-search e-mail alerts, "fast" within 15 minutes** (app listing, 2026-09-06), and **Realta's aggregator RSS** below. | Direct reads **out of scope**; listings via **0** (Realta) and **1c** (alert mail). See [blocked-sources-research.md](blocked-sources-research.md). |
171
+ | **Realta** (`realta.co.il/feed.xml`) | Listing-level RSS, 50 items ≈ two hours of national volume, `pubDate` minutes old, permalink `guid`, title with rooms / neighbourhood / price; sources per row on the Tel Aviv page: Madlan 7, Yad2 6, Facebook 6, Komo 4, Homeless 1; original URL recoverable from the `go.realta.co.il` redirect's query string; `robots.txt` invites crawling. Single small operator — the same exposure Yad2 just pressed on Keyz. | **0**, zero code, the apartment adopter's first source. Measure the window on a weekday before trusting it. |
172
+ | **Facebook groups** (e.g. `telavivapartments`, 53 K members) | Non-browser clients get a 400 error page, not a login wall; API removed; terms §3.2.3 (2025-01-01) forbid automated collection *even while logged in*. "All posts" notifications are algorithm-filtered, not exhaustive (vendor claim, unverified). Complete reads exist only through an account's own session: Playwright reading Facebook's feed JSON (F1), or the Groups Watcher extension posting every new post to a webhook (F2) — see the research note. Realta already carries Facebook-sourced apartment listings. | 3 for a complete feed; 1c and Realta meanwhile. |
173
+ | **Telegram channels** | `t.me/s/nester_rent_telaviv` serves message blocks (title "תל אביב דירות להשכרה ללא תיווך", ISO `<time datetime>`, `data-post`, price and rooms in the text, outbound link; Nester's own no-broker stock, ~4–5 posts/hour, appears nowhere else). `t.me/s/ILRentsTLV` also serves blocks **but its last post is 2023-04-12** — directory-listed yet dead (re-confirmed 2026-09-06 19:00 IDT). `t.me/s/realta_rent_il` is live and duplicates Realta's RSS. `t.me/s/plotlv` (a *group*) returns the landing page: `/s/` is channel-only. Scoutr, Shushu and Jeremy deliver via *bots*, which have no public preview. | **1** — and a live channel is a per-channel fact; see the liveness item under future milestones. `nester_rent_telaviv` is a candidate fixture with real RTL Hebrew. |
174
+ | **homeless.co.il** | Server-rendered; 200 from the WSL box with any UA (403 from Anthropic egress); 53 `viewad,<id>.aspx` links per city page with a per-row `dd/mm/yyyy` date. No RSS. | **1b** — has a per-item date. |
175
+ | **komo.co.il** | Server-rendered, no block; 999 items with price/rooms/m²/floor; permalink `?modaaNum=<id>`; **no per-item date**. | **1b** — first-seen is the only clock. |
176
+ | **madlan.co.il** | Cloudflare 403 on HTML regardless of UA; sitemaps serve but are day-granular and over 10 MB; no reachable terms page; Apify reads its GraphQL API. Madlan is Roztel Knowledge Systems, not Yad2. | None directly; its listings are the largest share of Realta's Tel Aviv rows. |
177
+ | **onmap.co.il** | JS-rendered ("0 apartments" in the HTML). **WinWin** closed 2020. | None. |
178
+ | **Google News RSS** | Works as RSS but carries no listings — `site:yad2.co.il` returns landing pages, Hebrew queries return newspaper articles. | 0, useless for listings; the football success does not generalise. |
179
+ | **Google Alerts RSS** | "Deliver to RSS feed" still exists (2026-06-17), but Yad2 listing pages have no crawl path and alerts fire only after indexing: days to never. | **Dropped** — Realta's feed does what the spike hoped for. |
180
+ | **WhatsApp groups** | The bot *is* a WhatsApp client — a group the owner's number joins arrives as ordinary inbound messages. Scoutr (2026-04): WhatsApp is "the primary market", a good listing lasts 10 min–1 h. | **In-band, not a feed-watch source.** If wanted, matching must accept inbound messages, not poll. |
181
+ | **Reddit r/telaviv** | `/new/.rss` 200 from a residential IP with a custom UA; 403 from datacenter IPs is widely reported. Low volume. | 0, fragile — the same UA/rate question as r/soccer. |
182
+ | **Instagram** | Login wall on hashtag pages; profile HTML carries no post data; the 2023-era `web_profile_info` endpoint answers 401 from the box. Official Business Discovery reads Professional accounts only. | 3, lowest value of the three. |
183
+ | **X** | Nitter and xcancel received X Corp's cease-and-desist on 2026-08-24 (an xcancel host answered the box on 09-06 with a whitelist-by-e-mail placeholder — borrowed time); the unauthenticated syndication widget serves a live 20-entry timeline for big clubs and a **months-stale engagement cache with HTTP 200** for journalists and Israeli outlets (Ornstein 2025-10, sport5il 2025-10, Maccabi 2025-11); the official API is pay-per-use only since 2026-02 (~$50–90/month for 20 accounts hourly, reads deduplicated per 24 h); twitterapi.io ~$5/month. Romano is on none of the free surfaces; one hobbyist Bluesky mirror of him is live. | **2, paid vendor**; Bluesky RSS at 0 for the handles that exist. |
184
+
185
+ Not verified by the survey: homeless's date-column semantics (update vs.
186
+ entry date), Telegram's terms on reading previews, the body format of a
187
+ Yad2 fast-alert mail, how many minutes Realta's 50-item window covers on a
188
+ weekday, whether Realta's "Facebook" rows are group posts or Marketplace.
189
+
190
+ ### Blocked platforms, researched 2026-09-06 at the owner's request
191
+
192
+ The owner asked for every way past the walls on Facebook, Instagram, X,
193
+ Yad2 and Madlan, Facebook first, because the notification-email bridge
194
+ "won't be good enough". The answer — per platform, with probes from the
195
+ box, costs, completeness, account risk and terms — is in
196
+ [blocked-sources-research.md](blocked-sources-research.md). The ladder
197
+ below already reflects it: Bluesky and Realta move to rung 0, X becomes a
198
+ paid-vendor rung 2, Facebook's rung-3 goal gets a primary design that
199
+ uses the owner's own session in the image's Chromium, Yad2 and Madlan
200
+ listings arrive through Realta and Yad2's own 15-minute alerts, and no
201
+ bot-protection bypass is built anywhere. One cross-cutting rule came out
202
+ of it: **a source is live when its newest item is recent, not when its
203
+ fetch returned 200** — X's stale cache and Telegram's dead channel fail
204
+ the same way.
205
+
206
+ ## The ladder, revised
207
+
208
+ | Rung | What | Cost | Status |
209
+ |------|------|------|--------|
210
+ | 0 | Reddit `/new/.rss`, YouTube channel feeds, **Bluesky per-account RSS** (`bsky.app/profile/<handle>/rss`, verified live for The Athletic, Ornstein, Arsenal), and for the apartment adopter **Realta's listing feed** (`realta.co.il/feed.xml` — Yad2, Madlan, Homeless, Komo and Facebook-group listings as one RSS, verified 2026-09-06) — already RSS; config plus a probe of UA and rate | config | **M1.1** (football); Realta waits for the apartment profile |
211
+ | 1 | Public Telegram channels via `t.me/s/<channel>` — server-rendered, no account, no dependency | one adapter + fixture | **M1.2–M1.5** |
212
+ | 1b | Generic page watcher — poll a server-rendered page, diff its link list against the seen-set; `seenAt` is the clock when the page has no per-item date (homeless.co.il has one, komo.co.il has none) | one adapter | future milestone, demoted: both sites are inside Realta's feed |
213
+ | 1c | Notification-email bridge — read a mailbox folder over IMAP (or Gmail API) and yield one item per Facebook "All Posts" notification and per **Yad2 fast alert** (Yad2 states e-mail alerts within 15 minutes for a saved search — a sanctioned, complete Yad2 read); member's own groups, new posts only, digest bundling as a known gap on the Facebook side | one adapter on existing host-side mail code, two mail parsers | future milestone |
214
+ | 2 | **X through a paid vendor** (twitterapi.io at $0.15 per 1,000 tweets — ~$2–3/month typical, ~$20 worst case; or the official pay-per-use API at ~$50–90/month for a clean-terms path), keyed by a host-side secret. Free paths cover **club handles only**: the unauthenticated syndication widget's live 20-entry mode, verified per handle behind the liveness check. Journalists and Israeli outlets get a months-stale cache with HTTP 200, Nitter is under a cease-and-desist (2026-08-24), and own-session automation burns an account. Bluesky moved to rung 0 | one API adapter, plus the syndication sub-item | future milestone, after the owner picks vendor and budget |
215
+ | 3 | Facebook groups (and Instagram, if ever) — the [blocked-sources research](blocked-sources-research.md) gives the rung-3 goal a primary design: the owner's **own session in Playwright reading Facebook's feed JSON** in the image that already ships Chromium (F1), with the Groups Watcher extension → webhook (F2) as the zero-code experiment to run first, and 1c as the compliant floor. The tooling is in the *agent* image, but feed-watch polls host-side and that container is per-run, while F1 needs a persistent logged-in profile — a long-lived container with a mounted profile, or Chromium on the host, is part of the goal's cost. Which account is put at risk is the owner's decision, not the plan's | large | **separate goal**, not this one |
216
+ | — | WhatsApp groups | none | in-band; not a source |
217
+ | — | Yad2, Madlan *direct* | — | out of scope: a Radware bypass breaches Yad2's terms verbatim and is unnecessary — their listings arrive through rung 0 (Realta) and rung 1c (Yad2's own alerts) |