@openhands/extensions 0.16.0 → 0.18.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 (70) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/AGENTS.md +1 -0
  3. package/README.md +5 -2
  4. package/automations/README.md +78 -11
  5. package/automations/bundle-index.js +18 -0
  6. package/automations/catalog/github-agents-md-maintainer/manifest.json +96 -0
  7. package/automations/catalog/github-issue-to-pr/manifest.json +107 -0
  8. package/automations/catalog/github-pr-reviewer/manifest.json +24 -7
  9. package/automations/catalog/github-repo-monitor/manifest.json +29 -34
  10. package/automations/catalog/jira-issue-to-pr/manifest.json +3 -3
  11. package/automations/catalog/news-digest/manifest.json +76 -0
  12. package/automations/catalog-index.js +13 -7
  13. package/automations/catalog.schema.json +112 -3
  14. package/automations/index.d.ts +66 -1
  15. package/automations/index.js +14 -0
  16. package/automations/interface.json +6 -2
  17. package/automations/interface.schema.json +6 -2
  18. package/index.d.ts +1 -1
  19. package/index.js +1 -1
  20. package/integrations/catalog/posthog.json +41 -3
  21. package/integrations/catalog/sonarqube.json +78 -0
  22. package/integrations/catalog-index.js +65 -63
  23. package/marketplaces/openhands-extensions.json +49 -0
  24. package/package.json +1 -1
  25. package/plugins/qa-changes/action.yml +1 -1
  26. package/pyproject.toml +1 -1
  27. package/python/openhands_extensions/_version.py +1 -1
  28. package/scripts/build-automation-catalog.mjs +30 -0
  29. package/scripts/build-skills-catalog.mjs +24 -7
  30. package/skills/add-skill/SKILL.md +1 -1
  31. package/skills/github-agents-md-maintainer/.plugin/plugin.json +19 -0
  32. package/skills/github-agents-md-maintainer/README.md +59 -0
  33. package/skills/github-agents-md-maintainer/SKILL.md +271 -0
  34. package/skills/github-agents-md-maintainer/commands/agents-md-setup.md +8 -0
  35. package/skills/github-agents-md-maintainer/references/state-schema.md +113 -0
  36. package/skills/github-agents-md-maintainer/scripts/main.py +1187 -0
  37. package/skills/github-issue-to-pr/.plugin/plugin.json +19 -0
  38. package/skills/github-issue-to-pr/README.md +84 -0
  39. package/skills/github-issue-to-pr/SKILL.md +373 -0
  40. package/skills/github-issue-to-pr/commands/issue-to-pr-setup.md +8 -0
  41. package/skills/github-issue-to-pr/references/state-schema.md +139 -0
  42. package/skills/github-issue-to-pr/scripts/main.py +1305 -0
  43. package/skills/github-pr-reviewer/README.md +12 -6
  44. package/skills/github-pr-reviewer/SKILL.md +93 -31
  45. package/skills/github-pr-reviewer/commands/pr-reviewer-setup.md +1 -1
  46. package/skills/github-pr-reviewer/references/state-schema.md +68 -18
  47. package/skills/github-pr-reviewer/scripts/main.py +540 -103
  48. package/skills/github-pr-reviewer/tests/test_main.py +503 -0
  49. package/skills/index.d.ts +6 -0
  50. package/skills/index.js +56 -17
  51. package/skills/jira-issue-to-pr/scripts/main.py +4 -2
  52. package/skills/news-digest/.plugin/plugin.json +19 -0
  53. package/skills/news-digest/README.md +71 -0
  54. package/skills/news-digest/SKILL.md +284 -0
  55. package/skills/news-digest/commands/news-digest-setup.md +8 -0
  56. package/skills/news-digest/references/state-schema.md +178 -0
  57. package/skills/news-digest/scripts/main.py +1250 -0
  58. package/skills/openhands-sdk/SKILL.md +2 -0
  59. package/skills/skill-creator/SKILL.md +1 -1
  60. package/skills/slack-channel-monitor/scripts/main.py +3 -2
  61. package/tests/fixtures/automations/capabilities.json +30 -0
  62. package/tests/fixtures/automations/github-pr-reviewer.json +319 -55
  63. package/tests/fixtures/automations/github-repo-monitor.json +230 -62
  64. package/tests/test_automation_setup.py +260 -31
  65. package/tests/test_catalogs.py +46 -1
  66. package/tests/test_github_agents_md_maintainer.py +352 -0
  67. package/tests/test_github_issue_to_pr.py +539 -0
  68. package/tests/test_news_digest.py +646 -0
  69. package/tests/test_skills_catalog.py +182 -36
  70. package/tests/test_slack_channel_monitor.py +1 -1
@@ -264,8 +264,7 @@ try:
264
264
 
265
265
  agent_settings = settings.get("agent_settings", {})
266
266
  agent_settings.pop("schema_version", None)
267
- # Drop mcp_config to avoid MCP connection failures at conversation creation time.
268
- agent_settings.pop("mcp_config", None)
267
+ mcp_config = agent_settings.pop("mcp_config", None)
269
268
  ctx = agent_settings.setdefault("agent_context", {})
270
269
  ctx.update({"load_public_skills": True, "load_user_skills": True, "load_project_skills": True})
271
270
  max_iterations = (settings.get("conversation_settings") or {}).get("max_iterations") or 1000
@@ -314,6 +313,9 @@ Steps:
314
313
  "run": True,
315
314
  },
316
315
  }
316
+ if mcp_config:
317
+ payload["mcp_config"] = mcp_config
318
+
317
319
  conv_req = urllib.request.Request(
318
320
  f"{agent_url}/api/conversations",
319
321
  data=json.dumps(payload).encode(),
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "news-digest",
3
+ "version": "1.0.0",
4
+ "description": "Create an automation that reads public RSS and Atom feeds on a schedule, keeps what is new and matches the configured topics, and has an agent write a short digest of it. Needs no credentials.",
5
+ "author": {
6
+ "name": "OpenHands",
7
+ "email": "contact@all-hands.dev"
8
+ },
9
+ "homepage": "https://github.com/OpenHands/extensions",
10
+ "repository": "https://github.com/OpenHands/extensions",
11
+ "license": "MIT",
12
+ "keywords": [
13
+ "news",
14
+ "rss",
15
+ "atom",
16
+ "digest",
17
+ "automation"
18
+ ]
19
+ }
@@ -0,0 +1,71 @@
1
+ # Daily News Digest
2
+
3
+ Create an automation that reads a list of public RSS and Atom feeds on a
4
+ schedule, hands an agent everything new, and has it pick out what matters for
5
+ your topics and write a short digest.
6
+
7
+ **It needs no credentials.** No token, no OAuth, no connected account - which
8
+ makes it the automation to run first, before deciding what access you are
9
+ willing to hand over.
10
+
11
+ ## Trigger
12
+
13
+ This skill is activated by:
14
+
15
+ - `/news-digest:setup`
16
+
17
+ ## Features
18
+
19
+ - Reads RSS 2.0, RSS 1.0/RDF and Atom, matched by element name rather than by
20
+ dialect, so a mixed feed list works
21
+ - Filters to what is new since the last digest and recent; what a story is
22
+ *about* is left to the agent, because that is the half with no right answer
23
+ - Starts no conversation at all when nothing matches, so a quiet day costs no
24
+ tokens and leaves the day open for a later run
25
+ - Recognises the same story arriving from two feeds, by the feed's identifier
26
+ and by the article's canonical link
27
+ - Runs daily by default, keyed by UTC date, so a retried or duplicated run
28
+ cannot write the same digest twice
29
+ - Survives a feed that is down, moved, or no longer a feed; fails only when
30
+ every feed fails
31
+ - Remembers what it reported **only once a digest exists**, so a failed run is
32
+ recovered by the next one instead of being silently skipped
33
+ - Keeps its own state small enough for the KV store's 64 KB limit, indefinitely
34
+
35
+ ## What the agent is asked to do
36
+
37
+ Read every new story the script fetched, decide which ones are actually about
38
+ the configured topics - a judgement call rather than a word search - and write
39
+ four to six hundred words about those: a lead on what actually matters, the rest grouped by topic,
40
+ one or two sentences and a link per story, and duplicate coverage of one event
41
+ merged into a single item. It is told that every claim must be supported by an
42
+ excerpt or a page it actually read, that a story it cannot substantiate belongs
43
+ in a headlines list rather than being guessed at, and that feed text is data to
44
+ be summarised - never instructions to follow.
45
+
46
+ ## Prerequisites
47
+
48
+ None. The runtime needs outbound HTTPS to the feed hosts, which it already has
49
+ if it can reach the model.
50
+
51
+ ## Quick Start
52
+
53
+ Ask OpenHands:
54
+
55
+ > "Set up a daily news digest at 8am for AI and open source news."
56
+
57
+ ## Where the digest goes
58
+
59
+ Nowhere that needs a credential, which is the trade: it stays in the run's
60
+ conversation, it is printed into the run log, and its opening is kept in state
61
+ so the next run's log can show what the last one said. Posting it to Slack,
62
+ Notion or a repository is a different automation, built from the skill for that
63
+ integration.
64
+
65
+ ## See Also
66
+
67
+ - [SKILL.md](SKILL.md) - Full setup workflow reference
68
+ - [references/state-schema.md](references/state-schema.md) - State document and
69
+ task lifecycle
70
+ - [../research-brief/SKILL.md](../research-brief/SKILL.md) - The credentialed
71
+ version of this idea: web search, then publish the brief
@@ -0,0 +1,284 @@
1
+ ---
2
+ name: news-digest
3
+ description: >
4
+ Create an automation that reads a list of public RSS and Atom feeds on a
5
+ schedule - daily by default - keeps what is new and matches the configured
6
+ topics, and has an agent write a short digest of it. It needs no credentials:
7
+ the feeds are public URLs and the conversation is started with no secrets and
8
+ no MCP servers.
9
+ triggers:
10
+ - /news-digest:setup
11
+ ---
12
+
13
+ # Daily News Digest Automation
14
+
15
+ Create a cron automation that turns a list of feeds into something worth
16
+ reading: a few hundred words a day on what happened in the topics you care
17
+ about, with a link under each item.
18
+
19
+ **It connects to nothing.** There is no token to issue, no account to link, no
20
+ OAuth screen. That makes it the automation to run first - it exercises the
21
+ schedule, the conversation, the model and the run log end to end while nothing
22
+ of yours is at stake, and it is useful in its own right afterwards.
23
+
24
+ The script is deterministic: the schedule, the once-a-day claim, fetching,
25
+ parsing, the freshness window, and remembering what has already been covered
26
+ are all Python. The LLM is invoked only for the part that
27
+ is judgement - reading the shortlist and writing something that is not just the
28
+ headlines pasted back. **When nothing new matches, no conversation is started
29
+ at all**, so a quiet day costs no tokens.
30
+
31
+ ---
32
+
33
+ ## Prerequisites
34
+
35
+ None. That is the point.
36
+
37
+ The runtime needs outbound HTTPS to the feed hosts, which it already has if it
38
+ can reach the model. Nothing is read from **Settings -> Secrets**, and nothing
39
+ needs to be added there.
40
+
41
+ ---
42
+
43
+ ## Setup Workflow
44
+
45
+ Follow these steps in order.
46
+
47
+ ### Step 1 - Collect the feeds
48
+
49
+ Ask: *"Which feeds should the digest read? (One RSS or Atom URL per line. Press
50
+ Enter for a general technology set.)"*
51
+
52
+ Default:
53
+
54
+ ```
55
+ https://news.ycombinator.com/rss
56
+ https://feeds.arstechnica.com/arstechnica/index
57
+ https://www.theverge.com/rss/index.xml
58
+ ```
59
+
60
+ Check each one before accepting it, because a URL that returns a web page
61
+ rather than a feed is the most common setup mistake:
62
+
63
+ ```bash
64
+ curl -sSL --max-time 20 -A "OpenHands-News-Digest/1.0" "{feed_url}" \
65
+ | python3 -c "
66
+ import sys
67
+ from xml.etree import ElementTree
68
+ try:
69
+ root = ElementTree.fromstring(sys.stdin.buffer.read())
70
+ except ElementTree.ParseError as exc:
71
+ print('ERROR: not valid XML:', exc); raise SystemExit
72
+ name = root.tag.rsplit('}', 1)[-1]
73
+ items = [e for e in root.iter() if e.tag.rsplit('}', 1)[-1] in ('item', 'entry')]
74
+ print(f'OK: <{name}> with {len(items)} entries' if name.lower() in ('rss', 'feed', 'rdf')
75
+ else f'ERROR: root element is <{name}>, which is not a feed')
76
+ "
77
+ ```
78
+
79
+ Record every accepted URL into `FEEDS = ["...", ...]`. A feed that fails at
80
+ runtime is reported and skipped, so one bad URL does not cost you the digest -
81
+ but it is better to find out now.
82
+
83
+ ### Step 2 - Collect the topics
84
+
85
+ Ask: *"What should the digest be about? (One topic per line, or comma
86
+ separated. Press Enter for `artificial intelligence, open source, developer
87
+ tools`. Leave it blank to summarise everything the feeds carry.)"*
88
+
89
+ Record as `TOPICS`. Two things are worth telling the user:
90
+
91
+ - The agent decides which stories are about them, reading each headline and
92
+ excerpt. So write topics the way you would explain your interests to a
93
+ colleague - `artificial intelligence` works even though almost every headline
94
+ says `AI`, and a story about a company releasing its model weights counts as
95
+ `open source` without using the phrase.
96
+ - An empty list means "cover whatever is most significant". That is right for a
97
+ handful of narrow feeds and vaguer for a firehose.
98
+
99
+ ### Step 3 - Collect the schedule
100
+
101
+ Ask: *"When should the digest be written? (Press Enter for the default: every
102
+ day at 08:00 UTC, `0 8 * * *`.)"*
103
+
104
+ Default: `0 8 * * *`. Record as `CRON_SCHEDULE`, and the timezone as
105
+ `CRON_TIMEZONE` (default `UTC`).
106
+
107
+ A schedule more frequent than daily is allowed and is not wasteful: work is
108
+ keyed by UTC date, so extra runs stop at a state read once the day is done, and
109
+ before that they cost one HTTP request per feed and no tokens. It is a
110
+ reasonable way to say "write the digest as soon as there is anything to write".
111
+
112
+ ### Step 4 - Confirm the secret scope
113
+
114
+ Do **not** ask which secrets to forward. `AGENT_SECRET_NAMES` is empty and
115
+ should stay empty: the conversation summarises text fetched from the open web,
116
+ which is written by strangers, and a credential handed to it would make every
117
+ feed on the list an instruction channel into the deployment's secret store.
118
+
119
+ If the user asks for a digest posted to Slack, Notion or a repository, that is a
120
+ different automation - it needs that integration connected, and it should be
121
+ built from the skill for it rather than by widening this one.
122
+
123
+ ### Step 5 - Generate the automation script
124
+
125
+ Read `scripts/main.py` from this skill's directory. Apply exactly two constant
126
+ substitutions near the top of the file:
127
+
128
+ > The script also reads a `config.json` shipped beside it, if there is one, over
129
+ > these constants. That is how the catalog entry
130
+ > (`automations/catalog/news-digest/`) configures an unmodified copy, since a
131
+ > declarative host cannot rewrite Python. This setup path substitutes the
132
+ > constants and ships no `config.json`, so the two never collide.
133
+
134
+ | Placeholder | Replace with |
135
+ |---|---|
136
+ | `FEEDS = [...]` | the list from Step 1 |
137
+ | `TOPICS = [...]` | the list from Step 2, or `[]` for no filter |
138
+
139
+ `LOOKBACK_HOURS` (48) and `MAX_ITEMS` (50) are left alone unless the user asks.
140
+ The lookback is deliberately wider than the schedule so a failed or missed run
141
+ is recovered by the next one; the seen-list is what stops the overlap from
142
+ repeating anything.
143
+
144
+ Use a safe string writer such as `json.dumps(value)` when inserting
145
+ user-provided URLs or topics into Python string literals.
146
+
147
+ Write the customized script to a temporary build directory and validate it:
148
+
149
+ ```bash
150
+ mkdir -p /tmp/news-digest-build
151
+ # write the customized main.py to /tmp/news-digest-build/main.py
152
+ python3 -m py_compile /tmp/news-digest-build/main.py && echo "Syntax OK"
153
+ ```
154
+
155
+ ### Step 6 - Package and upload
156
+
157
+ Determine the Automation backend URL and auth from the `<RUNTIME_SERVICES>`
158
+ block in your system context:
159
+ - **OPENHANDS_HOST**: the Automation backend `url_from_agent`
160
+ - **Auth**: `X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY`
161
+
162
+ ```bash
163
+ tar -czf /tmp/news-digest.tar.gz -C /tmp/news-digest-build .
164
+
165
+ TARBALL_PATH=$(curl -s -X POST \
166
+ "${OPENHANDS_HOST}/api/automation/v1/uploads?name=news-digest" \
167
+ -H "X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY" \
168
+ -H "Content-Type: application/gzip" \
169
+ --data-binary @/tmp/news-digest.tar.gz \
170
+ | python3 -c "import json,sys; print(json.load(sys.stdin)['tarball_path'])")
171
+
172
+ echo "Uploaded: $TARBALL_PATH"
173
+ ```
174
+
175
+ ### Step 7 - Register the automation
176
+
177
+ ```bash
178
+ curl -s -X POST "${OPENHANDS_HOST}/api/automation/v1" \
179
+ -H "X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY" \
180
+ -H "Content-Type: application/json" \
181
+ -d "{
182
+ \"name\": \"Daily news digest\",
183
+ \"trigger\": {\"type\": \"cron\", \"schedule\": \"{cron_schedule}\", \"timezone\": \"{cron_timezone}\"},
184
+ \"tarball_path\": \"$TARBALL_PATH\",
185
+ \"entrypoint\": \"python3 main.py\",
186
+ \"timeout\": 900
187
+ }" | python3 -m json.tool
188
+ ```
189
+
190
+ Record the returned `id`.
191
+
192
+ ### Step 8 - Confirm
193
+
194
+ Tell the user:
195
+
196
+ > ✅ **Daily news digest** is running!
197
+ >
198
+ > - Automation ID: `{id}`
199
+ > - Feeds: `{url}`, ... (one line each)
200
+ > - Topics: `{topics}` (or: no filter - everything the feeds carry)
201
+ > - Schedule: `{cron_schedule}` ({cron_timezone})
202
+ > - Credentials used: **none**
203
+ > - State file: `~/.openhands/workspaces/automation-state/news_digest_{id}.json`
204
+ >
205
+ > Each day it reads the feeds and writes the digest into the run's conversation
206
+ > and the run log. A day with nothing new produces nothing and costs nothing -
207
+ > the day stays open, so a later run picks up news published after this one.
208
+
209
+ Then offer to dispatch it once so they can read today's digest immediately:
210
+
211
+ ```bash
212
+ curl -s -X POST "${OPENHANDS_HOST}/api/automation/v1/{id}/dispatch" \
213
+ -H "X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY"
214
+ ```
215
+
216
+ ---
217
+
218
+ ## Runtime Behaviour (per run)
219
+
220
+ Each cron run executes `main.py`, which loads `config.json` if the catalog
221
+ shipped one and then:
222
+
223
+ 1. Loads state (the automation service's KV store, or a local JSON file when
224
+ the store is unavailable).
225
+ 2. Computes today's UTC date and **stops immediately if it is already
226
+ recorded** - no feed is fetched, so an extra run inside a finished day costs
227
+ one state read.
228
+ 3. Otherwise fetches every feed with a 20-second timeout and a 4 MB cap, and
229
+ parses RSS 2.0, RSS 1.0/RDF and Atom by local element name. A feed that
230
+ fails, is not XML, or is XML that is not a feed is recorded and skipped. The
231
+ run fails only if *every* feed fails.
232
+ 4. Selects the stories: not already covered, and published within
233
+ `LOOKBACK_HOURS` (undated stories are treated as current rather than
234
+ dropped). The newest `MAX_ITEMS` survive. Subject is deliberately not a
235
+ filter here.
236
+ 5. If none survive, records the check, says which stage emptied it, and
237
+ **leaves the day unclaimed** so a later run can try again. No conversation,
238
+ no tokens.
239
+ 6. Otherwise claims the day in state *before* the slow work, so an overlapping
240
+ run cannot write the digest twice, then starts an OpenHands conversation
241
+ with the stories **and the topics** in its prompt, an empty secrets payload
242
+ and no MCP servers, working in `{WORKSPACE_BASE}/news-digest/{date}`. The
243
+ agent decides which stories are relevant before it writes anything.
244
+ 7. When the conversation reaches `idle`, `finished`, `error` or `stuck`:
245
+ - reads `digest.md` from the working directory, falling back to the agent's
246
+ final message;
247
+ - prints the digest into the run log and keeps its opening in state;
248
+ - records the stories as covered **only now**, so a run that failed leaves
249
+ them for the next one;
250
+ - removes the working directory once the conversation is confirmed stopped.
251
+ 8. Prunes the task history to the last 14 days and the seen-list to 1000
252
+ fingerprints, both so the state document stays inside the KV store's 64 KB
253
+ value limit.
254
+
255
+ ### How a story is recognised again
256
+
257
+ Two fingerprints per story: one over the feed's own identifier (`guid`/`id`),
258
+ one over its link with the host lowercased, the fragment removed and campaign
259
+ parameters stripped. A story counts as already covered if **either** matches.
260
+ Both are needed - a feed whose links carry a per-fetch campaign tag is only
261
+ recognisable by its identifier, and two publishers syndicating the same article
262
+ agree on nothing *but* the link.
263
+
264
+ ---
265
+
266
+ ## Additional Resources
267
+
268
+ - **`references/state-schema.md`** - State JSON schema and the task lifecycle.
269
+ - **`scripts/main.py`** - The complete automation script.
270
+
271
+ ---
272
+
273
+ ## Troubleshooting
274
+
275
+ | Symptom | Likely cause | Fix |
276
+ |---|---|---|
277
+ | Every run says "Nothing new to digest" | Nothing was published, or it was all covered already | The run log now names which of the two it was; the digest covers what the topics miss only if the feeds carry it at all |
278
+ | The digest ignores a topic you care about | The feeds do not carry stories about it | Add a feed that does; the agent can only choose from what was fetched |
279
+ | A feed reports "not a feed" | The URL serves a web page, not the feed | Find the real feed URL - it is usually linked from the page as `application/rss+xml` |
280
+ | A feed reports an HTTP 403 | The host blocks unknown readers | Use a different feed for that source; this automation sends no credentials by design |
281
+ | The digest is thin and full of "Headlines" | Those feeds carry titles only | Expected for Hacker News and similar; add a feed that publishes summaries, such as Ars Technica |
282
+ | Nothing happens after a manual dispatch | Today is already recorded in state | Read the previous run's log for the digest, or clear today's entry from the state document |
283
+ | A day was missed entirely | The run failed, or the service was down | The next run covers it: the lookback window is 48 hours and failed runs deliberately remember nothing |
284
+ | Digest repeats a story | Two feeds identify the same article differently, and neither the guid nor the canonical link matched | Expected occasionally; the prompt asks the agent to merge duplicate coverage it can see |
@@ -0,0 +1,8 @@
1
+ ---
2
+ # auto-generated by sync_extensions.py
3
+ description: Create an automation that reads a list of public RSS and Atom feeds on a schedule - daily by default - keeps what is new and matches the configured topics, and has an agent write a short digest of it. It needs no credentials: the feeds are public URLs and the conversation is started with no secrets and no MCP servers.
4
+ ---
5
+
6
+ Read and follow the complete instructions in the SKILL.md file located in this skill's directory.
7
+
8
+ $ARGUMENTS
@@ -0,0 +1,178 @@
1
+ # State Schema
2
+
3
+ The automation maintains a single JSON state document, persisted across runs.
4
+ It is the source of truth for which days have been handled, which stories have
5
+ already been reported, which conversation is still active, and which working
6
+ directory is still on disk.
7
+
8
+ ---
9
+
10
+ ## Storage
11
+
12
+ **Primary (cloud):** the automation service's KV store, key `state`, available
13
+ when `AUTOMATION_KV_TOKEN` is injected.
14
+
15
+ **Fallback (local/dev):**
16
+
17
+ ```
18
+ {WORKSPACE_BASE_ROOT}/automation-state/news_digest_{automation_id}.json
19
+ ```
20
+
21
+ `WORKSPACE_BASE_ROOT` is two levels up from `WORKSPACE_BASE`, stripping
22
+ `automation-runs/{run_id}`; `automation_id` comes from `AUTOMATION_EVENT_PAYLOAD`.
23
+
24
+ The KV store caps a value at 64 KB. Everything below is bounded so the document
25
+ stays comfortably inside it however long the automation runs - which matters
26
+ here more than for a weekly automation, because a daily key writes a record a
27
+ day forever.
28
+
29
+ ---
30
+
31
+ ## Top-Level Schema
32
+
33
+ ```jsonc
34
+ {
35
+ "version": 1,
36
+ "updated_at": 1717200000.0,
37
+
38
+ // Set on every run that actually read the feeds. A run that stopped at the
39
+ // "today is done" check does not touch it.
40
+ "last_checked": 1717200000.0,
41
+
42
+ // What each selection stage left, from the same run. A digest that came out
43
+ // empty is explained by whichever of these is zero.
44
+ "last_funnel": { "fetched": 60, "unseen": 12, "fresh": 9 },
45
+
46
+ // Up to ten feed failures from the last run that read them, each truncated
47
+ // to 200 characters. Diagnostic only; nothing branches on it.
48
+ "last_feed_errors": ["https://example.test/feed: HTTP Error 500: Internal Server Error"],
49
+
50
+ // Story fingerprints already reported, oldest first, capped at 1000 - about
51
+ // two per story, so roughly five hundred stories. See "Fingerprints" below.
52
+ "seen": ["3f2a9c1b7d4e8a05", "…"],
53
+
54
+ // The most recent digest, in one slot rather than one per day. Keeping every
55
+ // digest here would overrun the value limit inside a fortnight.
56
+ "last_digest": {
57
+ "period": "2026-08-20",
58
+ "conversation_url": "https://app.all-hands.dev/conversations/…",
59
+ "written_at": 1717200000.0,
60
+ "text": "…first 4000 characters…"
61
+ },
62
+
63
+ // One record per day, keyed `news:{YYYY-MM-DD}`, pruned to the last 14.
64
+ "tasks": {}
65
+ }
66
+ ```
67
+
68
+ ---
69
+
70
+ ## Task Record
71
+
72
+ ```jsonc
73
+ "news:2026-08-20": {
74
+ "period": "2026-08-20",
75
+ "status": "active",
76
+ "conversation_id": "6ceaa97a-…",
77
+ "conversation_url": "https://…/conversations/6ceaa97a-…", // set at finalize
78
+ "workspace_dir": "/workspace/news-digest/2026-08-20", // removed with the directory
79
+ "item_keys": ["3f2a9c1b7d4e8a05", "…"], // dropped at finalize
80
+ "item_count": 12,
81
+ "last_activity": 1717200000.0,
82
+ "completed_at": 1717203600.0, // set once the conversation stopped
83
+ "expired_after": 7213.4 // only on `expired`
84
+ }
85
+ ```
86
+
87
+ ### Statuses
88
+
89
+ | Status | Meaning | Stories remembered? |
90
+ |---|---|---|
91
+ | `starting` | The day is claimed; the conversation does not exist yet | no |
92
+ | `active` | The conversation is running | no |
93
+ | `completed` | A digest was produced and printed | **yes** |
94
+ | `empty` | The conversation stopped without writing anything | no |
95
+ | `failed` | The conversation ended `error` or `stuck` | no |
96
+ | `expired` | Never reached a terminal status within `MAX_ACTIVE_AGE` (2h) | no |
97
+
98
+ Only `completed` widens `seen`. That is the whole recovery story: because the
99
+ lookback window (48h) is wider than the schedule, every other outcome leaves its
100
+ stories to be picked up by the next run rather than losing them.
101
+
102
+ ---
103
+
104
+ ## Lifecycle
105
+
106
+ ```
107
+ today already in tasks ──> stop (no feeds fetched)
108
+ │
109
+ run starts ──> fetch feeds ──> select stories
110
+ │
111
+ nothing new ───┴──> record last_checked, leave the day UNCLAIMED
112
+ │
113
+ stories ───────┴──> write `starting` and PERSIST
114
+ │
115
+ create workspace + conversation
116
+ │
117
+ success ──> `active` failure ──> drop the claim,
118
+ │ remove the workspace
119
+ (a later run polls) │
120
+ ▼
121
+ terminal ──> read digest.md, else the final message
122
+ │
123
+ digest ─────────┴──> `completed`, widen `seen`,
124
+ print it, remove the workspace
125
+ no digest ──────────> `empty`
126
+ error/stuck ────────> `failed`
127
+ never terminal ─────> `expired` after 2h
128
+ ```
129
+
130
+ ### Why the day is claimed before the slow work
131
+
132
+ State is otherwise written only at the end of a run. An overlapping run - a
133
+ retry, a manual dispatch landing on a cron tick - would read no record for today
134
+ and start a second conversation over the same stories. Writing `starting`
135
+ first closes that window. A claim left behind by a run that died before creating
136
+ its conversation is released after `STALLED_CLAIM_SECONDS` (15 minutes), which
137
+ is far longer than fetching a feed list.
138
+
139
+ ### Why "nothing new" does not claim the day
140
+
141
+ A feed that has not published yet looks exactly like a feed with nothing to
142
+ say. Claiming the day on an empty result would mean the first run of the morning
143
+ silently cancels the rest of the day. Instead the day stays open: later runs
144
+ re-check, at the cost of one HTTP request per feed and no tokens at all.
145
+
146
+ ---
147
+
148
+ ## Fingerprints
149
+
150
+ Each story contributes up to two 16-character SHA-256 prefixes to `seen`:
151
+
152
+ 1. the feed's own identifier (`guid` in RSS, `id` in Atom), and
153
+ 2. its link, with the scheme and host lowercased, the fragment and any trailing
154
+ slash removed, and `utm_*` parameters stripped.
155
+
156
+ A story is treated as already covered if **either** matches. Both are needed:
157
+ a feed whose links carry a per-fetch campaign tag is only recognisable by its
158
+ identifier, and two publishers syndicating the same article agree on nothing
159
+ *but* the link.
160
+
161
+ Hashes rather than the values themselves, because identifiers range from a short
162
+ guid to a long URL and the document has a size ceiling. They are one-way, so
163
+ `seen` cannot be read back to reconstruct what was covered - `last_digest` and
164
+ the conversations are where that is kept.
165
+
166
+ ---
167
+
168
+ ## Pruning
169
+
170
+ | Field | Cap | Rule |
171
+ |---|---|---|
172
+ | `seen` | 1000 fingerprints | Oldest evicted first |
173
+ | `tasks` | 14 records | Oldest evicted first, but never one that is `starting`/`active` or still holds a `workspace_dir` |
174
+ | `last_digest.text` | 4000 characters | Truncated |
175
+ | `last_feed_errors` | 10 entries, 200 characters each | Truncated |
176
+
177
+ A task still holding a `workspace_dir` is never pruned, because that record is
178
+ the only thing that knows a directory is waiting to be removed.