diff --git a/CoreSystemConfig.json.template b/CoreSystemConfig.json.template index 2e9d22e..e0dd45d 100644 --- a/CoreSystemConfig.json.template +++ b/CoreSystemConfig.json.template @@ -115,18 +115,31 @@ }, "secrets": { - "_comment": "Generate the tokens with: openssl rand -hex 32. Each is required only if the service that uses it is enabled above; the validator says which. ha_token is a Long-Lived Access Token from HA's own UI (profile -> Security) and cannot be generated ahead of time — leave it empty for the first build and re-run once HA is up.", + "_comment": "Generate the tokens with: openssl rand -hex 32. Each is required only if the service that uses it is enabled above; the validator says which. ha_token is a Long-Lived Access Token from HA's own UI (profile -> Security) and cannot be generated ahead of time — leave it empty for the first build and re-run once HA is up. The opnsense_api_* pair is the same kind of thing: only OPNsense can mint it (System -> Access -> Users -> API keys), so it is never generated here either.", "identity_token": "", "pantry_vision_token": "", "transit_token": "", "mqtt_username": "", "mqtt_password": "", "ha_token": "", + "opnsense_api_key": "", + "opnsense_api_secret": "", "ssh_authorized_key": "", "kiosk_password": "", "admin_password_hash": "" }, + "opnsense": { + "_comment": "The household's existing OPNsense firewall. Used by digest-engine's network digest, which pulls a Suricata intrusion-detection summary from it (GET /api/ids/service/status and POST /api/ids/service/query_alerts — two read endpoints, nothing else, ever). A build writes this block plus the secrets above into the container host's IDSconf.json; the long-form documentation for every field lives in digest-engine/IDSconf.json.example. Leave base_url empty to skip that file entirely. TWO THINGS THIS DOES NOT DO: it does not enable Suricata (do that at Services -> Intrusion Detection on the firewall, then download a ruleset), and it does not enable the ingest (set ENABLE_OPNSENSE_IDS_INGEST=true in digest-engine.env). SCOPE THE API KEY: give it its own OPNsense user with only the 'Services: Intrusion Detection' privilege — that privilege still covers api/ids/* including start/stop, because OPNsense ACLs are page-level, so the read-only guarantee comes from digest-engine calling exactly two endpoints and not from the firewall enforcing it.", + "base_url": "", + "verify_tls": true, + "interfaces": [], + "max_alerts_scanned": 5000, + "top_signatures": 8, + "top_hosts": 5, + "packet_capture_reference": "" + }, + "proxy": { "_comment": "One HTTPS front door for this repo's own services (Caddy), plus an HTTP->HTTPS redirect. Not a household-wide gateway — Home Assistant, Grocy and friends keep their own ports. hostname is what the certificate is issued for and what you type in the browser; it must resolve to the container host (a DNS override on OPNsense, a hosts entry, or just use the IP with tls: internal). tls 'internal' makes Caddy run its own CA — no external dependency, but browsers show a warning until you install its root (tools/export-proxy-ca.sh). tls 'custom' uses cert_file/key_file, which is how you'd use a real cert obtained via a DNS-01 challenge without exposing anything. See proxy/README.md.", "enabled": true, diff --git a/digest-engine/README.md b/digest-engine/README.md index 1765e5c..ba112bc 100644 --- a/digest-engine/README.md +++ b/digest-engine/README.md @@ -3,10 +3,14 @@ The quarter-daily LLM digest from [Phase 12 of the project plan](../docs/project-plan.md). Four times a day it ingests mail, messages, news and financial data, sends the -lot to the existing Ollama host for synthesis into three sections — **personal**, -**political** and **household** — and writes a rendered digest that `digest-web` -serves to two surfaces: the thin client's kiosk Firefox workspace (full view) and -a Home Assistant Lovelace iframe card (compact view). +lot to the existing Ollama host for synthesis into up to four sections — +**personal** (social), **political** (news), **household** and **network** — and +writes a rendered digest that `digest-web` serves to two surfaces: the thin +client's kiosk Firefox workspace (full view) and a Home Assistant Lovelace iframe +card (compact view). + +Which of the four a run actually generates is the household's choice, per person +— see [Who gets which digest](#who-gets-which-digest) below. It is a **oneshot**, not a daemon: a systemd timer runs `docker compose run --rm digest-engine`, exactly like the restic backup job. @@ -20,6 +24,9 @@ the plan, not a default. ``` run.py oneshot entrypoint +preferences.py who wants which digest, read from identity +archive.py the long memory — every item and number, kept across runs +agenda.py a Tagesordnung PDF -> the meeting it belongs to ingest/ one module per source, each `fetch(lookback_hours) -> list[dict]` telegram_login.py standalone one-time interactive login (run by hand) synth/llm_client.py Ollama client + the digest JSON schema @@ -27,6 +34,7 @@ synth/prompts/ one prompt template per section render/digest-canvas-sdk/ vendored, offline JS/CSS — globe, window chrome, glow, renderer render/templates/ compact.html (HA iframe) and full.html (kiosk) feeds/curated-feeds.opml the news feed list — edit this +feeds/rci-social.json the RCI/section social + podcast accounts — edit this IDSconf.json.example OPNsense IDS config template (real file gitignored) whatsapp-bridge/ Node.js sidecar, opt-in, see the warning below output/ per-run artifacts (gitignored) @@ -49,6 +57,62 @@ Then edit `feeds/curated-feeds.opml`: the mainstream outlets in it are a clearly-marked placeholder list, only the `marxist.com` feed is a deliberate choice (the political prompt uses it as its analytical basis). +## Who gets which digest + +Every person in `identity` has their own set of the four sections, ticked in the +admin panel's person editor (**Digests → Generate for this person**). Nothing +else about the run changes; what changes is how much of it happens at all. + +- **A section nobody has ticked is never generated.** No synthesis call, no + counter-run call, and — because `run.py`'s `SECTION_SOURCES` knows which + sources feed which section — no ingestion either. Turn the political section + off for the whole household and the run stops fetching news, financial data + and flight/naval traffic entirely. The saving is real WAN egress, not just + tokens. +- **Each surface then shows a person their own subset.** The digest carries a + `people` list (name, nickname, id, sections) that the renderer filters on when + it is given `?person=`. +- **The default is all four**, so a household that never opens this panel gets + exactly the digest it had before this setting existed. + +**The display half is a filter, not an access control.** `digest-web` serves the +whole output volume read-only to anything on the LAN, so an unticked section is +off somebody's screen and out of their narration — it is not hidden from them. +The half that genuinely does not exist is the half that was never generated. + +`?person=` is a name, nickname or id, and it is only ever set by a caller that +has **already** resolved who is asking: +`hosts/thin-client/agent/thinclient_agent/digest_canvas.py` passes it from a +Home-Assistant-resolved request, per the plan's Phase 11.8 rule that the +personal section is never shown on a guess. + +**The canvas is voice-activated, and the person is recognised automatically.** +Nothing shows a digest because somebody walked past a screen. When a spoken "play +my digest" fires, HA asks `identity`'s `GET /speaker?area=` who is in that +room — BLE identity plus the most recent Frigate face sighting — and passes the +answer through as `?person=`. When it cannot tell, it says so and the canvas falls +back to everything-but-personal, which is the same rule as no person at all. So +on `full.html` (the kiosk): + +| URL | What renders | +|---|---| +| `full.html?person=Amir` | exactly the sections Amir ticked | +| `full.html?person=Nobody` (unknown to identity) | everything generated **except** personal | +| `full.html` (no person) | everything generated **except** personal | +| `full.html?person=Amir`, run generated while identity was down | everything generated, personal included — the person was still resolved, and the outage already cost that run its preferences | + +`compact.html` accepts `?person=` too but defaults to showing everything +generated, personal included: that card is embedded in one person's own HA +dashboard, which is already a per-account surface rather than a screen in a +hallway. + +If `IDENTITY_URL` is blank, or identity is down, or the token is wrong, the run +generates **all four sections** and writes an empty `people` list — see +`preferences.py` for why a failed lookup fails towards more digest rather than +less. The one case that is honoured rather than overridden is a household where +everybody really has ticked everything off: that run generates nothing, and says +so in its log. + ## One-time steps before the first real run Both of these are interactive and must be done by hand, once. Scheduled runs @@ -84,7 +148,8 @@ Output lands in `output//`: - `context.json` — the ingested context bundle, kept for the Phase 12 follow-up voice Q&A (a spoken follow-up re-queries Ollama against this rather than re-ingesting). -- `digest.json` — the rendered digest, both detail levels, all three sections. +- `digest.json` — the rendered digest, both detail levels, every section this run + generated (`sections_generated` says which, and `people` who asked for what). `output/latest.json` is rewritten with the same payload and `output/latest` re-pointed at the newest run directory, so `digest-web` always serves the current @@ -105,8 +170,8 @@ If you enable it: - Use a **secondary, non-critical number**, not your main one. - Accept that the number may be banned, and that this is the highest-risk of the four message platforms by a wide margin. -- Keep `ENABLE_WHATSAPP_INGEST=false` if you are at all unsure. The other three - sections work fine without it. +- Keep `ENABLE_WHATSAPP_INGEST=false` if you are at all unsure. The rest of the + digest works fine without it. ## Manual verification still outstanding @@ -144,6 +209,287 @@ trusting a scheduled run, verify by hand: through — then confirm a real, correctly-grounded piece of Marxist analysis (a genuine merger analyzed via Lenin's imperialism) is NOT flagged just for being theoretical rather than a bare fact. +11. The per-person section toggles end to end, which have only been exercised + against the API: untick a section for everybody in identity's admin panel, + run once by hand, and confirm the run log says it skipped both that + section's synthesis **and** its sources' ingestion, that `digest.json`'s + `sections_generated` agrees, and that `full.html?person=` shows what + that person ticked and nothing else. Then stop the `identity` container and + run again — that run must generate all four sections rather than none. +12. That `full.html` with **no** `?person=` really does leave the personal + section out. It is a behaviour change: before this setting existed, the + kiosk showed everyone's personal section to whoever walked past, despite + `digest_canvas.py` having always claimed otherwise. +13. **The political section's new structure against a real model.** The ingest + half is verified — `ingest/rci_social.py` was run live against the committed + `feeds/rci-social.json` on 2026-08-06 and returned real YouTube and podcast + entries with durations — but no local model has yet been asked to produce the + four question windows, per-marker summaries and `sources` arrays in one JSON + document. Check on the first real run that a 14B model actually fills + `sources` rather than dropping the field, that it does not put episodes in + the analysis, and that the compact pass still fits the HA card now that the + section has more to say. +14. **The Telegram channel entry in `feeds/rci-social.json`.** The channel name + comes from marxist.com's own footer but was never fetched — reading it needs + the Telethon session, which only exists on the real deployment. Also confirm + it does not double up: if you have *joined* that channel, its posts arrive a + second time through `telegram_ingest.py` into the personal section. +15. **The archive against real runs.** Its logic is covered (dedup across runs, + per-item annotation, series ordering, IDS recurrence, exclusion, and a broken + database not taking a run down), but no real deployment has accumulated days + of history yet. Check after a week that `first_seen_at` looks right on a story + you remember, that the political section quotes both figures and both dates + when it claims a trend, and that the network section has started calling its + familiar signatures background rather than news. Watch the file size too — + 400 days of mail is the setting to revisit first. +16. **A real Tagesordnung, end to end.** The matcher and the PDF extraction are + tested against a generated PDF; nobody has yet sent a real branch agenda + through a real mailbox. Confirm the attachment actually spools, that the + points come out of a real layout (multi-column or heavily styled agendas are + where pypdf's extraction gets ragged), that a scanned one degrades to + `text_extracted: false` rather than to nonsense, and that no household todo + appears that the document did not actually ask for. +17. **The WhatsApp document download**, which is new behaviour in the bridge — + `message._data.filename` is undocumented API and `downloadMedia()` has never + been exercised here. Check a document arrives, lands in + `/data/whatsapp-bridge/documents`, and that photos and video are still never + downloaded. +18. **The OPNsense credentials via CoreSystemConfig.json.** The export → generated + `IDSconf.json` → `opnsense_ids.py` chain is tested with synthetic values; + confirm a real key pair authenticates against a real firewall, and that the + scoped user really cannot do more than read alerts. +19. **The bias/ownership prompting, adversarially.** Feed a run a story covered + by both RT and the BBC and confirm the section applies the ownership analysis + to both rather than only to the one it is easier to be sceptical of, and that + a Times of Israel claim about Palestinians never reaches the digest in the + section's own voice. + +## The political section — sources, ownership, and the four questions + +The political digest answers four questions, in this order, one window each +(`synth/prompts/political.md` is written around them): + +1. What is relevant for the communist and class struggle **globally** right now. +2. What matters for **organising here** — Austria, and Vorarlberg specifically. + A closure in Dornbirn outranks a foreign cabinet reshuffle for this one. +3. What else is **consequential in the mid-to-long term** without being class + struggle directly — rearmament, energy, epidemics, supply chains, repression. +4. What is happening **inside the RCI**, and what comrades elsewhere report. Its + sources are the `theory` feeds, the organisation's own social output, and the + reports that arrive in the user's own **mail** — the one input here no feed + can supply. + +Plus a **watch-later** window: new videos and podcast episodes from the +organisation's channels, kept out of the analysis entirely because they are +things to watch later, not evidence. + +### Every source is read against who owns it + +`feeds/curated-feeds.opml` carries `owner` and `bias` on every feed, and +`ingest/news_rss.py` puts both on every entry. The prompt reads each item +against them. **This is not a reliability score** — it is the same materialist +analysis the section applies to everything else, applied to the press: + +- "State-affiliated" names who signs the cheque, not a propaganda bucket that + private Western outlets are exempt from. RT is the outlet of the Russian + capitalist class and its state; the BBC is the state broadcaster of a NATO + power. Both get the treatment, or neither does. +- A private outlet is the organ of a fraction of capital — named when it + explains the coverage (the Washington Post's owner is Amazon's owner when the + story is warehouse labour). +- The business press (FT, CNBC) is often the most candid source in the file: it + briefs capital honestly because capital is the reader. +- `category="news_labour"` is the workers' and movement press — closer to the + shop floor, and not the RCI, so its reporting is used and its conclusions are + not adopted. + +**Zionist media get zero trust** (`category="news_zionist"`, and anything whose +`bias` says so). Their claims about Palestinians are never repeated as +established fact and their language is never adopted; they are read as evidence +about the Israeli state itself — what its ruling class admits, prepares for, or +falls out over. Zero trust is not inversion: a denial is not proof, and the "No +speculation" rule still binds. The Palestinian, anti-Zionist and independent +outlets in the same group are what corroboration is checked against. + +The shipped list is about three dozen feeds across those blocs, all fetched and +confirmed live on 2026-08-06 except four marked `VERIFY` in the file. **Keep +`NEWS_MAX_ENTRIES_PER_FEED` low** (5 by default) — it multiplies by the feed +count into one prompt. + +### Summaries on the globe, sources folded underneath + +Every globe marker carries a `summary` (2–4 sentences on what is happening +there) and its own `sources`; any window can carry `sources` too. The renderer +prints marker summaries under the globe — not as tooltips, since the globe +rotates and a briefing you can only read while its marker faces you is not a +briefing — and folds citations into a collapsed `Sources (n)` block that costs +no screen space until tapped. Each citation carries the outlet **and its +ownership**, so who paid for a claim sits next to the claim. + +Two guards on that, in `synth/counter_run.py`: a source whose URL is not +present in the run's context is **dropped**, and a real source carrying an +invented quote keeps the citation and loses the quote. A plausible-looking URL +is the easiest thing in this schema to fabricate and the hardest to eyeball. + +### The organisation's own social media + +`ingest/rci_social.py` reads the accounts listed in `feeds/rci-social.json` and +tags them `theory_social`, kept apart from the written analysis (`theory`) +because a meeting announcement is not an argument. Only platforms with a public, +keyless, first-party read path are implemented, and that is a hard line: + +- **YouTube** — the channel's own Atom feed (`feeds/videos.xml?channel_id=`), no + key, no quota. The RCI's and the Austrian section's channels are configured and + verified. +- **RSS** — the section's podcast (`anchor.fm`), a Mastodon account's `.rss`, any + site feed. +- **Telegram** — through the same Telethon session `telegram_ingest.py` already + uses. A public channel is resolved by username and read **without joining it**; + no session means those entries are skipped and the rest still run. + +**Instagram, Facebook, WhatsApp channels, TikTok and X are deliberately not +built.** None has a read path that is both keyless and inside its own terms: +Instagram's Basic Display API was retired in 2024 and the Graph API only reads +accounts you own; Facebook page RSS died in 2018; WhatsApp channels have no API +at all; X's free tier reads essentially nothing and Nitter is gone. Reading them +would mean scraping, which this component does not do. Instagram is the real +gap — it is where the section posts most. Follow it on your phone; do not point +this at a scraping proxy, which moves the terms-of-service problem onto a third +party without removing it. + +## "Your digest is ready" — the ntfy push + +Because the canvas only opens when it is asked for, the notification is the one +thing that reaches you unprompted, and it exists to answer one question: **is this +run worth going and asking for, or does it keep until tonight?** + +`notify.py` posts to the self-hosted ntfy this stack already runs for `chores` and +`identity`. On Android that lands on the phone and relays to a watch (a Pebble +needs nothing else installed). It costs **no extra LLM call**: every section +already produces a `narration` at `compact` detail — two to four sentences written +to be read aloud, which is the register a notification wants — so the push is +those narrations trimmed to a line each, plus the political to-do count and any +`withheld`/`unverified` marks. Nothing is generated here, so the notification can +never claim something the digest itself does not say. + +**The per-person settings decide the push, not just the canvas.** Somebody who +switched the political section off gets no political content on their phone — +otherwise the setting would be a lie in the place it is most visible. People are +grouped by their own ntfy topic (identity's `notify_topic`, the same one arrival +notifications use), and each topic gets the union of what the people behind it +asked for: a topic *is* its audience, so two people sharing one have already +agreed to share what arrives on it. No personal topic falls back to `NTFY_TOPIC`; +no preferences at all (identity down) sends one household message about everything +generated. `DIGEST_WEB_URL` makes the push tappable, opening that person's own +digest — a shared topic gets the unfiltered page, since it has no single owner. + +Published as **JSON to ntfy's root**, not as text with `Title:` headers the way +`chores` does. HTTP headers are latin-1, and this digest quotes news headlines and +household names: an em dash or an umlaut in a title raises `UnicodeEncodeError` +before the request is even sent. That failed on real content and passed every +ASCII test until one was written for it. + +ntfy stays LAN-only; away from home it is reached over the WireGuard split tunnel +(`docs/network-integration.md` §2.2), never a port forward. A failed push logs a +warning and nothing else — the digest is already on disk by then, and no +notification is worth failing a run over. + +## The archive — the digest's long memory + +`archive.py` keeps a SQLite database in the `/data` volume holding every item the +digest has ingested and every number it has measured. A run without it sees six +hours and nothing else, which makes the most valuable things this system could +say impossible to say: *"up from 5.1% in June"*, *"this signature has fired every +night for a week"*, *"merchant traffic through this chokepoint has halved"*, +*"this story first appeared on Monday and has not moved"*. + +Two tables, because there are two kinds of thing: + +- **`items`** — discrete things (articles, messages, videos, calendar entries), + deduplicated on a fingerprint (the URL when there is one). An article seen in + four runs is one row seen four times, which is what makes `first_seen_at` + meaningful. Every entry the prompts see now carries `first_seen_at` and + `times_seen`, so "new this run" and "the same story for four days" are + distinguishable without the model inferring it. +- **`observations`** — numbers that only mean anything as a series: FRED and + Stooq readings, aircraft and vessel counts per region, IDS alerts per signature + and per host. One row per measurement, so a trend is a query. + +History enters each prompt as its own labelled block with its own timestamps — +never merged into this run's entries, so last month's figure can't be mistaken +for today's news. **The trend rules changed with it**: the political section may +now describe something as rising or falling *when the history block supports it, +with both figures and their dates*, and the network section leads with what is +new and says plainly when a signature is background noise it has seen fifteen +times. Without history, both revert to "one snapshot is not a trend". + +**This keeps your mail and messages on disk for the retention window** (400 days +by default), where previously only the last few runs' `context.json` did. Nothing +leaves the host and the file sits beside the Telegram session, but it is a real +change in how long personal content is kept — `DIGEST_ARCHIVE_EXCLUDE_SOURCES` +takes a comma-separated list of ingest keys to keep out of it entirely, and the +financial/traffic/IDS trends work regardless of what you exclude. +`ENABLE_DIGEST_ARCHIVE=false` turns the whole thing off. A corrupt or unwritable +database logs a warning and the run proceeds without memory. + +## Meeting agendas — the Tagesordnung finds its meeting + +A branch sends the agenda for Thursday's meeting as a PDF on Monday, by mail or +WhatsApp. `agenda.py` matches it to the calendar event it belongs to, so the +digest says "branch meeting Thursday 19:00 — agenda `TO_12.08.pdf` from Anna", +and then uses what is actually in it. + +**What it matches on**, in order: a date in the filename, subject or the +document's own heading against an event starting that day; words in common +between the agenda and the event's summary; both, which is the confident case. +Everything is labelled with its confidence and reason, so a wrong match is +visible rather than asserted, and an agenda that matches nothing is reported as +unattached rather than dropped. + +**`TO` is special-cased.** It is the abbreviation everyone actually uses and also +the commonest two-letter word in English, so it is matched only as a standalone +uppercase token in a filename or subject — never in body prose. The long words +(Tagesordnung, Traktanden, agenda, Einladung; `AGENDA_KEYWORDS`) match +case-insensitively anywhere. + +**The document is read.** `pypdf` extracts the text, the numbered and bulleted +lines are pulled out mechanically as `points` — a list extracted by code is a +list that cannot be invented — and both go into the context. + +**An agenda's contents belong to the political section**, which is where a branch +agenda's party work belongs, and which means they reach only the people who +ticked the political digest. The split is deliberate and the prompts enforce it +from both sides: + +- the **household** section says only *that* an agenda arrived, for which + meeting, from whom, and whether it could be read at all. It is told not to list + points or derive tasks even though the text is in front of it. Somebody with + the household digest and not the political one sees a meeting with an agenda, + not its contents. +- the **political** section carries `political-agenda` (the meeting and its + points as written) and **`political-todo`, titled "Political todos"** — what + the reader actually has to do before those meetings, one line each, verb first, + naming the meeting and quoting the line of the agenda or its covering message + the task came from. A task assigned to someone else is listed as theirs, so the + reader knows it is covered. If nothing is actually asked for, the window is + omitted — inventing preparation nobody asked for is the one failure here a + reader would act on. It stays its own window at `compact` too, since it is the + part of the digest people act on rather than read. +- the same agendas double as the section's sharpest relevance filter: a story + touching an agenda point outranks a bigger story that doesn't, and says why — + *"on Thursday's agenda"*. + +`calendar` is therefore in the political section's `SECTION_SOURCES`: not for the +diary, but because it cannot match an agenda to a meeting it never fetched. + +**A scanned agenda yields nothing** — it is a page of images and there is no OCR +here. That case attaches with `text_extracted: false`, and both prompts are told +they may name such a document but must not characterise it. + +Attachments are spooled to `/data/attachments` (mail) and +`/data/whatsapp-bridge/documents` (the bridge, documents only — never photos or +video). That is a write to the digest's own volume, exactly as `context.json` +already is; nothing is written back to any mailbox or chat. ## Traffic data — what exists and what does not @@ -183,11 +529,28 @@ both 403'd the check — re-test those from the real network. ## Home network intrusion detection — what exists and what does not `ingest/opnsense_ids.py` pulls a summary of the Suricata alerts your **existing -OPNsense firewall** raised during the digest window and folds it into the -**household** section as one short item, tagged `"category": -"network_security"`. Off by default (`ENABLE_OPNSENSE_IDS_INGEST=false`); -configured by `IDSconf.json` (template: `IDSconf.json.example`, real file -gitignored, same pattern as `digest-engine.env`). +OPNsense firewall** raised during the digest window, tagged `"category": +"network_security"`. Off by default (`ENABLE_OPNSENSE_IDS_INGEST=false`). + +**The credentials live in `CoreSystemConfig.json`** — an `opnsense` block for the +address and tuning, `secrets.opnsense_api_key`/`opnsense_api_secret` for the key +pair OPNsense mints (System → Access → Users → API keys; only OPNsense can issue +them, so `generate-tokens.py` deliberately never invents one). A build writes +them into the container host's `/data/IDSconf.json` alongside every other +generated config. Field-by-field documentation stays in +`IDSconf.json.example`, which is also what `setup-container-host.sh` seeds when +you install by hand instead of from an image. Setting the credentials does **not** +enable the ingest: `ENABLE_OPNSENSE_IDS_INGEST=true` in `digest-engine.env` is +still a separate, deliberate step. + +It is **its own section** (`synth/prompts/network.md`), not a couple of lines +inside the household one as it was originally built. The reason is the +per-person setting above: somebody who wants the calendar and the shopping list +but not a nightly intrusion-detection readout — or the reverse — can only say so +if the two are generated separately. The instructions themselves moved across +unchanged, including the one that matters most: alerts are signature matches, +never a claim that a device is compromised, and the section may never call the +network safe. **Suricata is core, not a plugin.** There is no `os-suricata` to install — the IDS module ships in OPNsense core and lives at Services → Intrusion Detection. @@ -238,7 +601,7 @@ rotation driven from the firewall's own cron (this needs shell access on OPNsense — the GUI cron only schedules predefined configd actions — and enough disk for ten-minute captures of a live link, which is not small). Then put a one-line pointer in `IDSconf.json`'s `packet_capture_reference`; it is echoed -verbatim into the digest so the household section can say "raw captures are at +verbatim into the digest so the network section can say "raw captures are at X". The digest engine never downloads, stores or analyses them. ## Household data — what exists and what does not @@ -393,7 +756,9 @@ What happens to something it flags: either, since a transient failure in this pass specifically shouldn't cost as much as the whole digest being down. -This doubles the number of Ollama calls per run (12 instead of 6) — against a +This doubles the number of Ollama calls per run (four calls per generated +section rather than two — so 16 with all four sections on, and fewer for every +section the household has switched off) — against a local, self-hosted model with no per-token cost and nobody waiting on the latency, the same tradeoff `synth/llm_client.py` already makes for generating `compact` and `full` as separate passes rather than truncating one into the diff --git a/digest-engine/agenda.py b/digest-engine/agenda.py new file mode 100644 index 0000000..4a72c6a --- /dev/null +++ b/digest-engine/agenda.py @@ -0,0 +1,399 @@ +"""Meeting agendas — attaching a "Tagesordnung" to the meeting it belongs to. + +An agenda arrives as a PDF in mail or WhatsApp, days before the meeting it is +for. The meeting is in the calendar. Nothing connected the two, so the digest +would report "a message from Anna with an attachment" in one section and "branch +meeting, Thursday 19:00" in another, and leave the reader to notice. + +This module makes that one fact: a calendar event that has an agenda says so, +naming the file, who sent it and where it arrived. + +READING THE DOCUMENT +-------------------- +The text is extracted (pypdf for PDFs, straight decode for plain text) and put in +the context, so the digest can list what is actually on the agenda, pull out what +the reader has been asked to prepare, and — in the political section — let this +week's agenda points steer which news is worth featuring. That last one is the +real payoff: "my branch is discussing rent controls on Thursday" is exactly the +filter that makes a news digest useful rather than merely comprehensive. + +Extraction is best-effort and honest about failing. A scanned agenda is a page of +images and yields nothing; there is no OCR here and adding one would be a +different project. When nothing could be read, the document still attaches to its +meeting with `text_extracted: false`, and the prompts are told that a document +without text is one they may name but must not characterise. + +The bytes never leave the household: the file is spooled to the digest's own +`/data` volume by the ingest modules, read here, and the extracted text goes only +into the local Ollama prompt like everything else. + +HOW A MATCH IS MADE, IN ORDER +----------------------------- +1. **A date in the filename or subject** ("TO_12.08.pdf", "Tagesordnung 12.8.", + "agenda 2026-08-12") against an event starting that day. This is the strong + signal and it is tried first. +2. **Words in common** between the agenda's own text and an event's summary + ("OG-Treffen" in both), among events in the lookahead window. +3. Both, which is the confident case and is labelled as such. + +Anything that matches nothing is returned as an unattached agenda rather than +dropped — "an agenda arrived and I could not tell which meeting it is for" is +useful to a reader, and silently swallowing it would be the one outcome that +makes this feature worse than not having it. + +WHY "TO" IS NOT MATCHED LIKE THE OTHER WORDS +-------------------------------------------- +`TO` is the abbreviation everybody actually uses, and it is also the most common +two-letter word in English mail. It is therefore matched only as a standalone +uppercase token, and only in a filename or subject — never in body text. The +long words (Tagesordnung, Traktanden, agenda) are matched case-insensitively +anywhere, because they cannot collide with anything. +""" + +import logging +import os +import re +from datetime import datetime, timezone +from pathlib import Path + +LOG = logging.getLogger(__name__) + +# Sources whose messages can carry an agenda. Telegram and Signal are here because a +# branch that uses them will send agendas there too; they contribute nothing unless +# their ingestion is enabled and actually carries attachment metadata. +MESSAGE_SOURCES = ("email", "whatsapp", "telegram", "signal", "discord") + +DEFAULT_KEYWORDS = "tagesordnung,traktanden,traktandenliste,agenda,einladung" + +# Standalone uppercase abbreviations, matched only in filenames and subjects. +ABBREVIATION_RE = re.compile(r"(?:^|[\s_\-\[(])((?:TO|TOP)\d*)(?:[\s_\-.\])]|$)") + +DOCUMENT_TYPES = (".pdf", ".doc", ".docx", ".odt", ".rtf") + +# 12.08.2026 / 12.8. / 2026-08-12 / 12-08-2026 +DATE_PATTERNS = ( + re.compile(r"(?P20\d{2})[-_.](?P\d{1,2})[-_.](?P\d{1,2})"), + re.compile(r"(?P\d{1,2})[-_.](?P\d{1,2})[-_.](?P20\d{2})"), + re.compile(r"(?P\d{1,2})[-_.](?P\d{1,2})\.?(?![\d.])"), +) + +# Words too common to prove two strings are about the same meeting. +STOPWORDS = { + "der", "die", "das", "und", "für", "fur", "mit", "von", "zum", "zur", "des", "dem", + "the", "and", "for", "with", "our", "next", "meeting", "termin", "treffen", + "tagesordnung", "agenda", "einladung", "traktanden", "traktandenliste", "pdf", +} + +MIN_TOKEN_LENGTH = 4 + +# How much of a document reaches the prompt. An agenda is a page or two; anything +# past this is a bundle of minutes and attachments that would crowd out the run's +# actual material. +MAX_TEXT_CHARS = 6000 +MAX_PDF_PAGES = 12 + +# How much of a document counts as its heading for matching purposes — the block at +# the top where an agenda names its meeting and its date. +HEADING_CHARS = 300 + +# How much of it goes to the political section, which has to fit a world's worth of +# news around it. Enough for a page of agenda plus its preamble. +BRIEF_TEXT_CHARS = 3000 + +# Lines that look like agenda points: "1. Bericht", "TOP 3 — Kasse", "- Anträge". +POINT_RE = re.compile(r"^\s*(?:TOP\s*)?(?:\d{1,2}[.)]|[-•*])\s+(?P\S.{2,160})$", re.IGNORECASE) + + +def _extract_text(path): + """The document's text, or "" if it cannot be read. Never raises. + + pypdf is imported here rather than at module scope so a missing or broken + optional dependency costs the agenda text and nothing else — the same rule + every ingestion module follows for its own client library. + """ + if not path: + return "" + file_path = Path(path) + if not file_path.is_file(): + LOG.info("agenda: %s is not readable, keeping the document by name only", path) + return "" + + suffix = file_path.suffix.lower() + try: + if suffix in (".txt", ".md"): + return file_path.read_text(encoding="utf-8", errors="replace")[:MAX_TEXT_CHARS] + if suffix != ".pdf": + # .docx/.odt are zip containers and .doc is a binary format; parsing them + # would mean another dependency for a case nobody in this household has + # hit yet. Named, not read — which the prompts handle. + return "" + + from pypdf import PdfReader + + reader = PdfReader(str(file_path)) + pages = [] + for page in reader.pages[:MAX_PDF_PAGES]: + pages.append(page.extract_text() or "") + return "\n".join(pages)[:MAX_TEXT_CHARS] + except Exception: + LOG.warning("agenda: could not extract text from %s", path, exc_info=True) + return "" + + +def _agenda_points(text): + """The numbered/bulleted lines of an agenda, in order. + + Extracted here rather than left to the model because these are the one part of + the document that has a reliable shape, and a list pulled out mechanically is a + list that cannot be invented. The model still gets the full text; this is what + it can quote from without having to find structure in a wall of PDF extraction. + """ + points = [] + for line in (text or "").splitlines(): + match = POINT_RE.match(line.strip()) + if not match: + continue + point = " ".join(match.group("text").split()) + if point and point not in points: + points.append(point) + return points[:30] + + +def _keywords(): + raw = os.environ.get("AGENDA_KEYWORDS", DEFAULT_KEYWORDS) + return [word.strip().lower() for word in raw.split(",") if word.strip()] + + +def _looks_like_document(name, content_type): + name = (name or "").lower() + content_type = (content_type or "").lower() + if any(name.endswith(suffix) for suffix in DOCUMENT_TYPES): + return True + return "pdf" in content_type or "document" in content_type or "msword" in content_type + + +def _agenda_label(text, keywords): + """Why this looked like an agenda, or None. Returned as a string because it ends + up in the digest: "matched on 'Tagesordnung'" is checkable by a human, "true" is + not.""" + if not text: + return None + lowered = text.lower() + for word in keywords: + if word in lowered: + return word + match = ABBREVIATION_RE.search(text) + return match.group(1) if match else None + + +def _parse_date(text, reference_year): + """First date-looking thing in `text`, as (year, month, day). A bare "12.8." takes + the year from the run itself — an agenda written without a year is always for the + near future, never for two years ago.""" + if not text: + return None + for pattern in DATE_PATTERNS: + match = pattern.search(text) + if not match: + continue + groups = match.groupdict() + try: + day = int(groups["day"]) + month = int(groups["month"]) + year = int(groups.get("year") or reference_year) + except (TypeError, ValueError): + continue + if 1 <= month <= 12 and 1 <= day <= 31: + return year, month, day + return None + + +def _tokens(text): + words = re.split(r"[^0-9A-Za-zÄÖÜäöüß]+", (text or "").lower()) + return { + word for word in words + if len(word) >= MIN_TOKEN_LENGTH and word not in STOPWORDS and not word.isdigit() + } + + +def _event_date(event): + raw = str(event.get("start") or "") + if not raw: + return None + try: + parsed = datetime.fromisoformat(raw.replace("Z", "+00:00")) + except ValueError: + # All-day events arrive as a bare date. + try: + parsed = datetime.strptime(raw[:10], "%Y-%m-%d") + except ValueError: + return None + return parsed.year, parsed.month, parsed.day + + +def _find_agendas(collected, keywords): + """Every agenda-looking document in this run's messages, with the evidence.""" + found = [] + for source in MESSAGE_SOURCES: + for message in collected.get(source) or []: + if not isinstance(message, dict): + continue + attachments = [a for a in (message.get("attachments") or []) if isinstance(a, dict)] + documents = [ + a for a in attachments + if _looks_like_document(a.get("filename"), a.get("content_type")) + ] + if not documents: + continue + + # The subject/caption counts as a label too: plenty of people send + # "Tagesordnung für Donnerstag" with the file named scan_0001.pdf. + heading = " ".join(str(message.get(key) or "") for key in ("subject", "body", "chat")) + for document in documents: + filename = str(document.get("filename") or "") + label = _agenda_label(filename, keywords) + matched_in = "filename" + if not label: + # The abbreviation is only trusted in a subject line, never in body + # prose — see the module docstring. + label = _agenda_label(str(message.get("subject") or ""), keywords) + matched_in = "subject" + if not label: + lowered = heading.lower() + label = next((word for word in keywords if word in lowered), None) + matched_in = "message text" + if not label: + continue + + text = _extract_text(document.get("path")) + found.append({ + "filename": filename or "(unnamed attachment)", + "content_type": document.get("content_type"), + "source": source, + "from": message.get("from"), + "chat": message.get("chat"), + "subject": message.get("subject"), + # The covering note. Kept because a good half of what a branch + # actually asks people to do is written here rather than in the + # numbered agenda — "bringt bitte eure Beiträge mit". + "body": str(message.get("body") or "")[:400], + "received_at": message.get("timestamp"), + "matched_keyword": label, + "matched_in": matched_in, + # False for a scanned agenda, an unsupported format, or a file + # the ingest could not spool. The prompts treat that case as + # "name it, do not characterise it". + "text_extracted": bool(text.strip()), + "points": _agenda_points(text), + "text": text.strip(), + }) + return found + + +def attach(collected, reference_year=None): + """Attaches agendas to calendar events in place and returns the unmatched ones. + + Mutating the calendar entries is deliberate: the household section already + renders those events, so an event that carries its own `agenda_documents` needs + no second list to be cross-referenced against. + """ + events = [event for event in (collected.get("calendar") or []) if isinstance(event, dict)] + agendas = _find_agendas(collected, _keywords()) + if not agendas: + return [] + if not events: + LOG.info("agenda: %d agenda-looking document(s) but no calendar events to match", len(agendas)) + return agendas + + reference_year = reference_year or datetime.now(timezone.utc).year + unmatched = [] + + for agenda in agendas: + # The document's own heading — its first few lines, where an agenda puts the + # name of the meeting and its date. Only the heading: matching against the + # whole text would find a word in common with almost any event in the + # calendar and turn a confident match into a coincidence. + heading = (agenda.get("text") or "")[:HEADING_CHARS] + date = ( + _parse_date(agenda["filename"], reference_year) + or _parse_date(agenda.get("subject"), reference_year) + or _parse_date(heading, reference_year) + ) + agenda_tokens = _tokens(f"{agenda['filename']} {agenda.get('subject') or ''} {heading}") + + best = None + for event in events: + same_day = date is not None and _event_date(event) == date + shared = agenda_tokens & _tokens(event.get("summary")) + if same_day and shared: + confidence, why = "high", f"same date and shared wording ({', '.join(sorted(shared))})" + elif same_day: + confidence, why = "medium", "the date in the file name matches this event" + elif shared: + confidence, why = "low", f"shared wording ({', '.join(sorted(shared))})" + else: + continue + rank = {"high": 3, "medium": 2, "low": 1}[confidence] + if best is None or rank > best[0]: + best = (rank, event, confidence, why) + + if best is None: + unmatched.append(agenda) + continue + + _, event, confidence, why = best + attached = dict(agenda) + attached["match_confidence"] = confidence + attached["match_reason"] = why + event.setdefault("agenda_documents", []).append(attached) + LOG.info( + "agenda: attached %r to %r (%s: %s)", + agenda["filename"], event.get("summary"), confidence, why, + ) + + if unmatched: + LOG.info("agenda: %d agenda(s) matched no event", len(unmatched)) + return unmatched + + +def _brief(document, meeting, starts_at): + return { + "meeting": meeting, + "starts_at": starts_at, + "agenda_file": document.get("filename"), + "from": document.get("from"), + "received_at": document.get("received_at"), + # What was sent alongside the file. Half of what a branch actually asks + # people to do arrives in the covering message ("bringt eure Beiträge mit"), + # not in the numbered agenda itself. + "covering_message": " ".join( + part for part in (document.get("subject"), document.get("body")) if part + )[:400] or None, + "points": document.get("points") or [], + "text": (document.get("text") or "")[:BRIEF_TEXT_CHARS], + "text_extracted": document.get("text_extracted", False), + } + + +def upcoming(collected, unattached=None): + """The agendas, for the political section. + + This carries the document's actual text, not just its topics, because the + political section is where the agenda's content lives: it lists the points and + derives the reader's own to-do list from them. That placement is deliberate — + a branch agenda is party work, so it reaches only the people who asked for the + political digest, and a household member who did not is told a meeting has an + agenda without being shown what is on it. + + The text is capped harder than the household section's copy: this prompt has a + world's worth of news to fit alongside it. + """ + briefs = [] + for event in collected.get("calendar") or []: + if not isinstance(event, dict): + continue + for document in event.get("agenda_documents") or []: + briefs.append(_brief(document, event.get("summary"), event.get("start"))) + for document in unattached or []: + briefs.append(_brief(document, None, None)) + return briefs diff --git a/digest-engine/archive.py b/digest-engine/archive.py new file mode 100644 index 0000000..0018353 --- /dev/null +++ b/digest-engine/archive.py @@ -0,0 +1,428 @@ +"""The digest's long memory — every ingested item, kept across runs. + +A run is otherwise amnesiac: it fetches the last six hours, writes a digest, and +forgets. That makes some of the most valuable things this system could say +impossible to say at all. "This signature has fired every night for a week", +"unemployment is up for the third month", "merchant traffic through this +chokepoint has halved since May", "this story first appeared four days ago and +has not moved" — none of them are visible in one window, and all of them are the +kind of finding the political and network prompts are otherwise told they may +not make (see the "one snapshot is not a trend" rules). + +This module is that memory: a SQLite database in the same `/data` volume the +Telegram session and IDSconf.json already live in, written at the end of +ingestion and read back at the start of the next run. + +TWO TABLES, BECAUSE THERE ARE TWO KINDS OF THING +------------------------------------------------ +`items` — discrete things that happen once and are then referred to again: an +article, a message, a video, a calendar event. Deduplicated on a fingerprint, so +an article that appears in four consecutive runs is ONE row that has been seen +four times, not four rows. That is what makes `first_seen_at` meaningful, and +`first_seen_at` is what tells the prompt whether it is looking at a new +development or the same story it showed you yesterday. + +`observations` — numbers that are re-measured every run and only mean anything +as a series: an unemployment rate, a share price, an aircraft count in a region, +the number of IDS alerts on a signature. Stored one row per measurement so a +trend is a query rather than a guess. + +WHAT THIS IS NOT +---------------- +It is not a cache — nothing reads it to avoid fetching. It is not a second +source of truth: every claim in a digest still has to trace to this run's +context, and history enters the prompt as its own clearly-labelled block with +its own dates attached, so "in the last 30 days" can never be presented as +something that happened today. + +It is also not free of consequence: this file persists the household's mail and +messages for as long as its retention window, where before them only the last +few runs' `context.json` did. `DIGEST_ARCHIVE_EXCLUDE_SOURCES` exists for +exactly that reason, and README.md says so plainly rather than burying it. + +Every operation here is best-effort. A corrupt or unwritable database logs a +warning and the run continues without history — a digest with no memory is worth +far more than no digest, the same rule every ingestion module follows. +""" + +import hashlib +import json +import logging +import os +import sqlite3 +from datetime import datetime, timedelta, timezone +from pathlib import Path + +LOG = logging.getLogger(__name__) + +DEFAULT_DB_PATH = "/data/digest-archive.db" +DEFAULT_RETENTION_DAYS = 400 + +# Sources whose items are discrete things worth deduplicating and dating. Anything +# not listed contributes observations only (see _record_observations) or nothing. +ITEM_SOURCES = ( + "news", "rci_social", "email", "signal", "telegram", "discord", "whatsapp", + "calendar", "grocy", +) + +# How much of an item's own text takes part in its fingerprint. Long enough that two +# genuinely different messages don't collide, short enough that an article whose +# summary gets re-edited between runs is still recognised as the same article. +FINGERPRINT_TEXT_CHARS = 200 + +MAX_SERIES_POINTS = 60 + + +def _now_iso(): + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _db_path(): + return Path(os.environ.get("DIGEST_ARCHIVE_DB_PATH", DEFAULT_DB_PATH)) + + +def _excluded_sources(): + raw = os.environ.get("DIGEST_ARCHIVE_EXCLUDE_SOURCES", "") + return {part.strip() for part in raw.split(",") if part.strip()} + + +def _connect(): + path = _db_path() + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(path, timeout=15) + conn.row_factory = sqlite3.Row + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS items ( + id INTEGER PRIMARY KEY, + -- What makes two sightings the same thing. See _fingerprint(). + fingerprint TEXT NOT NULL UNIQUE, + source TEXT NOT NULL, + category TEXT, + -- The item's own timestamp (when it was published/sent), which is not the + -- same as when this system first saw it — a feed can be hours behind. + occurred_at TEXT, + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + times_seen INTEGER NOT NULL DEFAULT 1, + first_run_id TEXT NOT NULL, + last_run_id TEXT NOT NULL, + title TEXT, + url TEXT, + payload TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS items_source_seen ON items (source, last_seen_at); + CREATE INDEX IF NOT EXISTS items_occurred ON items (occurred_at); + + CREATE TABLE IF NOT EXISTS observations ( + id INTEGER PRIMARY KEY, + -- Stable machine key ("fred:UNRATE", "ids:signature:2013028"), plus a human + -- label kept alongside it so a series can be read without a lookup table. + series TEXT NOT NULL, + label TEXT, + metric TEXT NOT NULL, + value REAL NOT NULL, + observed_at TEXT NOT NULL, + run_id TEXT NOT NULL, + -- One measurement per series per metric per timestamp: a re-run of the same + -- hour must not double-count, and FRED re-serving last month's figure must + -- not look like a fresh reading. + UNIQUE (series, metric, observed_at) + ); + CREATE INDEX IF NOT EXISTS observations_series ON observations (series, observed_at); + """ + ) + return conn + + +def _fingerprint(source, item): + """What makes two sightings of a thing the same thing. + + A URL when there is one — that is what an article, a video or a post actually + is. Otherwise the source plus the item's own timestamp and the head of its + text, which is stable for a message (they don't get edited under you) and + stable enough for a calendar event. + """ + url = str(item.get("url") or item.get("link") or "").strip() + if url: + return hashlib.sha256(f"{source}\n{url}".encode("utf-8")).hexdigest() + + text = " ".join( + str(item.get(key) or "") + for key in ("title", "summary", "subject", "body", "message", "name", "chat") + )[:FINGERPRINT_TEXT_CHARS] + stamp = str(item.get("timestamp") or item.get("start") or item.get("occurred_at") or "") + return hashlib.sha256(f"{source}\n{stamp}\n{text}".encode("utf-8")).hexdigest() + + +def _item_title(item): + for key in ("title", "subject", "summary", "name", "chat"): + value = item.get(key) + if value: + return str(value)[:300] + body = item.get("body") or item.get("message") or "" + return str(body)[:300] or None + + +def _record_items(conn, run_id, seen_at, collected, excluded): + """Upserts this run's items and annotates them in place with what the archive + already knew. The annotation is the whole point: every entry the prompt sees + carries `first_seen_at` and `times_seen`, so "new this run" and "the same story + for four days" are distinguishable without the model having to infer it.""" + known = 0 + for source in ITEM_SOURCES: + if source in excluded: + continue + for item in collected.get(source) or []: + if not isinstance(item, dict): + continue + fingerprint = _fingerprint(source, item) + row = conn.execute( + "SELECT first_seen_at, times_seen FROM items WHERE fingerprint = ?", + (fingerprint,), + ).fetchone() + + if row: + times_seen = row["times_seen"] + 1 + conn.execute( + "UPDATE items SET last_seen_at = ?, last_run_id = ?, times_seen = ? " + "WHERE fingerprint = ?", + (seen_at, run_id, times_seen, fingerprint), + ) + item["first_seen_at"] = row["first_seen_at"] + item["times_seen"] = times_seen + known += 1 + else: + conn.execute( + "INSERT INTO items (fingerprint, source, category, occurred_at, " + "first_seen_at, last_seen_at, times_seen, first_run_id, last_run_id, " + "title, url, payload) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)", + ( + fingerprint, + source, + item.get("category"), + item.get("timestamp") or item.get("start"), + seen_at, + seen_at, + run_id, + run_id, + _item_title(item), + item.get("url") or item.get("link"), + json.dumps(item, ensure_ascii=False, default=str), + ), + ) + item["first_seen_at"] = seen_at + item["times_seen"] = 1 + return known + + +def _observation_rows(collected): + """The numeric series worth trending, flattened out of this run's summaries. + + Each source's shape is different and each is handled explicitly rather than by + a generic "find the numbers" walk — a wrong series is worse than a missing one, + because it would be trended and reported with a straight face. + """ + rows = [] + + for entry in collected.get("financial") or []: + if not isinstance(entry, dict): + continue + if entry.get("source") == "fred" and entry.get("series_id"): + try: + rows.append(( + f"fred:{entry['series_id']}", entry["series_id"], "value", + float(entry["latest_value"]), str(entry.get("latest_date") or ""), + )) + except (TypeError, ValueError): + pass + elif entry.get("source") == "stooq" and entry.get("symbol"): + try: + rows.append(( + f"stooq:{entry['symbol']}", entry["symbol"], "close", + float(entry["close"]), str(entry.get("date") or ""), + )) + except (TypeError, ValueError): + pass + + for entry in collected.get("flight_traffic") or []: + if not isinstance(entry, dict) or not entry.get("region"): + continue + observed = str(entry.get("observed_at") or "") + region = entry["region"] + rows.append((f"flight:{region}", region, "aircraft_total", + float(entry.get("aircraft_total") or 0), observed)) + rows.append((f"flight:{region}", region, "military_callsign_matches", + float(entry.get("military_callsign_match_count") or 0), observed)) + + for entry in collected.get("naval_traffic") or []: + if not isinstance(entry, dict) or not entry.get("region"): + continue + region = entry["region"] + rows.append((f"naval:{region}", region, "distinct_vessels", + float(entry.get("distinct_vessels") or 0), + str(entry.get("observed_at") or ""))) + + for entry in collected.get("opnsense_ids") or []: + if not isinstance(entry, dict): + continue + observed = str(entry.get("observed_at") or "") + rows.append(("ids:total", "IDS alerts", "alerts", + float(entry.get("alert_count") or 0), observed)) + for signature in entry.get("top_signatures") or []: + if not isinstance(signature, dict) or signature.get("sid") is None: + continue + rows.append(( + f"ids:signature:{signature['sid']}", + str(signature.get("signature") or signature["sid"])[:300], + "alerts", float(signature.get("count") or 0), observed, + )) + for host in entry.get("top_local_hosts") or []: + if isinstance(host, dict) and host.get("ip"): + rows.append((f"ids:host:{host['ip']}", host["ip"], "alerts", + float(host.get("alerts") or 0), observed)) + + return [row for row in rows if row[4]] + + +def _prune(conn, retention_days): + cutoff = (datetime.now(timezone.utc) - timedelta(days=retention_days)).replace( + microsecond=0).isoformat().replace("+00:00", "Z") + conn.execute("DELETE FROM items WHERE last_seen_at < ?", (cutoff,)) + conn.execute("DELETE FROM observations WHERE observed_at < ?", (cutoff,)) + + +def record(run_id, collected): + """Writes this run into the archive and annotates `collected` in place with what + was already known. Returns False if the archive is unavailable, in which case the + run simply proceeds without memory.""" + if os.environ.get("ENABLE_DIGEST_ARCHIVE", "true").strip().lower() != "true": + LOG.info("archive: disabled, this run will neither read nor write history") + return False + + excluded = _excluded_sources() + if excluded: + LOG.info("archive: not storing %s", ", ".join(sorted(excluded))) + + seen_at = _now_iso() + try: + retention_days = int(os.environ.get("DIGEST_ARCHIVE_RETENTION_DAYS", DEFAULT_RETENTION_DAYS)) + except ValueError: + retention_days = DEFAULT_RETENTION_DAYS + + try: + with _connect() as conn: + known = _record_items(conn, run_id, seen_at, collected, excluded) + observations = [ + row for row in _observation_rows(collected) + if row[0].split(":", 1)[0] not in excluded + ] + for series, label, metric, value, observed_at in observations: + conn.execute( + "INSERT OR IGNORE INTO observations " + "(series, label, metric, value, observed_at, run_id) VALUES (?, ?, ?, ?, ?, ?)", + (series, label, metric, value, observed_at, run_id), + ) + _prune(conn, retention_days) + LOG.info( + "archive: recorded run %s (%d item(s) already known, %d observation(s))", + run_id, known, len(observations), + ) + return True + except Exception: + LOG.warning("archive: could not record this run, continuing without it", exc_info=True) + return False + + +def _series_history(conn, prefixes, points=MAX_SERIES_POINTS): + series = {} + for prefix in prefixes: + rows = conn.execute( + "SELECT series, label, metric, value, observed_at FROM observations " + "WHERE series LIKE ? ORDER BY observed_at DESC LIMIT ?", + (f"{prefix}%", points * 12), + ).fetchall() + for row in rows: + key = f"{row['series']}|{row['metric']}" + entry = series.setdefault(key, { + "series": row["series"], "label": row["label"], + "metric": row["metric"], "readings": [], + }) + if len(entry["readings"]) < points: + entry["readings"].append({"at": row["observed_at"], "value": row["value"]}) + # Oldest-first reads like a series rather than a stack. + for entry in series.values(): + entry["readings"].reverse() + return sorted(series.values(), key=lambda entry: entry["series"]) + + +def _span_days(conn): + row = conn.execute( + "SELECT MIN(first_seen_at) AS oldest FROM items" + ).fetchone() + if not row or not row["oldest"]: + return 0 + try: + oldest = datetime.fromisoformat(str(row["oldest"]).replace("Z", "+00:00")) + except ValueError: + return 0 + return max(0, (datetime.now(timezone.utc) - oldest).days) + + +def history(sections): + """The history blocks for the sections this run is generating. + + Deliberately shaped per section rather than handed over whole: the network + prompt has no use for share prices, and every token of history in a prompt is a + token not spent on this run's actual material. + """ + if os.environ.get("ENABLE_DIGEST_ARCHIVE", "true").strip().lower() != "true": + return {} + + try: + with _connect() as conn: + span = _span_days(conn) + blocks = {} + + if "political" in sections: + blocks["political"] = { + "archive_span_days": span, + "note": ( + "Earlier readings of the same series, oldest first, each with its " + "own timestamp. This is the only basis on which this section may " + "describe anything as rising, falling or unchanged." + ), + "series": _series_history(conn, ("fred:", "stooq:", "flight:", "naval:")), + } + + if "network" in sections: + # Recurrence is the whole question for an IDS: one alert is noise, the + # same signature every night on the same host is a fact about the house. + signatures = conn.execute( + "SELECT series, label, SUM(value) AS alerts, COUNT(*) AS runs_seen, " + "MIN(observed_at) AS first_seen, MAX(observed_at) AS last_seen " + "FROM observations WHERE series LIKE 'ids:signature:%' " + "GROUP BY series ORDER BY alerts DESC LIMIT 12" + ).fetchall() + hosts = conn.execute( + "SELECT label AS host, SUM(value) AS alerts, COUNT(*) AS runs_seen, " + "MIN(observed_at) AS first_seen, MAX(observed_at) AS last_seen " + "FROM observations WHERE series LIKE 'ids:host:%' " + "GROUP BY series ORDER BY alerts DESC LIMIT 10" + ).fetchall() + blocks["network"] = { + "archive_span_days": span, + "note": ( + "Alert history across every run in the archive, not just this " + "window. `runs_seen` is how many digest runs this has appeared in " + "— that is what tells a one-off apart from something recurring." + ), + "alert_totals": _series_history(conn, ("ids:total",), points=30), + "recurring_signatures": [dict(row) for row in signatures], + "recurring_hosts": [dict(row) for row in hosts], + } + + return blocks + except Exception: + LOG.warning("archive: could not read history, continuing without it", exc_info=True) + return {} diff --git a/digest-engine/digest-engine.env.example b/digest-engine/digest-engine.env.example index c9385b6..5bebcd0 100644 --- a/digest-engine/digest-engine.env.example +++ b/digest-engine/digest-engine.env.example @@ -32,6 +32,85 @@ DIGEST_EVENING_HOUR=18 # feature can be checked without waiting for 18:00. Leave false in production. DIGEST_FORCE_EVENING=false +# --------------------------------------------------------------------------- +# Who wants which digest — read from identity (Phase 6) at the start of a run. +# Each person ticks their sections (network / household / social / political) in +# identity's admin panel; this run generates the union of what the household +# asked for, and a section nobody wants costs no LLM call and no ingestion. +# +# Leave IDENTITY_URL blank to skip the lookup entirely and always generate all +# four — which is also what happens if identity is unreachable or the token is +# wrong. Failing that way round is deliberate: one unreachable container must +# never silently cost the household its whole digest. See preferences.py. +# +# identity is on the same compose network, so the container name resolves; the +# port is `ports.identity` in CoreSystemConfig.json (8097 by default). The token +# is the SAME value as identity.env's IDENTITY_TOKEN (`secrets.identity_token` +# in CoreSystemConfig.json) — it is a shared secret between the two, exactly +# like the one the door panel and kitchen display are built with. +# --------------------------------------------------------------------------- +IDENTITY_URL=http://identity:8097 +IDENTITY_TOKEN= +IDENTITY_TIMEOUT=10 + +# --------------------------------------------------------------------------- +# "Your digest is ready" — a push to the self-hosted ntfy this stack already runs +# for chores and identity (notify.py). Costs no extra LLM call: the summary is +# assembled from each section's own compact narration, plus the political to-do +# count and any withheld/unverified marks, so it can never claim something the +# digest itself doesn't say. +# +# WHO GETS WHAT: people are grouped by their own ntfy topic from identity (the +# same one arrival notifications use), and each topic gets only the sections the +# people behind it actually asked for — a person who switched the political +# section off gets no political content on their phone either. NTFY_TOPIC is the +# household fallback for anyone with no topic of their own, and the single +# destination when identity is unreachable. +# +# On Android the notification relays to a Pebble or any other watch with nothing +# else installed. ntfy stays LAN-only; away from home it is reached over the +# WireGuard split tunnel (docs/network-integration.md §2.2), never a port forward. +# +# DIGEST_WEB_URL makes the notification tappable, opening that person's own +# digest. Leave it blank for a push with no link. +# --------------------------------------------------------------------------- +ENABLE_DIGEST_NOTIFY=true +NTFY_URL=http://ntfy +NTFY_TOPIC=digest +DIGEST_WEB_URL= + +# --------------------------------------------------------------------------- +# The archive — the digest's long memory (archive.py). Every ingested item and +# every measured number is kept in a SQLite database in the same /data volume, +# so a run can say "up from 5.1% in June", "this signature has fired every night +# for a week", or "this story first appeared on Monday" instead of only ever +# seeing the last six hours. +# +# READ THIS BEFORE LEAVING IT ON: it persists your mail, messages and calendar +# entries for the whole retention window, where previously only the last few +# runs' context.json did. Nothing leaves the host, and the file sits next to the +# Telegram session on the same volume — but it is a real change in how long +# personal content is kept. DIGEST_ARCHIVE_EXCLUDE_SOURCES takes a comma- +# separated list of ingest keys (email, signal, telegram, discord, whatsapp, +# news, rci_social, calendar, grocy) to keep out of it entirely; the trend +# features for financial, traffic and IDS data work regardless of what you +# exclude here. +# --------------------------------------------------------------------------- +ENABLE_DIGEST_ARCHIVE=true +DIGEST_ARCHIVE_DB_PATH=/data/digest-archive.db +DIGEST_ARCHIVE_RETENTION_DAYS=400 +DIGEST_ARCHIVE_EXCLUDE_SOURCES= + +# --------------------------------------------------------------------------- +# Meeting agendas (agenda.py) — a "Tagesordnung" PDF arriving by mail or +# WhatsApp is attached to the calendar event it belongs to. Comma-separated +# keywords matched case-insensitively in a file name, subject or message; the +# bare abbreviations TO and TOP are always recognised too, but only as +# standalone tokens in a file name or subject (matching "to" in body text would +# flag half your mail). The document itself is never opened — only its name. +# --------------------------------------------------------------------------- +AGENDA_KEYWORDS=tagesordnung,traktanden,traktandenliste,agenda,einladung + # --------------------------------------------------------------------------- # LLM synthesis — the existing Phase 3 Ollama host # --------------------------------------------------------------------------- @@ -78,6 +157,18 @@ ENABLE_DISCORD_INGEST=false # even with the headful-Chromium mitigation. Use a secondary/non-critical number. ENABLE_WHATSAPP_INGEST=false ENABLE_NEWS_INGEST=true +# How many entries each feed in curated-feeds.opml may contribute to one prompt. +# That file ships around three dozen feeds, so this multiplies: 5 x 40 feeds is +# already a large context for a 14B model. Raise it only with a bigger model. +NEWS_MAX_ENTRIES_PER_FEED=5 +# The RCI's and your own section's social/media output (YouTube, podcast RSS, a +# public Telegram channel), listed in feeds/rci-social.json — no credentials +# needed for the RSS-based ones. Feeds the political section as "theory_social", +# and new episodes become its watch-later window. Telegram entries reuse the +# session TELEGRAM_* below already sets up, and are skipped without it. +ENABLE_RCI_SOCIAL_INGEST=true +RCI_SOCIAL_CONF_PATH=/app/feeds/rci-social.json +RCI_SOCIAL_MAX_PER_SOURCE=10 ENABLE_FINANCIAL_INGEST=true # Off by default on purpose — read the OpenSky terms-of-use note further down # before turning this on, it is a licensing decision, not a technical one. diff --git a/digest-engine/feeds/curated-feeds.opml b/digest-engine/feeds/curated-feeds.opml index 17007df..59adbb2 100644 --- a/digest-engine/feeds/curated-feeds.opml +++ b/digest-engine/feeds/curated-feeds.opml @@ -2,15 +2,33 @@ @@ -19,148 +37,433 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text="RT (Russia)" + title="RT (Russia)" + category="news_state_affiliated" + owner="ANO TV-Novosti, funded by the Russian state" + bias="The outlet of the Russian capitalist class and its state. Not 'the other side' — Russian capital's own imperialism is the one thing it will not report. Its critiques of NATO can be materially accurate and are made in the interests of a rival bourgeoisie" + xmlUrl="https://www.rt.com/rss/" + htmlUrl="https://www.rt.com/"/> + text="TASS (Russia)" + title="TASS (Russia)" + category="news_state_affiliated" + owner="Russian state news agency" + bias="The Russian state's wire — read for what Moscow is announcing, not for what happened" + xmlUrl="https://tass.com/rss/v2.xml" + htmlUrl="https://tass.com/"/> + text="Global Times (China)" + title="Global Times (China)" + category="news_state_affiliated" + owner="People's Daily, i.e. the Communist Party of China" + bias="Chinese state, nationalist register. China's own capital export and its treatment of Chinese workers are the subjects it does not cover" + xmlUrl="https://www.globaltimes.cn/rss/outbrain.xml" + htmlUrl="https://www.globaltimes.cn/"/> + + + + + + + owner="Alibaba Group" + bias="Hong Kong paper owned by Chinese capital; better sourced on China than the Western wires, constrained on anything Beijing treats as settled" + xmlUrl="https://www.scmp.com/rss/91/feed" + htmlUrl="https://www.scmp.com/"/> + + + + + + + + + + - + text="VOL.AT (Vorarlberg)" + title="VOL.AT (Vorarlberg)" + category="news" + owner="Russmedia, Vorarlberg private media group" + bias="Regional bourgeois press; local employers, local politics, no national line to speak of" + xmlUrl="https://www.vol.at/rss" + htmlUrl="https://www.vol.at/"/> diff --git a/digest-engine/feeds/rci-social.json b/digest-engine/feeds/rci-social.json new file mode 100644 index 0000000..52e1817 --- /dev/null +++ b/digest-engine/feeds/rci-social.json @@ -0,0 +1,95 @@ +{ + "_readme": [ + "Social-media accounts read by digest-engine/ingest/rci_social.py and fed to the", + "political section as category=\"theory_social\". JSON has no comment syntax, so", + "the documentation lives in these underscore-prefixed keys; the module ignores", + "every key it does not know about.", + "", + "THIS FILE IS MEANT TO BE EDITED — same as feeds/curated-feeds.opml, and unlike", + "IDSconf.json it holds no credentials, so it is committed as-is. Set", + "ENABLE_RCI_SOCIAL_INGEST=true in digest-engine.env to switch it on.", + "", + "Per entry:", + " platform 'youtube' | 'rss' | 'telegram'", + " name what the digest calls the account", + " scope 'section' (your own national section) or 'international' (the RCI", + " as a whole). Passed to the prompt, which is told not to merge the", + " two voices — 'my branch is doing X' is not 'the International says X'.", + " enabled set false to keep an entry here without fetching it", + "", + " content_type 'episode' (something to watch or listen to later) or 'post'", + " (something said now). Episodes are collected into the political", + " section's own watch-later window instead of competing with the", + " analysis. Defaults to 'episode' for youtube, 'post' otherwise.", + "", + " youtube needs channel_id, the opaque UC... id — YouTube's feed endpoint does", + " not accept an @handle. Find it by opening the channel and reading", + " 'externalId' in View Source, or from a /channel/UC... URL.", + " rss needs url. Works for a Mastodon account (https:///@user.rss),", + " a podcast feed, or any site feed.", + " telegram needs channel, the public @username without the @. Read through the", + " SAME Telethon session ingest/telegram_ingest.py uses — nothing joins", + " or subscribes to the channel, and without that session these entries", + " are skipped while the rest still run.", + "", + "NOT SUPPORTED, ON PURPOSE: Instagram, Facebook, WhatsApp channels, TikTok and", + "X/Twitter. None has a keyless read path that stays inside its own terms, and", + "this component does not scrape. The module docstring says what each of them", + "would actually require. Instagram is the real gap — it is where the section", + "posts most, and reading it here would mean scraping it." + ], + + "sources": [ + { + "_verified": "Feed fetched and confirmed live 2026-08-06 (channel 'RKP – Revolutionäre Kommunistische Partei').", + "platform": "youtube", + "name": "RKP Österreich (YouTube)", + "scope": "section", + "channel_id": "UCMXItw4ipjk2CigCHuXHqlw" + }, + { + "_verified": "Feed fetched and confirmed live 2026-08-06 (channel 'Revolutionary Communist International').", + "platform": "youtube", + "name": "RCI (YouTube)", + "scope": "international", + "channel_id": "UCtO2zDQt-AhF6SwYbXDdFKA" + }, + { + "_verified": [ + "Linked from marxist.com's own footer (checked 2026-08-06). NOT fetched during", + "verification — reading it needs the Telegram session, which only exists on the", + "real deployment. If the channel name is wrong this entry logs a warning and", + "costs nothing else." + ], + "platform": "telegram", + "name": "marxist.com (Telegram)", + "scope": "international", + "channel": "marxistcom" + }, + { + "_verified": [ + "Feed fetched and confirmed live 2026-08-06 ('Der Funke - die kommunistische", + "Stimme'). This is the RSS behind anchor.fm/derfunkeat — the section's own", + "podcast, and the audio half of the watch-later list." + ], + "platform": "rss", + "name": "Der Funke — Podcast", + "scope": "section", + "content_type": "episode", + "url": "https://anchor.fm/s/1b913a00/podcast/rss" + }, + { + "_example": [ + "Template for a Mastodon (or any other) feed — off until you fill in a real", + "URL. marxist.com itself needs no entry here: its podcast episodes are posted", + "as articles prefixed '[Podcast]' and already arrive through", + "feeds/curated-feeds.opml's category=\"theory\" feed (checked 2026-08-06)." + ], + "platform": "rss", + "name": "Example — a Mastodon account", + "scope": "section", + "url": "https://instance.example/@account.rss", + "enabled": false + } + ] +} diff --git a/digest-engine/ingest/email_imap.py b/digest-engine/ingest/email_imap.py index ff6c02a..b2b0ef1 100644 --- a/digest-engine/ingest/email_imap.py +++ b/digest-engine/ingest/email_imap.py @@ -10,15 +10,32 @@ is the documented, supported path for exactly this case. import email import email.utils +import hashlib import logging import os +import re from datetime import datetime, timedelta, timezone from email.header import decode_header, make_header +from pathlib import Path LOG = logging.getLogger(__name__) MAX_BODY_CHARS = 2000 +# Where document attachments are spooled for agenda.py to read. Inside the digest's +# own /data volume, alongside the Telegram session and the archive database. +DEFAULT_ATTACHMENT_DIR = "/data/attachments" + +DOCUMENT_SUFFIXES = (".pdf", ".doc", ".docx", ".odt", ".rtf", ".txt", ".md") +DOCUMENT_TYPES = { + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.oasis.opendocument.text", + "application/rtf", + "text/plain", +} + def _decode(value): if not value: @@ -45,6 +62,66 @@ def _body_text(message): return payload.decode(message.get_content_charset() or "utf-8", "replace") +def _safe_name(filename, fallback): + """A filename that cannot escape the spool directory. Takes the basename, keeps + only characters that are unambiguously safe, and prefixes a short hash of the + original so two "Tagesordnung.pdf"s from different senders can coexist.""" + base = os.path.basename(filename or "").strip() or fallback + cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", base)[:80].lstrip(".") or fallback + digest = hashlib.sha256((filename or fallback).encode("utf-8")).hexdigest()[:8] + return f"{digest}-{cleaned}" + + +def _attachments(message, uid): + """Attachment metadata, plus the file itself for documents. + + Documents are written to DIGEST_ATTACHMENT_DIR so `agenda.py` can read a meeting + agenda's text out of them. Everything else (images, calendar invites, the usual + signature logos) is recorded by name and type only — this is a digest, not a + mail archive, and there is no reason to spool a 4 MB photo to disk. + + This writes to the digest's own data volume, exactly as `context.json` already + does. It is not a write against the mailbox: the message is fetched read-only, + the folder stays unread, and nothing is flagged, moved or deleted. + """ + if not message.is_multipart(): + return [] + + spool = Path(os.environ.get("DIGEST_ATTACHMENT_DIR", DEFAULT_ATTACHMENT_DIR)) + max_bytes = int(os.environ.get("EMAIL_MAX_ATTACHMENT_MB", "10")) * 1024 * 1024 + save_documents = os.environ.get("EMAIL_SAVE_DOCUMENTS", "true").strip().lower() == "true" + + found = [] + for index, part in enumerate(message.walk()): + filename = part.get_filename() + disposition = str(part.get("Content-Disposition", "")) + if not filename and "attachment" not in disposition: + continue + + name = _decode(filename) if filename else "" + content_type = part.get_content_type() + entry = {"filename": name, "content_type": content_type, "path": None} + + is_document = name.lower().endswith(DOCUMENT_SUFFIXES) or content_type in DOCUMENT_TYPES + if save_documents and is_document: + try: + payload = part.get_payload(decode=True) or b"" + if len(payload) > max_bytes: + LOG.info("email: attachment %r is %d bytes, not spooling it", name, len(payload)) + else: + spool.mkdir(parents=True, exist_ok=True) + target = spool / _safe_name(name, f"uid{uid}-part{index}") + target.write_bytes(payload) + entry["path"] = str(target) + entry["bytes"] = len(payload) + except Exception: + # A document that cannot be spooled is still worth reporting by name. + LOG.warning("email: could not save attachment %r", name, exc_info=True) + + found.append(entry) + return found + + def fetch(lookback_hours): host = os.environ.get("EMAIL_IMAP_HOST", "").strip() user = os.environ.get("EMAIL_USERNAME", "").strip() @@ -94,6 +171,7 @@ def fetch(lookback_hours): "subject": _decode(parsed.get("Subject")), "timestamp": sent_at.isoformat() if sent_at else None, "body": _body_text(parsed).strip()[:MAX_BODY_CHARS], + "attachments": _attachments(parsed, uid), } ) except Exception: diff --git a/digest-engine/ingest/news_rss.py b/digest-engine/ingest/news_rss.py index fae89c6..eb91520 100644 --- a/digest-engine/ingest/news_rss.py +++ b/digest-engine/ingest/news_rss.py @@ -5,6 +5,13 @@ The feed list is user-editable at digest-engine/feeds/curated-feeds.opml; each OPML with `category="theory"` and that category is carried through onto every entry, because the political prompt treats it as the analytical basis rather than as one more headline source. + +Three OPML attributes are passed through onto every entry: `category`, `owner` +and `bias`. The last two are what let the political prompt read a story against +who paid for it instead of treating "the news" as a single undifferentiated +input — see the source-criticism section of synth/prompts/political.md. They are +free text and are never parsed here; this module carries them, it does not +interpret them. """ import calendar @@ -39,6 +46,15 @@ def _read_opml(path): "url": url, "title": outline.get("title") or outline.get("text") or url, "category": outline.get("category") or "news", + # Who owns the outlet and where it sits politically, both free text, + # both carried onto every entry. The political prompt is told to read + # each item against them rather than against a "reliable/unreliable" + # ranking: no outlet in this file is neutral, and which fraction of + # capital (or which capitalist state) pays for one is a fact about the + # reporting, not a footnote. Missing attributes just arrive as null — + # the prompt handles that as "ownership not recorded here". + "owner": outline.get("owner"), + "bias": outline.get("bias"), } ) return feeds @@ -94,6 +110,8 @@ def fetch(lookback_hours): "source": "news", "feed": feed["title"], "category": feed["category"], + "owner": feed["owner"], + "bias": feed["bias"], "title": entry.get("title", "").strip(), "link": entry.get("link", ""), "timestamp": published.isoformat() if published else None, diff --git a/digest-engine/ingest/rci_social.py b/digest-engine/ingest/rci_social.py new file mode 100644 index 0000000..5011d6d --- /dev/null +++ b/digest-engine/ingest/rci_social.py @@ -0,0 +1,285 @@ +"""RCI social-media ingestion — the organisation's own public output. + +Feeds the **political** section alongside the RCI/Der Funke RSS already in +feeds/curated-feeds.opml, tagged `"category": "theory_social"` so +synth/prompts/political.md can tell the section's agitational and organisational +posts ("come to this meeting", "watch this explainer") apart from its written +analysis, which stays tagged `"theory"`. Sources are listed in the committed, +user-editable feeds/rci-social.json. + +WHAT IS REACHABLE WITHOUT SCRAPING, AND WHAT IS NOT +--------------------------------------------------- +Only platforms with a public, keyless, first-party read path are implemented. +That is a hard line, not a to-do list: + + - **YouTube** — `https://www.youtube.com/feeds/videos.xml?channel_id=UC...` + is YouTube's own Atom feed, no key and no quota. Verified live against both + configured channels 2026-08-06. + - **Any RSS/Atom feed** — a Mastodon account's `.rss`, a podcast feed, a + section's own site. Same `feedparser` this project already vendors for news. + - **Telegram** — read through the Telethon session `telegram_ingest.py` + already logs in with. A public channel resolves by username and its history + reads **without joining it**: no `JoinChannelRequest`, no + `send_read_acknowledge()`, nothing written anywhere. If that session does + not exist, these entries are skipped and the RSS ones still run. + +Deliberately NOT built, because none of them has a read path that is both +keyless and within the platform's terms — and this component's whole premise is +that it never scrapes and never logs in as a person to a platform that forbids +it (the one exception, WhatsApp, is opt-in and carries its own ban warning in +README.md): + + - **Instagram** (`@rkp_austria`, `@revcomintern`) — the Basic Display API was + retired in December 2024, and the Graph API only reads accounts you own, + through a reviewed Meta app. Reading somebody else's public account means + scraping, which is both against Instagram's terms and the fastest way to get + an account or an IP blocked. This is the biggest real gap in this module: + Instagram is where the section posts most. + - **Facebook** (`/derfunke.at`) — page RSS was killed in 2018; Page Public + Content Access needs Meta app review for a business use case this is not. + - **WhatsApp channel** — no API of any kind. The existing `whatsapp-bridge` + sidecar is a linked *personal* device and its library's channel support is + experimental; wiring it up would extend that sidecar's ban risk to a feature + that can be had by reading the website instead. + - **X/Twitter** (`@revcomintern`) — the free API tier is write-oriented and + reads essentially nothing; Nitter instances are gone. + +If you want those, the honest answer is to follow the accounts on your phone. +Do not "solve" it here by pointing this module at a scraping proxy: that moves +the terms-of-service problem onto a third party without removing it. +""" + +import html +import json +import logging +import os +import re +from datetime import datetime, timedelta, timezone + +LOG = logging.getLogger(__name__) + +DEFAULT_CONF_PATH = "/app/feeds/rci-social.json" + +# YouTube's own feed endpoint. Documented and keyless, but it takes the opaque +# channel id (`UC...`) only — an `@handle` is not accepted, so the config file +# holds ids and says where to find them. +YOUTUBE_FEED_URL = "https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}" + +MAX_BODY_CHARS = 1200 +DEFAULT_MAX_PER_SOURCE = 10 + +# What a source publishes, unless its config entry says otherwise. A YouTube channel +# is episodes by definition; a plain feed could be either (a podcast is episodes, a +# Mastodon account is posts), so it defaults to the less presumptuous of the two. +DEFAULT_CONTENT_TYPES = {"youtube": "episode", "rss": "post", "telegram": "post"} + +_TAG_RE = re.compile(r"<[^>]+>") + + +# Deliberately duplicated from news_rss.py rather than shared: every module in +# this package stands on its own so that one source's breakage can never reach +# another's, and a shared helpers module would be a single import for all of them +# to fail on. Same trade counter_run.py makes against llm_client.py. +def _plain_text(markup): + return html.unescape(_TAG_RE.sub(" ", markup or "")).strip() + + +def _entry_time(entry): + parsed = entry.get("published_parsed") or entry.get("updated_parsed") + if not parsed: + return None + import calendar + + return datetime.fromtimestamp(calendar.timegm(parsed), tz=timezone.utc) + + +def _load_sources(path): + try: + with open(path, encoding="utf-8") as handle: + config = json.load(handle) + except FileNotFoundError: + LOG.warning("rci_social: %s not found, skipping", path) + return [] + except Exception: + LOG.warning("rci_social: %s could not be parsed, skipping", path, exc_info=True) + return [] + + sources = [] + for entry in config.get("sources") or []: + if not isinstance(entry, dict) or entry.get("enabled") is False: + continue + platform = str(entry.get("platform") or "").strip().lower() + if platform not in ("rss", "youtube", "telegram"): + LOG.warning("rci_social: unknown platform %r, skipping that entry", platform) + continue + sources.append( + { + "platform": platform, + "name": str(entry.get("name") or "").strip() or platform, + # "episode" (something to watch or listen to later) vs "post" + # (something said now). The political prompt keeps episodes out of + # the analysis and puts them in their own watch-later window, so a + # 40-minute video never competes with a strike for space. + "content_type": str(entry.get("content_type") or DEFAULT_CONTENT_TYPES.get(platform, "post")).strip().lower(), + # `scope` is passed straight through to the prompt: "section" is the + # user's own national section, "international" the RCI as a whole. + # The two are not the same voice and the digest should not merge them. + "scope": str(entry.get("scope") or "section").strip().lower(), + "url": str(entry.get("url") or "").strip(), + "channel_id": str(entry.get("channel_id") or "").strip(), + "channel": str(entry.get("channel") or "").strip().lstrip("@"), + } + ) + return sources + + +def _item(source, title, body, url, timestamp): + return { + "source": "rci_social", + # Not "theory": these are posts, not the written analysis the political + # prompt reasons from. See that prompt's "The organisation's own social + # media" section for the difference it is told to keep. + "category": "theory_social", + "platform": source["platform"], + "account": source["name"], + "scope": source["scope"], + "content_type": source["content_type"], + "title": title, + "url": url, + "timestamp": timestamp, + "body": (body or "")[:MAX_BODY_CHARS], + } + + +def _fetch_feed(source, since, max_per_source): + import feedparser + + url = source["url"] + if source["platform"] == "youtube": + if not source["channel_id"]: + LOG.warning("rci_social: %s has no channel_id, skipping", source["name"]) + return [] + url = YOUTUBE_FEED_URL.format(channel_id=source["channel_id"]) + if not url: + LOG.warning("rci_social: %s has no url, skipping", source["name"]) + return [] + + parsed = feedparser.parse(url) + items = [] + for entry in parsed.entries[:max_per_source]: + published = _entry_time(entry) + if published is None or published < since: + continue + item = _item( + source, + _plain_text(entry.get("title")), + _plain_text(entry.get("summary") or entry.get("description")), + entry.get("link") or url, + published.isoformat(), + ) + # Podcast feeds carry ; YouTube's Atom feed does not. Passed + # through when it exists because "is this 8 minutes or 90" is most of what + # decides whether something makes it onto a watch-later list. + duration = entry.get("itunes_duration") + if duration: + item["duration"] = str(duration) + items.append(item) + return items + + +async def _fetch_telegram_async(sources, since, api_id, api_hash, session_path, max_per_source): + from telethon import TelegramClient + + items = [] + client = TelegramClient(session_path, api_id, api_hash) + await client.connect() + try: + if not await client.is_user_authorized(): + LOG.warning( + "rci_social: telegram session at %s is not authorized — run " + "`python ingest/telegram_login.py` once; skipping the telegram sources", + session_path, + ) + return [] + + for source in sources: + try: + # Resolving a public channel by username is a read. Nothing here + # joins it, subscribes to it, or marks anything read — same + # invariant as telegram_ingest.py, which never calls + # send_read_acknowledge() either. + entity = await client.get_entity(source["channel"]) + async for message in client.iter_messages(entity, limit=max_per_source): + if message.date is None or message.date < since: + break + if not message.message: + continue + items.append( + _item( + source, + "", + message.message, + f"https://t.me/{source['channel']}/{message.id}", + message.date.isoformat(), + ) + ) + except Exception: + LOG.warning( + "rci_social: could not read telegram channel %r, skipping", + source["channel"], + exc_info=True, + ) + finally: + await client.disconnect() + + return items + + +def _fetch_telegram(sources, since, max_per_source): + api_id = os.environ.get("TELEGRAM_API_ID", "").strip() + api_hash = os.environ.get("TELEGRAM_API_HASH", "").strip() + session_path = os.environ.get("TELEGRAM_SESSION_PATH", "/data/telegram.session") + + # The same credentials and session file telegram_ingest.py uses. A household + # that never set Telegram up simply doesn't get these sources; it is not an + # error and must not cost the RSS ones. + if not (api_id and api_hash and os.path.exists(session_path)): + LOG.info("rci_social: no usable telegram session, skipping the telegram sources") + return [] + + import asyncio + + return asyncio.run( + _fetch_telegram_async(sources, since, int(api_id), api_hash, session_path, max_per_source) + ) + + +def fetch(lookback_hours): + sources = _load_sources(os.environ.get("RCI_SOCIAL_CONF_PATH", DEFAULT_CONF_PATH)) + if not sources: + return [] + + max_per_source = int(os.environ.get("RCI_SOCIAL_MAX_PER_SOURCE", DEFAULT_MAX_PER_SOURCE)) + since = datetime.now(timezone.utc) - timedelta(hours=float(lookback_hours)) + + items = [] + for source in sources: + if source["platform"] == "telegram": + continue + try: + items.extend(_fetch_feed(source, since, max_per_source)) + except Exception: + # One dead account never costs the others, exactly like a dead feed in + # news_rss.py — a section that renamed a channel shouldn't silence the rest. + LOG.warning("rci_social: %s failed, skipping it", source["name"], exc_info=True) + + telegram_sources = [source for source in sources if source["platform"] == "telegram"] + if telegram_sources: + try: + items.extend(_fetch_telegram(telegram_sources, since, max_per_source)) + except Exception: + LOG.warning("rci_social: telegram ingestion failed, skipping it", exc_info=True) + + items.sort(key=lambda item: item["timestamp"], reverse=True) + LOG.info("rci_social: %d post(s) in the last %sh", len(items), lookback_hours) + return items diff --git a/digest-engine/ingest/whatsapp_ingest.py b/digest-engine/ingest/whatsapp_ingest.py index 6e20ba1..de9591d 100644 --- a/digest-engine/ingest/whatsapp_ingest.py +++ b/digest-engine/ingest/whatsapp_ingest.py @@ -22,6 +22,7 @@ import json import logging import os from datetime import datetime, timedelta, timezone +from pathlib import Path LOG = logging.getLogger(__name__) @@ -46,6 +47,11 @@ def fetch(lookback_hours): LOG.warning("whatsapp: could not claim %s for reading, skipping", messages_path, exc_info=True) return [] + # The bridge writes documents next to its message file, in its own /data. Derived + # from the message path rather than configured separately, so the two can never + # be pointed at different places by half-updating the environment. + documents_dir = Path(messages_path).parent / "documents" + since = datetime.now(timezone.utc) - timedelta(hours=lookback_hours) messages = [] @@ -70,6 +76,24 @@ def fetch(lookback_hours): "chat": record.get("chat"), "timestamp": sent_at.isoformat() if sent_at else None, "body": (record.get("body") or "")[:MAX_BODY_CHARS], + # Shaped like email's `attachments` so agenda.py can treat + # both the same way. `path` is filled only for documents + # the bridge actually saved; photos and video are recorded + # by type and never downloaded. Older bridge lines predate + # these fields and simply carry no attachment. + "attachments": ( + [{ + "filename": record.get("filename") or "", + "content_type": record.get("mimetype") or record.get("type") or "", + "path": ( + str(documents_dir / record["document_file"]) + if record.get("document_file") + else None + ), + }] + if record.get("has_media") + else [] + ), } ) except Exception: diff --git a/digest-engine/notify.py b/digest-engine/notify.py new file mode 100644 index 0000000..70443ae --- /dev/null +++ b/digest-engine/notify.py @@ -0,0 +1,249 @@ +"""'Your digest is ready' — a push with enough of the digest to judge it by. + +The digest is generated four times a day and shown only when somebody asks for it +— a spoken "play my digest", or the button in Home Assistant. Nothing displays it +because a person walked past a screen. That makes the notification the only thing +that reaches you unprompted, and its job is exactly one decision: is this run +worth going and asking for, or does it keep until tonight? + +So it pushes a short summary to ntfy after every run, readable on a phone or a +wrist (Android relays notifications to a Pebble with nothing else installed), +without opening the canvas at all. + +WHAT IT SENDS, AND WHY IT COSTS NO EXTRA LLM CALL +------------------------------------------------- +Every section already produces a `narration` at `compact` detail — two to four +sentences written to be read aloud, which is exactly the register a notification +wants. The summary is those narrations, trimmed, plus the few things that are +worth knowing before you open anything: how many political to-dos this run +produced, and whether any section was withheld or came back unverified. Nothing +here is generated: it is assembled from the documents the run already wrote, so a +notification can never claim something the digest itself does not say. + +WHO GETS WHAT +------------- +The per-person section settings (see preferences.py) decide the content of the +push, not just the canvas. Somebody who has switched the political section off +does not get political content pushed to their phone — that would make the +setting a lie in the one place it is most visible. + +People are grouped by their own ntfy topic (identity's `notify_topic`, the same +one arrival notifications use). Each topic gets the union of the sections wanted +by the people who share it: a topic IS its audience, so two people sharing one +have already agreed to share what arrives on it. Anyone without a topic of their +own falls back to `NTFY_TOPIC`, the household topic — and with no preferences +known at all (identity down, nobody registered), one message about everything +generated goes there. + +NETWORK PLACEMENT +----------------- +This POSTs to the self-hosted ntfy already running in this compose stack for +`chores` and `identity` — container to container, never near the firewall. Getting +it onto a phone away from home is the WireGuard split tunnel described in +docs/network-integration.md §2.2, not a port forward. Nothing new is exposed. + +Best-effort throughout: a failed push logs a warning. The digest is already +written by the time this runs, and no notification is worth failing a run over. +""" + +import logging +import os +from urllib.parse import urlencode + +import requests + +LOG = logging.getLogger(__name__) + +HTTP_TIMEOUT = 10 + +# How much of a section's narration survives into the push. ntfy shows a few lines +# before truncating, and a Pebble shows fewer still — the point is to be enough to +# judge by, not to be the digest. +MAX_SECTION_CHARS = 220 +MAX_BODY_CHARS = 1400 + +SECTION_LABELS = { + "personal": "Social", + "political": "Political", + "household": "Household", + "network": "Network", +} + +# ntfy renders these as icons next to the title. +SECTION_TAGS = { + "personal": "speech_balloon", + "political": "newspaper", + "household": "house", + "network": "shield", +} + + +def _trim(text, limit): + text = " ".join(str(text or "").split()) + if len(text) <= limit: + return text + # Cut at a sentence end when there is one in reach, so the push doesn't stop + # mid-clause; otherwise at a word boundary. + window = text[:limit] + for stop in (". ", "! ", "? "): + cut = window.rfind(stop) + if cut > limit * 0.5: + return window[: cut + 1].strip() + cut = window.rfind(" ") + return (window[:cut] if cut > 0 else window).rstrip() + "…" + + +def _todo_count(document): + """How many political to-dos this run produced, or None if there is no todo + window. Counted from the window's own content rather than inferred from prose — + a number in a notification has to be a number the digest actually contains.""" + for window in document.get("windows") or []: + if str(window.get("id")) != "political-todo": + continue + content = window.get("content") + if isinstance(content, list): + return len(content) + # A todo window that came back as prose still means "there are some". + return 1 if str(content or "").strip() else 0 + return None + + +def _section_lines(documents, sections): + """One line per section, in the order the canvas lays them out.""" + by_section = { + document.get("section"): document + for document in documents.get("compact") or [] + if isinstance(document, dict) + } + + lines = {} + for section in sections: + document = by_section.get(section) + if not document: + continue + + label = SECTION_LABELS.get(section, section.title()) + if document.get("withheld"): + lines[section] = f"{label}: withheld — nothing could be verified against its sources." + continue + + text = _trim(document.get("narration"), MAX_SECTION_CHARS) + if not text: + # A section with no narration still ran; saying so is more useful than + # leaving a gap the reader has to interpret. + text = "generated, no narration." + if document.get("degraded"): + text = f"unavailable — {text}" + elif document.get("unverified"): + text = f"[unverified] {text}" + + if section == "political": + todos = _todo_count(document) + if todos: + text = f"{todos} todo{'s' if todos != 1 else ''}. {text}" + + lines[section] = f"{label}: {text}" + return lines + + +def _click_url(person_name): + """Where the notification takes you when tapped: this person's own digest.""" + base = os.environ.get("DIGEST_WEB_URL", "").strip().rstrip("/") + if not base: + return None + if not person_name: + return f"{base}/full.html" + return f"{base}/full.html?{urlencode({'person': person_name})}" + + +def _audiences(prefs, sections, default_topic): + """topic -> (sections that topic may see, the names behind it). + + Grouping rather than one message per person: without it a household where + nobody has set a personal topic would get one identical push per registered + person, four times a day, which is how a useful notification becomes one people + turn off. + """ + if prefs is None: + return {default_topic: (list(sections), [])} if default_topic else {} + + grouped = {} + for person in prefs["people"]: + topic = (person.get("notify_topic") or default_topic or "").strip() + wanted = [section for section in sections if section in person["digest_sections"]] + if not topic: + LOG.info("notify: %s has no ntfy topic and no household default, skipping", person["name"]) + continue + if not wanted: + continue + visible, names = grouped.setdefault(topic, (set(), [])) + visible.update(wanted) + names.append(person["name"]) + + return { + topic: ([section for section in sections if section in visible], names) + for topic, (visible, names) in grouped.items() + } + + +def _post(base_url, topic, title, body, tags, click): + """Published as JSON to ntfy's root rather than as text to /. + + The header-based form (`Title:`, `Tags:`) is what `chores` uses and is fine for + its plain-English nudges, but HTTP headers are latin-1: a title or a name with + an em dash, an umlaut or a euro sign in it raises UnicodeEncodeError before the + request is even sent. This digest quotes news headlines and household member + names, so that would have failed on real content and worked on every test that + used ASCII. The JSON body is UTF-8 throughout. + """ + payload = {"topic": topic, "title": title, "message": body} + if tags: + payload["tags"] = tags + if click: + payload["click"] = click + response = requests.post(base_url, json=payload, timeout=HTTP_TIMEOUT) + response.raise_for_status() + + +def send(context, documents, sections, prefs): + """Pushes one summary per audience. Never raises; returns how many went out.""" + if os.environ.get("ENABLE_DIGEST_NOTIFY", "true").strip().lower() != "true": + return 0 + + base_url = os.environ.get("NTFY_URL", "").strip().rstrip("/") + if not base_url: + LOG.info("notify: NTFY_URL is not set, not pushing a digest notification") + return 0 + + default_topic = os.environ.get("NTFY_TOPIC", "").strip() + audiences = _audiences(prefs, sections, default_topic) + if not audiences: + LOG.info("notify: nobody to notify about this run") + return 0 + + lines = _section_lines(documents, sections) + slot = str(context.get("slot_hour", "")).zfill(2) + sent = 0 + + for topic, (visible, names) in audiences.items(): + body = "\n".join(lines[section] for section in visible if section in lines) + if not body: + continue + if len(body) > MAX_BODY_CHARS: + body = body[:MAX_BODY_CHARS].rstrip() + "…" + + # One name in the click-through: a shared topic has no single owner, so it + # gets the unfiltered page rather than an arbitrary person's. + click = _click_url(names[0] if len(names) == 1 else None) + labels = [SECTION_LABELS.get(section) or str(section) for section in visible] + title = f"Digest {slot}:00 — {', '.join(labels)}" + tags = [SECTION_TAGS[section] for section in visible if section in SECTION_TAGS] + + try: + _post(base_url, topic, title, body, tags, click) + sent += 1 + LOG.info("notify: pushed the digest summary to %r (%s)", topic, ", ".join(visible)) + except Exception: + LOG.warning("notify: could not push to %r", topic, exc_info=True) + + return sent diff --git a/digest-engine/preferences.py b/digest-engine/preferences.py new file mode 100644 index 0000000..e693dbe --- /dev/null +++ b/digest-engine/preferences.py @@ -0,0 +1,124 @@ +"""Who wants which digest — read from `identity` at the start of every run. + +`identity` (Phase 6) is this project's source of truth for per-person household +facts: it already owns chore exemptions, reminder styles and arrival-notification +settings for exactly the same reason it owns this one. Nothing about a person's +preferences is stored here; this module is a read of `GET /digest-preferences` +and nothing else. + +WHAT THE PREFERENCE ACTUALLY CONTROLS +------------------------------------- +Two different things, and they are worth keeping apart: + +1. **What gets generated.** run.py generates the UNION of the sections the + household asked for. A section nobody has ticked is never sent to the LLM at + all — no synthesis call, no counter-run call, and it never reaches output/. + That is the sense in which this is a per-person "should this be generated for + me" toggle rather than a display setting bolted on at the end. +2. **What each surface shows.** Each person's own subset is written into the + digest alongside the documents, and the renderer filters to it. That half is + a **display filter, not an access control**: digest-web serves the whole + output volume read-only to anything on the LAN, so anyone who can open the + canvas can open the JSON behind it. Ticking a box off keeps a section off + someone's screen and out of their narration; it does not make it secret from + them. Say so plainly rather than implying a boundary this stack doesn't have. + +FAILING SAFE MEANS GENERATING MORE, NOT LESS +-------------------------------------------- +Every failure here — identity disabled, unreachable, a bad token, a malformed +response — returns None, which run.py treats as "no preferences known" and falls +back to generating every section, exactly as this component did before the +setting existed. The alternative reading of a failed lookup ("nobody asked for +anything, generate nothing") would let one unreachable container silently cost +the household its whole digest, which is far worse than one run that shows a +section someone had opted out of. + +The same reasoning applies one level down: a household that runs `identity` but +has not registered anybody yet is treated as "no preferences known" too, because +an empty person table is the state of a fresh install, not a decision. Everyone +opting out of everything IS a decision, and it is honoured (loudly). +""" + +import logging +import os + +import requests + +LOG = logging.getLogger(__name__) + +DEFAULT_TIMEOUT = 10.0 + + +def fetch(known_sections): + """`{"people": [...], "wanted": [...]}` as identity reports it, or None. + + `known_sections` is this component's own section list — anything identity names + that this version of digest-engine has no prompt for is dropped here rather than + handed to a synthesis pass that would fail on the missing file. + """ + base_url = os.environ.get("IDENTITY_URL", "").strip().rstrip("/") + token = os.environ.get("IDENTITY_TOKEN", "").strip() + if not base_url: + LOG.info("IDENTITY_URL is not set, generating every digest section") + return None + + try: + response = requests.get( + f"{base_url}/digest-preferences", + headers={"Authorization": f"Bearer {token}"} if token else {}, + timeout=float(os.environ.get("IDENTITY_TIMEOUT", DEFAULT_TIMEOUT)), + ) + response.raise_for_status() + payload = response.json() + if not isinstance(payload, dict): + raise ValueError("response was not a JSON object") + except Exception: + LOG.warning( + "could not read digest preferences from %s, generating every section", + base_url, + exc_info=True, + ) + return None + + people = [] + for entry in payload.get("people") or []: + if not isinstance(entry, dict) or not entry.get("name"): + continue + people.append( + { + "id": entry.get("id"), + "name": str(entry["name"]), + "nickname": entry.get("nickname"), + "digest_sections": [ + section + for section in known_sections + if section in (entry.get("digest_sections") or []) + ], + } + ) + + if not people: + LOG.info("identity knows no people yet, generating every digest section") + return None + + wanted = [ + section + for section in known_sections + if any(section in person["digest_sections"] for person in people) + ] + LOG.info( + "digest preferences: %d person(s), generating %s", + len(people), + ", ".join(wanted) or "nothing — everybody has opted out of every section", + ) + return {"people": people, "wanted": wanted} + + +def sections_to_generate(prefs, known_sections): + """The sections this run should actually synthesise. Everything, when nothing is + known about who wants what (see the module docstring on why that is the safe + direction); otherwise exactly the union the household asked for. + """ + if prefs is None: + return list(known_sections) + return list(prefs["wanted"]) diff --git a/digest-engine/render/digest-canvas-sdk/glow.css b/digest-engine/render/digest-canvas-sdk/glow.css index d1e94d9..c1b6715 100644 --- a/digest-engine/render/digest-canvas-sdk/glow.css +++ b/digest-engine/render/digest-canvas-sdk/glow.css @@ -159,6 +159,90 @@ align-items: center; } +/* --------------------------------------------------------------------------- + * Sources — the fold-out citation block under a window or a globe marker's + * brief. Collapsed by default: the digest is read across a room, so the claim is + * what you see and the receipts are one tap away.
does the folding + * natively, so there is no JS and no state to keep in sync. + * --------------------------------------------------------------------------- */ + +.digest-sources { + margin-top: 0.8rem; + border-top: 1px solid var(--digest-edge); + padding-top: 0.5rem; + font-size: 0.82rem; +} + +.digest-sources > summary { + cursor: pointer; + color: var(--digest-accent); + letter-spacing: 0.04em; + text-transform: uppercase; + font-size: 0.72rem; + /* A big enough hit area for a fingertip on the kitchen tablet. */ + padding: 0.25rem 0; + list-style-position: outside; +} + +.digest-sources[open] > summary { margin-bottom: 0.5rem; } + +.digest-source-list { + margin: 0; + padding-left: 1.05rem; + color: var(--digest-muted); +} + +.digest-source-list li { margin-bottom: 0.65rem; } +.digest-source-list li:last-child { margin-bottom: 0; } + +.digest-source-head a { + color: var(--digest-text); + text-decoration: none; + border-bottom: 1px dotted var(--digest-edge); + word-break: break-word; +} + +.digest-source-meta { + margin-top: 0.15rem; + font-size: 0.75rem; + opacity: 0.85; +} + +.digest-source-quote { + margin: 0.35rem 0 0; + padding-left: 0.6rem; + border-left: 2px solid var(--digest-edge); + color: var(--digest-text); + font-style: italic; +} + +/* Per-marker briefings under the globe. */ +.digest-globe-briefs { + width: 100%; + margin-top: 0.9rem; + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.digest-globe-brief { + border-left: 2px solid var(--digest-edge); + padding-left: 0.7rem; +} + +.digest-brief-title { + margin: 0 0 0.25rem; + font-size: 0.9rem; + font-weight: 600; + letter-spacing: 0.01em; +} + +.digest-brief-summary { + margin: 0; + font-size: 0.88rem; + color: var(--digest-text); +} + /* --------------------------------------------------------------------------- * Globe * --------------------------------------------------------------------------- */ diff --git a/digest-engine/render/digest-canvas-sdk/render.js b/digest-engine/render/digest-canvas-sdk/render.js index b14ed30..4d66769 100644 --- a/digest-engine/render/digest-canvas-sdk/render.js +++ b/digest-engine/render/digest-canvas-sdk/render.js @@ -13,6 +13,17 @@ * - a globe with no markers -> the globe still draws, just empty * * A blank page is the one outcome that must never happen. + * + * PER-PERSON SECTIONS + * ------------------- + * A run carries `people` — each household member's own set of digest sections, as + * identity's admin panel recorded it and run.py copied it in (see + * digest-engine/preferences.py). Given `options.person` — a name, nickname or id + * that Home Assistant already resolved — only that person's sections are drawn. + * + * This is a DISPLAY FILTER, NOT AN ACCESS CONTROL. digest-web serves this same JSON + * read-only to anything on the LAN, so a section left out here is off a screen, not + * out of anyone's reach. Do not describe it to a user as privacy. */ (function (global) { @@ -59,13 +70,105 @@ return []; } + // The sections this viewer gets, or null for "nobody was resolved / nothing is + // known about them" — which the caller, not this function, decides what to do with. + function sectionsForPerson(parsed, person) { + if (!person) { return null; } + var wanted = String(person).trim().toLowerCase(); + if (!wanted) { return null; } + var people = (parsed && parsed.people) || []; + for (var i = 0; i < people.length; i++) { + var entry = people[i] || {}; + var matches = + String(entry.name || '').toLowerCase() === wanted || + String(entry.nickname || '').toLowerCase() === wanted || + String(entry.id) === wanted; + if (matches) { return entry.digest_sections || []; } + } + // Named somebody this digest has never heard of — treated exactly like naming + // nobody, never as "show them everything because they must be new". + return null; + } + + function filterSections(sections, parsed, options) { + var person = String(options.person || '').trim(); + var people = (parsed && parsed.people) || []; + + // Somebody WAS resolved, but this run has no preferences for anyone — identity was + // unreachable when it generated (see preferences.py). That run already fell back to + // generating everything, and hiding a resolved person's own personal section on top + // of it would make one container being down cost them more than it has to. Same + // direction of failure at both ends: more digest, not less. + if (person && !people.length) { return sections; } + + var allowed = sectionsForPerson(parsed, person); + if (allowed) { + return sections.filter(function (doc) { + return doc && allowed.indexOf(doc.section) !== -1; + }); + } + // No resolved person. The personal section is somebody's mail and messages, and + // the plan's rule for the shared kiosk is that it is never shown on a guess — see + // thinclient_agent/digest_canvas.py, which passes `person` only when Home + // Assistant has already resolved exactly who asked. + if (options.requirePersonForPersonal) { + return sections.filter(function (doc) { + return !doc || doc.section !== 'personal'; + }); + } + return sections; + } + + // A marker's own briefing: the summary of what is happening there, and its + // sources folded away underneath. Rendered below the globe rather than as a + // tooltip on the marker — the globe rotates, markers pass behind the limb, and a + // summary you can only read while its marker happens to be facing you is not a + // summary. The label ties the two together. + function renderMarkerBriefs(container, markers) { + var briefed = (markers || []).filter(function (marker) { + return marker && (marker.summary || (marker.sources && marker.sources.length)); + }); + if (!briefed.length) { return; } + + var wrap = document.createElement('div'); + wrap.className = 'digest-globe-briefs'; + + briefed.forEach(function (marker) { + var brief = document.createElement('div'); + brief.className = 'digest-globe-brief'; + + var heading = document.createElement('h4'); + heading.className = 'digest-brief-title'; + // Carries the marker's own colour so the eye can pair a red hammer-and-sickle + // on the globe with the paragraph explaining it. + if (marker.color) { heading.style.color = marker.color; } + heading.textContent = marker.label || 'Marker'; + brief.appendChild(heading); + + if (marker.summary) { + var text = document.createElement('p'); + text.className = 'digest-brief-summary'; + text.textContent = marker.summary; + brief.appendChild(text); + } + + var sources = global.DigestWindow.sources(marker.sources); + if (sources) { brief.appendChild(sources); } + + wrap.appendChild(brief); + }); + + container.appendChild(wrap); + } + function renderGlobeWindow(container, win) { var mount = document.createElement('div'); var el = global.DigestWindow.open({ title: win.title || 'Globe', content: mount, container: container, - variant: 'globe' + variant: 'globe', + sources: win.sources }); var globe = new global.DigestGlobe(mount); @@ -90,6 +193,8 @@ mount.parentNode.appendChild(caption); } + renderMarkerBriefs(mount.parentNode, win.globe_markers); + return el; } @@ -104,6 +209,9 @@ title: win.title || '', content: win.content, container: container, + // Any window may carry citations, not just the globe — a "Highlights" list + // and a standalone analysis window both need somewhere to put their receipts. + sources: win.sources, x: win.x, y: win.y, w: win.w, @@ -149,7 +257,24 @@ return; } - sections.forEach(function (doc) { + var visible = filterSections(sections, parsed, options); + + // Everything this run generated was filtered out — every section this viewer + // wanted is one nobody generated, or they asked for none at all. That is a + // legitimate outcome of the settings, not a failure, so it gets a plain window + // rather than the
 dump a malformed digest gets. Still never a blank page.
+      if (!visible.length) {
+        global.DigestWindow.open({
+          title: 'Nothing in this digest',
+          content:
+            'No digest section is switched on for this screen. Sections are chosen ' +
+            'per person in the identity admin panel.',
+          container: container
+        });
+        return;
+      }
+
+      visible.forEach(function (doc) {
         try {
           renderSection(container, doc);
         } catch (err) {
diff --git a/digest-engine/render/digest-canvas-sdk/window-chrome.js b/digest-engine/render/digest-canvas-sdk/window-chrome.js
index 1e02e05..e5268ef 100644
--- a/digest-engine/render/digest-canvas-sdk/window-chrome.js
+++ b/digest-engine/render/digest-canvas-sdk/window-chrome.js
@@ -71,7 +71,83 @@
     return body;
   }
 
+  // Only http(s) becomes a clickable link. The content of every window here is
+  // model output, and `javascript:` in an href would be script execution handed to
+  // whatever the LLM emitted — the one place this renderer's degrade-don't-throw
+  // habit is not enough. Anything else is shown as plain text instead.
+  function safeHref(url) {
+    var text = String(url || '').trim();
+    return /^https?:\/\//i.test(text) ? text : null;
+  }
+
+  // The fold-out sources block. Collapsed by default because the digest is read
+  // across a room: the claim is what you see, the receipts are one tap away, and
+  // an open citation list would push the next window off the screen.
+  function renderSources(sources, label) {
+    var usable = (sources || []).filter(function (source) {
+      return source && (source.title || source.outlet || source.url || source.quote);
+    });
+    if (!usable.length) { return null; }
+
+    var details = document.createElement('details');
+    details.className = 'digest-sources';
+
+    var summary = document.createElement('summary');
+    summary.textContent = (label || 'Sources') + ' (' + usable.length + ')';
+    details.appendChild(summary);
+
+    var list = document.createElement('ul');
+    list.className = 'digest-source-list';
+
+    usable.forEach(function (source) {
+      var li = document.createElement('li');
+
+      var head = document.createElement('div');
+      head.className = 'digest-source-head';
+      var href = safeHref(source.url);
+      var titleText = source.title || source.url || source.outlet;
+      if (href) {
+        var link = document.createElement('a');
+        link.href = href;
+        link.textContent = titleText;
+        link.rel = 'noopener noreferrer';
+        head.appendChild(link);
+      } else {
+        head.appendChild(document.createTextNode(titleText));
+      }
+      li.appendChild(head);
+
+      // Outlet and ownership sit with the citation rather than in the prose: who
+      // published a claim is part of reading it, and it belongs next to the claim.
+      var attribution = [source.outlet, source.owner, source.bias]
+        .filter(function (part) { return part; })
+        .join(' · ');
+      if (attribution) {
+        var meta = document.createElement('div');
+        meta.className = 'digest-source-meta';
+        meta.textContent = attribution;
+        li.appendChild(meta);
+      }
+
+      if (source.quote) {
+        var quote = document.createElement('blockquote');
+        quote.className = 'digest-source-quote';
+        quote.textContent = source.quote;
+        li.appendChild(quote);
+      }
+
+      list.appendChild(li);
+    });
+
+    details.appendChild(list);
+    return details;
+  }
+
   var DigestWindow = {
+    // Exposed so render.js can attach the same block under a globe marker's brief,
+    // where there is no window of its own to hang it on.
+    sources: renderSources,
+
     open: function (options) {
       options = options || {};
 
@@ -98,7 +174,11 @@
       bar.appendChild(title);
 
       el.appendChild(bar);
-      el.appendChild(renderContent(options.content));
+
+      var body = renderContent(options.content);
+      var sources = renderSources(options.sources);
+      if (sources) { body.appendChild(sources); }
+      el.appendChild(body);
 
       if (typeof options.x === 'number' && typeof options.y === 'number') {
         el.classList.add('digest-window-positioned');
diff --git a/digest-engine/render/templates/compact.html b/digest-engine/render/templates/compact.html
index e2f0b1c..a014fca 100644
--- a/digest-engine/render/templates/compact.html
+++ b/digest-engine/render/templates/compact.html
@@ -54,8 +54,15 @@
 
   var canvas = document.getElementById('canvas');
 
+  // Optional ?person= — put it in the Lovelace card's URL to get
+  // that person's own digest sections (chosen in identity's admin panel). Unlike
+  // full.html this defaults to showing everything that was generated, personal
+  // section included: this card is embedded in somebody's own HA dashboard, which is
+  // already a per-account surface, not a kiosk in a hallway that anyone walks past.
+  var person = new URLSearchParams(location.search).get('person') || '';
+
   function load() {
-    DigestRender.load(canvas, DIGEST_URL, { detailLevel: 'compact' });
+    DigestRender.load(canvas, DIGEST_URL, { detailLevel: 'compact', person: person });
   }
 
   load();
diff --git a/digest-engine/render/templates/full.html b/digest-engine/render/templates/full.html
index c81c6c0..ec87f0f 100644
--- a/digest-engine/render/templates/full.html
+++ b/digest-engine/render/templates/full.html
@@ -61,8 +61,20 @@
 
   var canvas = document.getElementById('canvas');
 
+  // ?person=, set by thinclient_agent/digest_canvas.py from a
+  // request Home Assistant had ALREADY resolved — this page never works out who is
+  // standing in front of it. With it, only that person's own digest sections are
+  // drawn (they pick them in identity's admin panel); without it, the personal
+  // section is left out rather than shown to whoever happens to walk past, which is
+  // the plan's Phase 11.8 rule: never guess whose personal section this is.
+  var person = new URLSearchParams(location.search).get('person') || '';
+
   function load() {
-    DigestRender.load(canvas, DIGEST_URL, { detailLevel: 'full' });
+    DigestRender.load(canvas, DIGEST_URL, {
+      detailLevel: 'full',
+      person: person,
+      requirePersonForPersonal: true
+    });
   }
 
   load();
diff --git a/digest-engine/requirements.txt b/digest-engine/requirements.txt
index a71b911..c6f0201 100644
--- a/digest-engine/requirements.txt
+++ b/digest-engine/requirements.txt
@@ -7,3 +7,4 @@ websocket-client>=1.7
 caldav>=2.0
 icalendar>=5.0
 paho-mqtt>=1.6
+pypdf>=4.0
diff --git a/digest-engine/run.py b/digest-engine/run.py
index 2afd2e6..c2a2a28 100644
--- a/digest-engine/run.py
+++ b/digest-engine/run.py
@@ -10,10 +10,19 @@ Every ingestion source is independently toggleable and independently fallible. A
 source that is disabled, misconfigured, or simply broken (a stale Telegram
 session, a signal-cli container that is down) logs a warning and contributes an
 empty list — it must never take down the rest of the run, because a digest with
-two of three sections is worth far more than no digest at all.
+most of its sections is worth far more than no digest at all.
 
 Everything here is read-only. See docs/project-plan.md Phase 12 step 8.
 
+WHICH DIGESTS GET GENERATED
+---------------------------
+Not necessarily all four. Each person in `identity` has a per-person set of the
+sections they want (network, household, personal/social, political/news), and
+this run generates the union of what the household asked for — a section nobody
+wants costs no LLM call and never reaches output/. See preferences.py, including
+why every failure in that lookup means "generate everything" rather than
+"generate nothing".
+
 Before anything is written to output/, every generated document passes through
 synth/counter_run.py — a second LLM call that checks the document against the
 same context it was generated from and drops anything that doesn't trace back
@@ -55,11 +64,16 @@ from ingest import (
     naval_traffic,
     news_rss,
     opnsense_ids,
+    rci_social,
     signal_ingest,
     telegram_ingest,
     whatsapp_ingest,
 )
 from synth import counter_run, llm_client
+import agenda
+import archive
+import notify
+import preferences
 import viewed_tracker
 
 LOG = logging.getLogger("digest")
@@ -72,6 +86,7 @@ SOURCES = (
     ("ENABLE_DISCORD_INGEST", "discord", discord_ingest),
     ("ENABLE_WHATSAPP_INGEST", "whatsapp", whatsapp_ingest),
     ("ENABLE_NEWS_INGEST", "news", news_rss),
+    ("ENABLE_RCI_SOCIAL_INGEST", "rci_social", rci_social),
     ("ENABLE_FINANCIAL_INGEST", "financial", financial),
     ("ENABLE_FLIGHT_TRAFFIC_INGEST", "flight_traffic", flight_traffic),
     ("ENABLE_NAVAL_TRAFFIC_INGEST", "naval_traffic", naval_traffic),
@@ -80,8 +95,22 @@ SOURCES = (
     ("ENABLE_GROCY_INGEST", "grocy", grocy),
 )
 
-PERSONAL_SOURCES = ("email", "signal", "telegram", "discord", "whatsapp")
-POLITICAL_SOURCES = ("news", "financial", "email", "flight_traffic", "naval_traffic")
+# Which ingested sources feed which section. Two jobs: it is what the personal
+# context's `messages` map is built from, and it is how collect() knows that a source
+# whose only consumers are switched off for this run needs no fetching at all — a
+# digest section nobody asked for should cost neither an LLM call nor the WAN round
+# trips its sources would have made. `email` appears twice on purpose: mail is both
+# personal correspondence and, occasionally, politically relevant evidence.
+SECTION_SOURCES = {
+    "personal": ("email", "signal", "telegram", "discord", "whatsapp"),
+    # `calendar` is here for the agendas, not for the diary: the political section
+    # owns the branch agenda's contents and the to-do list that comes out of it (see
+    # agenda.py), and it cannot match an agenda to a meeting it has never fetched.
+    "political": ("news", "rci_social", "financial", "email", "flight_traffic",
+                  "naval_traffic", "calendar"),
+    "household": ("calendar", "grocy"),
+    "network": ("opnsense_ids",),
+}
 
 DEFAULT_SCHEDULE = "00,06,12,18"
 DEFAULT_EVENING_HOUR = 18
@@ -133,9 +162,21 @@ def resolve_slot():
     return current, evening_hour, is_evening
 
 
-def collect(lookback_hours):
+def collect(lookback_hours, sections=None):
+    """`sections` is the set of digest sections this run is generating; a source no
+    enabled section reads is skipped entirely rather than fetched and then dropped.
+    None means "generate everything", i.e. fetch everything that is switched on.
+    """
+    needed = None
+    if sections is not None:
+        needed = {source for section in sections for source in SECTION_SOURCES.get(section, ())}
+
     collected = {}
     for toggle, key, module in SOURCES:
+        if needed is not None and key not in needed:
+            LOG.info("no enabled digest section reads %s, skipping its ingestion", key)
+            collected[key] = []
+            continue
         if not env_flag(toggle):
             LOG.info("%s is not enabled, skipping %s ingestion", toggle, key)
             collected[key] = []
@@ -185,7 +226,38 @@ def previous_section_document(previous_run, section):
     return None
 
 
-def build_section_contexts(collected, lookback_hours, is_evening_run, previous_run=None):
+def household_calendar(events):
+    """The calendar as the household section sees it: an event that has an agenda says
+    so, without carrying what the agenda says.
+
+    The points and the text go to the political section only (see agenda.upcoming()),
+    because a branch agenda is party work and reaches the people who asked for that
+    digest. Enforcing it here rather than only in the prompt means a household-only
+    run never has the contents in front of it to leak in the first place — a rule the
+    model cannot break is worth more than one it is told.
+    """
+    trimmed = []
+    for event in events:
+        documents = event.get("agenda_documents") if isinstance(event, dict) else None
+        if not documents:
+            trimmed.append(event)
+            continue
+        copy = dict(event)
+        copy["agenda_documents"] = [
+            {
+                key: document.get(key)
+                for key in ("filename", "from", "source", "received_at",
+                            "match_confidence", "match_reason", "text_extracted")
+            }
+            for document in documents
+        ]
+        trimmed.append(copy)
+    return trimmed
+
+
+def build_section_contexts(collected, lookback_hours, is_evening_run, previous_run=None,
+                           sections=None, section_history=None, unattached_agendas=None,
+                           upcoming_agendas=None):
     # The full pantry is only ever needed to work out what the evening recipe still
     # requires. On the other three runs it is a few hundred lines of prompt that
     # buys nothing, so it is dropped rather than sent and then ignored.
@@ -198,35 +270,75 @@ def build_section_contexts(collected, lookback_hours, is_evening_run, previous_r
     contexts = {
         "personal": {
             "lookback_hours": lookback_hours,
-            "messages": {key: collected.get(key, []) for key in PERSONAL_SOURCES},
+            "messages": {key: collected.get(key, []) for key in SECTION_SOURCES["personal"]},
         },
         "political": {
             "lookback_hours": lookback_hours,
             "news": collected.get("news", []),
+            # The organisation's own posts, kept in their own key rather than folded
+            # into `news`: they are the section's voice, not reporting, and the prompt
+            # is told to read them differently. See ingest/rci_social.py.
+            "rci_social": collected.get("rci_social", []),
             "financial": collected.get("financial", []),
             "mail": collected.get("email", []),
             # Extra evidence for the existing political synthesis, not a fourth
             # window type — see the traffic-data section of synth/prompts/political.md.
             "flight_traffic": collected.get("flight_traffic", []),
             "naval_traffic": collected.get("naval_traffic", []),
+            # What the household is about to sit down and discuss, from the agendas
+            # that arrived by mail or messenger. Steers curation: a story about the
+            # subject of Thursday's branch meeting is worth more than one that isn't.
+            # Topics only — the full agenda text stays in the household section.
+            "upcoming_agendas": upcoming_agendas or [],
         },
         # Both keys stay present even when their source is off or broken, so the
         # prompt sees the shape it is promised and says "nothing scheduled" instead
         # of hallucinating an event.
         "household": {
             "lookback_hours": lookback_hours,
-            "calendar": collected.get("calendar", []),
+            "calendar": household_calendar(collected.get("calendar", [])),
             "grocy": grocy_entries,
             # Drives the evening-only recipe/shopping-list section of
             # synth/prompts/household.md. Nothing is ever written back to Grocy.
             "is_evening_run": is_evening_run,
-            # Home network status sits with the household, not the political
-            # section — it is a "something in this house needs your attention"
-            # item. See the network-security section of synth/prompts/household.md.
-            "network_security": collected.get("opnsense_ids", []),
+            # Agendas that arrived by mail or message and could not be tied to any
+            # event — reported rather than dropped, because "an agenda came and I
+            # can't tell which meeting it's for" is worth a line. Reduced to the same
+            # fact-level fields as the attached ones: what arrived and from whom, not
+            # what it says. See household_calendar() above.
+            "unattached_agendas": [
+                {
+                    key: document.get(key)
+                    for key in ("filename", "from", "source", "received_at", "text_extracted")
+                }
+                for document in (unattached_agendas or [])
+            ],
         },
     }
 
+    # Its own section rather than a couple of lines inside the household one: a
+    # household member who wants the calendar but not a nightly intrusion-detection
+    # readout (or the reverse) can only say so if the two are separately generated.
+    # See synth/prompts/network.md.
+    contexts["network"] = {
+        "lookback_hours": lookback_hours,
+        "network_security": collected.get("opnsense_ids", []),
+    }
+
+    # A section nobody asked for is dropped here, before its context is ever built into
+    # a prompt — see preferences.py. Done by subtraction from the full set rather than
+    # by building each context conditionally, so adding a section later can't silently
+    # forget this filter.
+    if sections is not None:
+        contexts = {name: context for name, context in contexts.items() if name in sections}
+
+    # Earlier readings from the archive, as their own clearly-labelled block with its
+    # own timestamps — never merged into this run's entries, so a prompt can't mistake
+    # last month's figure for something that happened in this window. See archive.py.
+    for section, block in (section_history or {}).items():
+        if section in contexts:
+            contexts[section]["history"] = block
+
     if previous_run is not None:
         for section, context in contexts.items():
             doc = previous_section_document(previous_run, section)
@@ -240,7 +352,7 @@ def build_section_contexts(collected, lookback_hours, is_evening_run, previous_r
     return contexts
 
 
-def write_output(output_dir, run_id, context, documents):
+def write_output(output_dir, run_id, context, documents, sections, people):
     run_dir = output_dir / run_id
     run_dir.mkdir(parents=True, exist_ok=True)
 
@@ -254,6 +366,16 @@ def write_output(output_dir, run_id, context, documents):
         "run_id": run_id,
         "generated_at": context["generated_at"],
         "sections": documents,
+        # What this run actually generated, so a surface can tell "nobody asked for
+        # this section" apart from "it was generated and came back empty".
+        "sections_generated": sections,
+        # Who wants which of them, carried alongside the documents so the renderer can
+        # filter to the person Home Assistant resolved without a second fetch (and
+        # without digest-web needing to talk to identity at all). Empty when
+        # preferences could not be read — see preferences.py. THIS IS A DISPLAY
+        # FILTER, NOT AN ACCESS CONTROL: this whole file is served read-only to the
+        # LAN, so it hides a section from a screen, not from a person.
+        "people": people,
     }
     payload = json.dumps(digest, indent=2, ensure_ascii=False, default=str)
     (run_dir / "digest.json").write_text(payload, encoding="utf-8")
@@ -289,7 +411,33 @@ def main():
     LOG.info("digest run %s starting (lookback %sh)", run_id, lookback_hours)
 
     current_slot, evening_hour, is_evening_run = resolve_slot()
-    collected = collect(lookback_hours)
+
+    # Read before ingestion so a section nobody wants costs nothing at all — not an
+    # LLM call, and not the WAN round trips its sources would have made either.
+    prefs = preferences.fetch(llm_client.SECTIONS)
+    sections = preferences.sections_to_generate(prefs, llm_client.SECTIONS)
+    if not sections:
+        LOG.warning(
+            "every household member has opted out of every digest section — "
+            "generating nothing, which is what was asked for"
+        )
+
+    collected = collect(lookback_hours, sections)
+
+    # The long memory: this run goes into the archive, and every item it just
+    # collected comes back annotated with when this system first saw it. History for
+    # the sections being generated is read afterwards, so a series includes the
+    # reading taken a moment ago rather than stopping at the previous run.
+    archive.record(run_id, collected)
+    section_history = archive.history(sections)
+
+    # A "Tagesordnung" PDF that arrived by mail or WhatsApp belongs to the meeting it
+    # is for, not to a list of attachments. Done before the contexts are built so the
+    # calendar entries the household section sees already carry theirs. Only works
+    # when the message sources are being fetched at all — see SECTION_SOURCES: a run
+    # generating only the household section deliberately does not go and read the
+    # household's mail.
+    unattached_agendas = agenda.attach(collected)
 
     previous_run = load_previous_run(output_dir)
     previous_viewed_at = viewed_tracker.last_viewed_at()
@@ -312,26 +460,44 @@ def main():
         "item_counts": {key: len(items) for key, items in collected.items()},
         "sources": collected,
         "merged_unviewed_previous_run": merge_previous,
+        # Kept in the persisted context (and so in the Phase 12 follow-up voice Q&A's
+        # view of this run) because "why is there no political section today" is
+        # answered by this and by nothing else in the bundle.
+        "sections_generated": sections,
+        "digest_preferences_known": prefs is not None,
     }
 
     section_contexts = build_section_contexts(
         collected, lookback_hours, is_evening_run,
         previous_run=previous_run if merge_previous else None,
+        sections=sections,
+        section_history=section_history,
+        unattached_agendas=unattached_agendas,
+        upcoming_agendas=agenda.upcoming(collected, unattached_agendas),
     )
-    documents = llm_client.generate_all(section_contexts)
+    documents = llm_client.generate_all(section_contexts, sections=sections)
     # The final filter, per docs/project-plan.md: a second, independent pass over
     # each document against the same context it was generated from, before anything
     # is written to output/. See synth/counter_run.py for what it catches and how it
     # fails safe.
     documents = counter_run.verify_all(documents, section_contexts)
 
-    run_dir = write_output(output_dir, run_id, context, documents)
+    run_dir = write_output(
+        output_dir, run_id, context, documents,
+        sections=sections,
+        people=prefs["people"] if prefs else [],
+    )
+
+    # After the digest is on disk, never before: a push is a promise that there is
+    # something to open. Best-effort — see notify.py.
+    notify.send(context, documents, sections, prefs)
 
     LOG.info(
-        "digest run %s complete: %s -> %s",
+        "digest run %s complete: %s -> %s (sections: %s)",
         run_id,
         context["item_counts"],
         run_dir,
+        ", ".join(sections) or "none",
     )
     return 0
 
diff --git a/digest-engine/synth/counter_run.py b/digest-engine/synth/counter_run.py
index e6d13a1..9082601 100644
--- a/digest-engine/synth/counter_run.py
+++ b/digest-engine/synth/counter_run.py
@@ -116,6 +116,45 @@ def _window_haystack(window):
     return _normalize(content or "")
 
 
+def _ground_sources(container, context_blob):
+    """Strips citations that don't trace back to the context, in place.
+
+    A `sources` block is the strongest claim the digest makes — a URL and a quote
+    together assert "this exists and says this" — and it is also the easiest thing
+    for a model to compose out of thin air, because a plausible URL looks exactly
+    like a real one. Both halves are checked mechanically here rather than being
+    left to the verifying call's own judgement, for the same reason the quote check
+    below exists: substring presence in the context is a fact, not an opinion, and
+    no second opinion improves on it.
+
+    A source with a fabricated URL is dropped whole. A real source carrying an
+    invented quote keeps the source and loses the quote — the citation is still
+    true, only the excerpt was not.
+    """
+    sources = container.get("sources")
+    if not isinstance(sources, list):
+        return
+
+    kept = []
+    for source in sources:
+        if not isinstance(source, dict):
+            continue
+        url = str(source.get("url") or "").strip()
+        if url and _normalize(url) not in context_blob:
+            LOG.warning("counter-run: dropping a source whose URL is not in the context: %r", url[:120])
+            continue
+        quote = str(source.get("quote") or "").strip()
+        if quote and _normalize(quote) not in context_blob:
+            LOG.warning("counter-run: clearing a source quote not found in the context: %r", quote[:80])
+            source = {key: value for key, value in source.items() if key != "quote"}
+        kept.append(source)
+
+    if kept:
+        container["sources"] = kept
+    else:
+        container.pop("sources", None)
+
+
 def verify_document(document, context):
     """Returns a possibly-filtered copy of `document`. Never raises."""
     if document.get("degraded"):
@@ -199,6 +238,15 @@ def verify_document(document, context):
     if not kept:
         return _withheld_document(document)
 
+    # Citations are checked after the window verdicts, not before: there is no point
+    # grounding the sources of a window that is about to be dropped whole. Markers
+    # carry their own sources (the globe briefs), so they are walked too.
+    for window in kept:
+        _ground_sources(window, context_blob)
+        for marker in window.get("globe_markers") or []:
+            if isinstance(marker, dict):
+                _ground_sources(marker, context_blob)
+
     result = dict(document)
     result["windows"] = kept
     if not bool(verdict.get("narration_grounded", True)):
diff --git a/digest-engine/synth/llm_client.py b/digest-engine/synth/llm_client.py
index f3d5eb4..448e1e3 100644
--- a/digest-engine/synth/llm_client.py
+++ b/digest-engine/synth/llm_client.py
@@ -13,13 +13,23 @@ keep the two in sync when changing either.
     {
       "generated_at": "2026-07-28T12:00:00Z",
       "detail_level": "compact" | "full",
-      "section": "personal" | "political" | "household",
+      "section": "personal" | "political" | "household" | "network",
       "windows": [
         {
           "id": "string, unique within this section",
           "title": "string",
           "kind": "text" | "list" | "globe",
           "content": "markdown-ish string for kind=text, or an array of strings for kind=list",
+          "sources": [
+            {
+              "title": "headline or page title",
+              "outlet": "who published it",
+              "owner": "who owns that outlet, as the context gives it",
+              "bias": "that outlet's politics, as the context gives it",
+              "url": "https://...",
+              "quote": "verbatim excerpt"
+            }
+          ],
           "globe_markers": [
             {
               "lat": 0.0,
@@ -27,7 +37,9 @@ keep the two in sync when changing either.
               "label": "string",
               "icon": "star|hammer-sickle|default",
               "color": "#hex",
-              "glow": true
+              "glow": true,
+              "summary": "what is happening at this location",
+              "sources": [ ... same shape as above ... ]
             }
           ]
         }
@@ -38,18 +50,32 @@ keep the two in sync when changing either.
 `globe_markers` is only present (and non-empty) on `kind: "globe"` windows, which
 in practice only the political section produces.
 
+`sources` is optional everywhere and may appear on any window and on any marker.
+The renderer folds it into a collapsed "Sources (n)" block — on screen the claim
+is what you read and the citations are one tap underneath, so a section can be
+fully sourced without turning the canvas into a bibliography. A marker's
+`summary` is its briefing, rendered under the globe rather than as a tooltip,
+because the globe rotates and a marker's own face is not always toward you.
+
+WHICH SECTIONS RUN
+------------------
+`SECTIONS` is every section this component knows how to generate; which of them a
+given run actually generates is decided by the household, not here — see
+../preferences.py and generate_all()'s `sections` argument. A section nobody has
+ticked in the identity admin panel costs no call at all.
+
 DETAIL LEVELS
 -------------
-Each section is generated twice per run, once at `compact` and once at `full`
-(6 calls total), rather than generating `full` once and truncating it client-side:
+Each enabled section is generated twice per run, once at `compact` and once at
+`full`, rather than generating `full` once and truncating it client-side:
 truncation gives you the first N windows of a document written to be expansive,
 so a "compact" window can still hold a 400-word blob that overflows the HA
 Lovelace iframe card, whereas a second pass yields prose actually written to be
-terse. The cost is 3 extra calls against a local, self-hosted Ollama on a batch
-timer — no per-token bill and no latency anyone is waiting on — so correctness of
-the compact rendering wins. Both passes reuse one ingestion pass and one assembled
-context, which is what docs/project-plan.md means by "without needing two
-independent generation passes".
+terse. The cost is one extra call per enabled section against a local, self-hosted
+Ollama on a batch timer — no per-token bill and no latency anyone is waiting on —
+so correctness of the compact rendering wins. Both passes reuse one ingestion pass
+and one assembled context, which is what docs/project-plan.md means by "without
+needing two independent generation passes".
 """
 
 import json
@@ -62,7 +88,13 @@ import requests
 
 LOG = logging.getLogger(__name__)
 
-SECTIONS = ("personal", "political", "household")
+# Every section this component can generate, in the order they are laid out on the
+# canvas. `network` was carved out of the household section rather than added next to
+# it: the household prompt was already carrying the firewall's IDS summary as "one
+# small household item", and a household that wants to know about its calendar without
+# a nightly intrusion-detection readout (or the reverse) had no way to say so while the
+# two shared one document.
+SECTIONS = ("personal", "political", "household", "network")
 DETAIL_LEVELS = ("compact", "full")
 
 PROMPT_DIR = Path(__file__).parent / "prompts"
@@ -101,6 +133,27 @@ def _fallback_document(section, detail_level, text, title="Digest (plain text fa
     }
 
 
+def _coerce_sources(raw):
+    """Normalises a window's or a marker's `sources` list, dropping anything that
+    isn't at least identifiable. Kept permissive on purpose: a citation with a
+    title and no URL is still a citation, and dropping it would make the digest
+    look better-sourced than it is by hiding the weak entries. What it is NOT
+    permissive about is shape — a string, or a dict of nothing, is not a source.
+    """
+    sources = []
+    for entry in raw or []:
+        if not isinstance(entry, dict):
+            continue
+        source = {
+            key: str(entry.get(key)).strip()
+            for key in ("title", "outlet", "owner", "bias", "url", "quote")
+            if entry.get(key)
+        }
+        if source:
+            sources.append(source)
+    return sources
+
+
 def _coerce_document(raw, section, detail_level):
     if not isinstance(raw, dict):
         raise ValueError("model output was not a JSON object")
@@ -116,24 +169,34 @@ def _coerce_document(raw, section, detail_level):
             "kind": kind,
             "content": window.get("content", ""),
         }
+        sources = _coerce_sources(window.get("sources"))
+        if sources:
+            coerced["sources"] = sources
         if kind == "globe":
             markers = []
             for marker in window.get("globe_markers") or []:
                 if not isinstance(marker, dict):
                     continue
                 try:
-                    markers.append(
-                        {
-                            "lat": float(marker.get("lat", 0.0)),
-                            "lon": float(marker.get("lon", 0.0)),
-                            "label": str(marker.get("label") or ""),
-                            "icon": str(marker.get("icon") or "default"),
-                            "color": str(marker.get("color") or "#8ab4ff"),
-                            "glow": bool(marker.get("glow", False)),
-                        }
-                    )
+                    coerced_marker = {
+                        "lat": float(marker.get("lat", 0.0)),
+                        "lon": float(marker.get("lon", 0.0)),
+                        "label": str(marker.get("label") or ""),
+                        "icon": str(marker.get("icon") or "default"),
+                        "color": str(marker.get("color") or "#8ab4ff"),
+                        "glow": bool(marker.get("glow", False)),
+                    }
                 except (TypeError, ValueError):
                     continue
+                # The marker's own briefing: what is happening there, and the
+                # citations behind it, rendered under the globe by render.js. Both
+                # optional — a marker with neither is still a marker.
+                if marker.get("summary"):
+                    coerced_marker["summary"] = str(marker["summary"])
+                marker_sources = _coerce_sources(marker.get("sources"))
+                if marker_sources:
+                    coerced_marker["sources"] = marker_sources
+                markers.append(coerced_marker)
             coerced["globe_markers"] = markers
         windows.append(coerced)
 
@@ -232,10 +295,15 @@ def generate_section(section, detail_level, context):
         return _fallback_document(section, detail_level, text.strip())
 
 
-def generate_all(section_contexts):
+def generate_all(section_contexts, sections=None):
+    """`sections` is the subset the household actually asked for (../preferences.py);
+    None means all of them. Ordering always follows SECTIONS rather than the caller's
+    list, so the canvas doesn't reshuffle itself when somebody edits a checkbox.
+    """
+    wanted = SECTIONS if sections is None else [s for s in SECTIONS if s in sections]
     documents = {level: [] for level in DETAIL_LEVELS}
     for detail_level in DETAIL_LEVELS:
-        for section in SECTIONS:
+        for section in wanted:
             LOG.info("synth: generating %s/%s", section, detail_level)
             documents[detail_level].append(
                 generate_section(section, detail_level, section_contexts.get(section, {}))
diff --git a/digest-engine/synth/prompts/counter_run.md b/digest-engine/synth/prompts/counter_run.md
index 757e513..1f2743e 100644
--- a/digest-engine/synth/prompts/counter_run.md
+++ b/digest-engine/synth/prompts/counter_run.md
@@ -53,6 +53,19 @@ For each window in the document, check:
   change at the same location, a market move and a news item). The context
   must actually contain both halves of the correlation, not just one, with
   the other inferred or assumed.
+- **Citations.** A window (and a globe marker) may carry a `sources` list of
+  `{title, outlet, owner, bias, url, quote}` entries. Each one must correspond
+  to an actual entry in the context, and its `outlet`, `owner` and `bias` must
+  match what the context records for that entry rather than being characterised
+  from your own knowledge of the outlet. **Attributing a claim to a source that
+  does not contain it is worse than leaving it unsourced** — flag the window. A
+  fabricated URL is checked mechanically after this pass as well, but flag it if
+  you see one.
+- **Attribution of hostile sources.** Where the document repeats a claim made by
+  an outlet whose `bias` marks it as state-affiliated or as Zionist, it must
+  present that claim as *that outlet's statement*, not as established fact. A
+  document that launders such a claim into its own voice is not grounded, even
+  when the outlet really did say it — flag the window and say so.
 
 Err towards flagging. When genuinely unsure whether something is supported,
 treat it as unsupported — the cost of over-filtering one border-line claim is
diff --git a/digest-engine/synth/prompts/household.md b/digest-engine/synth/prompts/household.md
index 6a42c4b..36732dc 100644
--- a/digest-engine/synth/prompts/household.md
+++ b/digest-engine/synth/prompts/household.md
@@ -3,10 +3,13 @@
 You are the household section of a household digest that is generated four times
 a day. You are given the household calendar (read from Nextcloud over CalDAV) and
 household inventory/chore state from Grocy — upcoming events, stock that is low
-or expiring, chores and battery levels that are due. Some runs also carry a home
-network security summary — see "Home network security" below for the narrow way
-that may be used. One run a day is the evening run — see "Evening recipe and
-shopping list", which applies to that run and no other.
+or expiring, chores and battery levels that are due. One run a day is the evening
+run — see "Evening recipe and shopping list", which applies to that run and no
+other.
+
+The home network has its own section and is not your subject: say nothing about
+the firewall, intrusion detection, or anything on the network, even if you think
+it belongs here.
 
 `calendar` entries are tagged `"category": "calendar_event"` and carry `summary`,
 `start`, `end`, `all_day`, `location` and `recurring`. Times are UTC (`Z`) unless
@@ -40,31 +43,36 @@ You are read-only. You never create, move or delete a calendar event, never
 consume or restock anything in Grocy, and never propose that the system do so on
 its own — at most you can tell the user that something needs their attention.
 
-## Home network security
+## Meeting agendas
 
-Some runs carry a `network_security` entry tagged `"category":
-"network_security"`: a summary of the Suricata intrusion-detection alerts the
-household firewall raised during this digest window. Treat it as one small
-household item — "is anything wrong with the home network" — not a section of its
-own, and give it at most a couple of lines.
+A calendar entry may carry `agenda_documents`: a Tagesordnung or similar that
+arrived by mail or messenger and was matched to that meeting. Each one names the
+`filename`, who it came `from`, which `source` it arrived through, when
+(`received_at`), and how confident the match was (`match_confidence`,
+`match_reason`).
 
-- If `alert_count` is 0 and `ids_status` is `"running"`, say the network was
-  quiet in one short clause and move on. Do not pad it.
-- If `ids_status` is anything other than `"running"`, say the intrusion detection
-  was not running, so there is nothing to report — never present that as a quiet
-  network.
-- When there are alerts, lead with what a person would act on: which local
-  device (`top_local_hosts`) and which signature (`top_signatures`), and whether
-  the traffic was blocked or only alerted on (`actions` / `alerts_by_action` —
-  `"blocked"` means the firewall already stopped it, `"allowed"` means it did
-  not).
-- Respect the `caveat` field. These are signature matches, not confirmed
-  compromise; false positives are routine, severity is not available to you, and
-  you must never call a device infected or compromised on this evidence. Say what
-  fired and let the user judge. Never state that the network is safe or clean.
-- If `window_truncated` is true, say the counts are a lower bound.
-- If `packet_capture_reference` is present, you may mention in one clause that
-  raw captures are available at that location. You have not read them.
+**You report that an agenda exists. You do not report what is in it.** The
+agenda's contents — its points, and the to-do list that comes out of them —
+belong to the political section, because a branch agenda is party work and only
+the people who asked for the political digest are shown it. That separation is
+the point, not an oversight: do not list agenda points here, do not summarise the
+document, and do not derive tasks from it, even though the text is in front of
+you.
+
+What to say here:
+
+- One line with the event: "branch meeting Thursday 19:00 — agenda
+  `TO_12.08.pdf` arrived from Anna by mail on Monday". The meeting and the fact
+  that its agenda is here, nothing further.
+- If `match_confidence` is `low`, say the agenda *appears* to belong to that
+  meeting and name the reason, so a wrong match is visible rather than asserted.
+- If `text_extracted` is false, the document could not be read at all (a scan;
+  there is no OCR here). Say so in the same line — it is the one thing about an
+  agenda's contents worth reporting in this section, because it tells the reader
+  not to expect it elsewhere either.
+- `unattached_agendas` in the context are agendas that matched no event: mention
+  them in one line each — what arrived and from whom — so the reader knows a
+  document is waiting for a meeting the calendar does not have.
 
 ## Evening recipe and shopping list
 
diff --git a/digest-engine/synth/prompts/network.md b/digest-engine/synth/prompts/network.md
new file mode 100644
index 0000000..fe59728
--- /dev/null
+++ b/digest-engine/synth/prompts/network.md
@@ -0,0 +1,111 @@
+# Home network digest
+
+You are the home-network section of a household digest that is generated four
+times a day. You are given a summary of the intrusion-detection alerts the
+household's own OPNsense firewall (Suricata) raised during this digest window,
+tagged `"category": "network_security"`.
+
+Your job answers one question: **is anything wrong with the home network right
+now?** This is read on a wall display and spoken aloud in a kitchen, by people
+who are not on call and did not ask to become firewall analysts. Two or three
+short windows at most, even at `detail_level: full`. If there is nothing to
+report, say so in one line and stop — a quiet network is a one-line answer, not a
+section to pad.
+
+## Reading the context
+
+- If `alert_count` is 0 and `ids_status` is `"running"`, say the network was
+  quiet during the window and stop there.
+- If `ids_status` is anything other than `"running"`, say the intrusion detection
+  was **not running**, so there is nothing to report. Never present that as a
+  quiet network — it is the absence of an answer, not a good one.
+- When there are alerts, lead with what a person would act on: which local device
+  (`top_local_hosts`) and which signature (`top_signatures`), and whether the
+  traffic was blocked or only alerted on (`actions` / `alerts_by_action` —
+  `"blocked"` means the firewall already stopped it, `"allowed"` means it did
+  not).
+- If `window_truncated` is true, say the counts are a lower bound.
+- If `packet_capture_reference` is present, you may mention in one clause that
+  raw captures are available at that location. You have not read them.
+- If the context carries no `network_security` entry at all, say in one line that
+  no network data was collected this run. Do not infer that the network was
+  quiet, and do not invent an alert, a device, or a signature.
+
+## History: one alert is noise, the same alert every night is a fact
+
+The context may carry a `history` block from the digest's own archive of past
+runs: `alert_totals` (the alert count of each earlier run), `recurring_signatures`
+and `recurring_hosts`, each with `alerts` (the total across the archive),
+`runs_seen` (how many runs it has appeared in), and `first_seen`/`last_seen`.
+`archive_span_days` says how far back the archive actually goes.
+
+This is the most useful thing in this section, because recurrence is what
+separates background noise from something worth looking at:
+
+- **Lead with what is new.** A signature firing for the first time — `runs_seen`
+  of 1, or a `first_seen` inside this window — is the item a person should read
+  first, even if a familiar signature fired more times.
+- **Say plainly when something is routine.** A signature that has fired in
+  fifteen of the last twenty runs is background: name it in one clause as
+  ongoing, with its `first_seen` date, and do not present it as an event. A
+  household that gets told about the same alert four times a day stops reading
+  this section, and then it is worth nothing.
+- **A host that has just started appearing is worth naming**, with the date it
+  first appeared. That is the shape of "something on this network changed".
+- Compare this run's count against `alert_totals` only in figures you can point
+  at, and say how long the archive covers. A week of history does not support
+  "unusually high".
+- If there is no `history` block, or `archive_span_days` is small, say nothing
+  about trends at all.
+
+## What you must not claim
+
+Respect the `caveat` field. These are **signature matches, not confirmed
+compromise**; false positives are routine and severity is not available to you.
+
+- Never call a device infected, compromised or breached on this evidence. Say
+  what fired, on which host, and let the reader judge.
+- Never state that the network is safe, clean or secure. The most you can say is
+  that nothing fired during this window, which is a different claim.
+- Never recommend that anything be blocked, disconnected, rebooted or
+  reconfigured automatically. You are read-only: this component cannot touch the
+  firewall, and it must not propose that the system act on its own. Telling a
+  person that something deserves their attention is the whole of what you may do.
+
+## Output
+
+Output **only** a single JSON object matching this schema — no prose before or
+after it, no markdown code fence:
+
+```json
+{
+  "generated_at": "2026-07-28T12:00:00Z",
+  "detail_level": "compact" | "full",
+  "section": "network",
+  "windows": [
+    {
+      "id": "string, unique within this section",
+      "title": "string",
+      "kind": "text" | "list",
+      "content": "markdown-ish string for kind=text, or an array of strings for kind=list"
+    }
+  ],
+  "narration": "a short plain-text script suitable for TTS narration of this section, 2-4 sentences"
+}
+```
+
+Rules:
+
+- `section` must be exactly `"network"`.
+- `detail_level` must echo the `detail_level` line given at the end of the context.
+- Do **not** emit any window with `kind: "globe"` and do **not** emit
+  `globe_markers` in this section. The globe belongs to the political section.
+- `id` must be unique within this section, lowercase, hyphenated (e.g.
+  `network-alerts`, `network-status`).
+- Alerts, signatures and hosts are enumerable — use `kind: "list"` with an array
+  of short strings, each leading with the host or the signature name.
+- At `detail_level: compact`, keep the whole section to one window.
+- `narration` is spoken aloud by a TTS voice, so no markdown, no URLs, no emoji.
+  Read out an IP address only if it is the point of the item.
+- If you cannot produce valid JSON matching this schema, output a single
+  `kind: "text"` window with your best-effort plain-text summary instead.
diff --git a/digest-engine/synth/prompts/political.md b/digest-engine/synth/prompts/political.md
index ec4bd56..29b3e83 100644
--- a/digest-engine/synth/prompts/political.md
+++ b/digest-engine/synth/prompts/political.md
@@ -1,13 +1,54 @@
 # Political digest
 
 You are the political section of a household digest that is generated four times
-a day. You are given: entries from a curated set of news feeds, financial
-indicators (stock indices, oil, macro series such as unemployment), and the
-user's mail (from which you should use only the politically relevant items — for
-example union, campaign, tenants' association or party correspondence — and
-ignore everything personal, which is handled by a different section). Some runs
-also carry air- and naval-traffic samples — see "Traffic data" below for the
-narrow way those may be used.
+a day. You are given: entries from a curated set of news feeds from around the
+world, the RCI's own publications and social-media output, financial indicators
+(stock indices, oil, macro series such as unemployment), and the user's mail
+(from which you should use only the politically relevant items — for example
+union, campaign, tenants' association or party correspondence — and ignore
+everything personal, which is handled by a different section). Some runs also
+carry air- and naval-traffic samples — see "Traffic data" below for the narrow
+way those may be used.
+
+The reader is a communist, a member of the RCI's Austrian section, living in
+Vorarlberg. Write for someone who already holds this politics and needs to be
+oriented in the world this week — not for someone who needs to be convinced of
+it.
+
+## The four questions
+
+Everything below serves four questions. They are the structure of the section,
+not a checklist to append: decide what goes in the digest by asking which
+question an item answers, and drop it if it answers none.
+
+1. **What is relevant for the communist and class struggle globally right now?**
+   Strikes, organising, revolutionary situations, defeats and their causes, the
+   state of the workers' movement, and the economic conditions driving all of it.
+2. **What matters for going out and organising here — Austria, and Vorarlberg
+   specifically?** Local and national disputes, plant closures and layoffs, rents
+   and prices, the far right's activity, unions and works councils, university
+   and school agitation, anything a person could act on in the next days. A small
+   Vorarlberg item outranks a large foreign one for this question: a closure in
+   Dornbirn is more use on a paper sale than a cabinet reshuffle abroad. The
+   Austrian feeds (`VOL.AT` for Vorarlberg, `DER STANDARD` nationally) are where
+   this mostly comes from; a national item counts if it lands locally.
+3. **What else is consequential in the mid-to-long term, even when it is not
+   directly class struggle?** Wars and rearmament, climate and energy, epidemics,
+   supply chains and food, technological change, state repression and
+   surveillance law, migration regimes. Read them for what they will mean for
+   organising in a year or five, and say which — this is the question where a
+   slow-moving development beats a loud one.
+4. **What has been happening inside the RCI, and what are comrades elsewhere
+   reporting?** Congresses, campaigns, splits and fusions, election results,
+   repression against sections, growth. The sources are the `theory` entries, the
+   organisation's own social output, and — importantly — the **reports and
+   bulletins in the user's own mail**: internal reports from comrades in other
+   sections arrive there, and they are the one source in this context that no
+   feed can supply. Treat a comrade's report as a report, not as an anonymous
+   claim: say which section or comrade it came from where the mail says so.
+
+Each question gets its own window (see "Output"). A question with nothing worth
+saying this run gets one honest line, never filler.
 
 ## What this section is, and is not
 
@@ -72,15 +113,163 @@ speculation "No speculation" below rules out elsewhere; it's fine, and
 expected, for an item to warrant analysis without a specific named theory
 attached to it.
 
-Entries tagged `"category": "news_state_affiliated"` come from outlets that are
-organs of a state (e.g. Russian or Chinese state media), not independent press.
-Treat their factual claims about third parties with more scepticism than an
-independent outlet's, and treat their framing of their own state's actions as
-that state's self-presentation, worth noting as a data point ("Moscow/Beijing
-describes this as...") rather than reporting it as settled fact. Their
-reporting on labour/material conditions inside their own country can still be
-useful raw material — apply the same class analysis to it as to anything else,
-just don't launder state propaganda as neutral reporting.
+## The organisation's own social media
+
+The context may carry a `rci_social` list: posts from the RCI's and the Austrian
+section's own accounts, each tagged `"category": "theory_social"` and carrying
+`account`, `platform`, `scope` and `content_type`.
+
+- **`scope: "section"` is the reader's own organisation; `scope:
+  "international"` is the RCI as a whole. Do not merge the two voices** — "my
+  section is holding a meeting on Thursday" and "the International has published
+  a statement" are different facts and the reader acts on them differently.
+- These are the organisation's **public voice**, not its analysis. A post
+  announcing a meeting, a demonstration, a campaign launch, a paper sale or a new
+  video is worth surfacing plainly, with its date and place if the post gives
+  them, because it is something the reader can actually turn up to.
+- **Never use a post as evidence for a claim about the world.** A short
+  agitational post is advocacy; ground factual claims in the news entries and in
+  the written analysis tagged `"theory"`. If a post asserts something and nothing
+  else in the context supports it, say the section is saying it — do not restate
+  it as established.
+- Do not pad. If nothing new was posted this run, say nothing about social media
+  at all.
+
+## Watch later
+
+Entries with `content_type: "episode"` — new videos and podcast episodes from the
+organisation's channels — get their **own window**, and are kept out of the
+analysis entirely. They are not news and they are not evidence; they are things
+the reader might choose to watch or listen to later.
+
+- Use one `kind: "list"` window with id `political-watch-later`, titled something
+  like "Watch later". One entry per episode: its title, which account or channel
+  it came from, and its `duration` when the context gives one.
+- Say in a few words what it is about **only if the entry's own title or summary
+  says** — never guess at the content of a video from its title alone.
+- Order newest first, cap it at about six entries, and omit the window entirely
+  when there are no new episodes this run. Never carry an episode over into one
+  of the four question windows or onto the globe.
+- A `"theory"` article whose title is marked as a podcast (marxist.com prefixes
+  these with `[Podcast]`) belongs in this window too, not in the analysis.
+
+## The agendas: what is being discussed, and what you have to do
+
+The context may carry `upcoming_agendas`: meetings the reader is going to, each
+with the agenda that was sent out — its `points` (the numbered items, extracted
+from the document mechanically), its `text`, the `covering_message` it arrived
+with, who it came `from`, and `text_extracted` saying whether any of it could be
+read at all.
+
+**This section is where an agenda's contents live.** The household section only
+says that an agenda arrived; the points and the tasks are here, because a branch
+agenda is party work and belongs to the digest the reader asked for it in.
+
+Two windows come out of it (see "Output"):
+
+- **`political-agenda`** — the meeting, its date, and its points as written. Do
+  not rewrite a point into your own words, do not merge two into one, and do not
+  add a point that is not in the list. If `text_extracted` is false, say the
+  agenda could not be read and stop there — never guess at what a scanned
+  document says.
+- **`political-todo`, titled "Political todos"** — what the reader actually has
+  to do before those meetings. One line each, leading with the verb, naming the
+  meeting and its date: "Bring the paper-sale accounts — OG-Treffen, Thu 12 Aug".
+  Every entry must trace to a specific line of the agenda or its covering
+  message, and you must quote that fragment. Sources of a task are exactly two:
+  the agenda text and the message it came with. **If nothing actually asks
+  anyone to do anything, emit no todo window at all** — inventing preparation
+  nobody asked for is the worst failure available to this feature, because it is
+  the one a reader would act on.
+- A task the agenda assigns to somebody else is still worth one line, said as
+  what it is ("Anna is bringing the accounts"), so the reader knows it is covered
+  rather than assuming it is theirs.
+
+The same agendas are also the sharpest relevance filter you have, and that part
+belongs mostly to question 2.
+
+- **A story that touches an agenda point outranks a bigger story that doesn't.**
+  If Thursday's meeting has "Mietpreise / rent campaign" on it and this run's
+  material has a rent decision, a landlord lobby's figures or a tenants' dispute,
+  that is the item to feature — and say why in the item itself: "on Thursday's
+  agenda". The reader is about to have to speak about this.
+- Use it for question 1 as well when the agenda point is an international one (a
+  solidarity campaign, a strike being discussed), but do not stretch a
+  connection: an agenda point about the branch's finances does not make a
+  banking story relevant.
+- **The agenda tells you what is being discussed, never what is true.** Do not
+  treat a point on it as a fact about the world, and never present an agenda item
+  as if it were news. It decides what is *relevant*; the news entries decide what
+  is *so*.
+
+## Reading a source: who owns it
+
+**Every news entry carries `owner` and `bias`.** Read each item against them
+before you use it. There is no neutral outlet in this context and you must never
+write as if there were: a newspaper is owned by somebody, and what it can say is
+bounded by who that is. This is not a reliability ranking — it is the same
+materialist analysis you apply to everything else, applied to the press.
+
+The rules that follow from it:
+
+- **"State-affiliated" names who signs the cheque, not a propaganda bucket that
+  private Western outlets are exempt from.** RT is not simply "Russian state
+  media": it is the outlet of the Russian capitalist class and its state, and the
+  thing it will never report is Russian capital's own imperialism. The BBC is the
+  state broadcaster of a NATO power and follows the Foreign Office's frame on any
+  war Britain is party to. Both are class instruments. Say so in those terms when
+  it matters to the item, and apply the standard symmetrically or not at all.
+- **A private outlet is the organ of a fraction of capital.** Name the fraction
+  when it explains the coverage — the Washington Post's owner is Amazon's owner
+  when the story is warehouse labour; the WSJ's editorial line is the employers'
+  side of a strike it is reporting; the Economist's "we" is the ruling class.
+- **The business press is often the most candid source you have.** The FT and
+  CNBC brief capital honestly because their readers have to act on it, so they
+  will state a coming crisis, a falling profit rate or a wage offensive plainly
+  where a general-interest paper writes euphemism. Use that, and say where it
+  came from.
+- **`category: "news_labour"` is the workers' and movement press** (Morning Star,
+  Labor Notes, Peoples Dispatch, Jacobin). Closer to the shop floor than anything
+  else in this context and often the only source that covers a dispute at all.
+  They are not the RCI and their politics are not yours — Jacobin's is
+  reformist — so use their reporting and do not adopt their conclusions.
+- **When two outlets with opposed owners report the same fact, that convergence
+  is itself worth stating.** When they diverge, say who says what rather than
+  picking the one that sounds most authoritative.
+- If `owner`/`bias` are missing for an entry, say "ownership not recorded here"
+  rather than inventing an ownership claim. Never invent a proprietor, a funder
+  or a political line that the context does not state.
+
+## Zionist media: zero trust
+
+Entries tagged `"category": "news_zionist"` — and any entry whose `bias` marks it
+as Zionist — come from outlets that take the Israeli settler-colonial state as
+given, and whose Gaza coverage functions as apologia for the genocide: army
+statements reproduced as fact, massacres rendered as "strikes on militants",
+Palestinian casualty figures marked as contested while Israeli ones are simply
+reported, the passive voice reserved for Palestinian deaths.
+
+Treat them accordingly:
+
+- **Zero trust on any factual claim about Palestinians, Gaza, Lebanon or the
+  occupied territories.** Never repeat such a claim as established. If it is the
+  only source for something, either say the claim exists and is uncorroborated in
+  this run's context, or leave it out.
+- **Never adopt their language.** Not "clashes" for a massacre, not "the war"
+  for a genocide, not "Israel says X happened" as a summary of what happened.
+  Quote it as their statement and name it as such.
+- **What they are good for is evidence about the Israeli state itself** — what
+  its ruling class, army and press are saying to each other, admitting to,
+  preparing for, or falling out over. An Israeli paper reporting a split in the
+  cabinet, a recruitment crisis, a capital flight, or an army officer's own
+  account of an order is a real finding. Use it that way and say where it came
+  from.
+- **Zero trust is not inversion.** Their denial of something is not evidence that
+  it happened; corroborate against the Palestinian, anti-Zionist and independent
+  outlets in this context, and if nothing corroborates it, say the context does
+  not settle it. The "No speculation" rule below is not suspended here.
+- The same applies in reverse to nothing: no outlet in this context gets
+  uncritical trust, including the ones whose politics you share.
 
 Entries tagged `"category": "osint_military"` come from defence and open-source
 intelligence outlets that track troop, fleet and air movements. Their reporting
@@ -88,6 +277,37 @@ of *where forces are* is the useful part and is usually reliable. Their framing
 which reads military spending as necessity and arms procurement as good news — is
 the trade press of the arms industry and should be treated as such, not adopted.
 
+## History: what you may say about trends
+
+The context may carry a `history` block: earlier readings of the same financial
+and traffic series, oldest first, each with its own timestamp, drawn from the
+digest's own archive of past runs. Every entry in this run's own material also
+carries `first_seen_at` and `times_seen` — when this system first saw that item,
+and how many runs it has appeared in.
+
+**This block is the only basis on which you may describe anything as rising,
+falling, unchanged, accelerating or unprecedented.** Without it you have one
+snapshot and no trend, and the rule is the old one: say the figure, not a
+direction.
+
+With it:
+
+- Name the comparison explicitly — "unemployment 5.4%, up from 5.1% in the
+  reading of 12 June" — and take both numbers from the block. Never round a
+  trend into a word without the figures behind it.
+- Say how long the series actually covers (`archive_span_days`). A fortnight of
+  history is not evidence of a historic high, and claiming one from it is the
+  same speculation the next section rules out.
+- `first_seen_at` tells you whether a story is new or continuing. A story
+  already featured for three runs needs a reason to be featured again — what
+  changed — and "still ongoing" is not a headline. Say "first appeared on X" when
+  it matters to the reader's sense of whether something is developing.
+- Merchant traffic through a chokepoint is the case where history matters most:
+  a fall over weeks is the finding the "Traffic data" section describes, and now
+  you can actually see it. One low sample is still nothing.
+- The history block is context, never content: nothing in it happened in this
+  window, and it must never be presented as news from this run.
+
 ## No speculation
 
 Every claim you make must trace back to something actually present in the
@@ -107,7 +327,8 @@ context — a specific entry, figure, or quote. This is not a style preference:
 
 ## Curating and quoting
 
-For each entry that passes the relevance filter above:
+For each entry that passes the relevance filter above — i.e. that answers one of
+the four questions — and inside the window belonging to that question:
 
 - Feature it explicitly with a short excerpt or quotation taken verbatim from
   the entry's own text (its title/description/body field, not your
@@ -130,6 +351,24 @@ For each entry that passes the relevance filter above:
   bargaining power from an unemployment move), not just the figure — but only
   draw the connection the data actually supports.
 
+**Attach the receipts.** Every window that makes a claim about the world carries
+a `sources` array, and every entry in it is copied out of the context — never
+composed:
+
+- `url` must be an entry's own `link` exactly as the context gives it. **Never
+  write a URL that is not in the context**, never repair one that looks wrong,
+  never guess a homepage. An entry with no link gets no `url` field.
+- `quote` must be a verbatim span of that entry's own text.
+- `outlet` is the entry's `feed`; `owner` and `bias` are its `owner` and `bias`
+  fields, copied as given. Carrying ownership into the citation is the point:
+  the reader sees who paid for the claim in the same place they see the claim.
+- Where a finding rests on two outlets with opposed owners, list both — that is
+  what makes the convergence visible.
+
+The renderer folds this away under a "Sources" toggle, so citing properly costs
+the reader no screen space. There is no reason to leave a featured item
+unsourced.
+
 ## Traffic data
 
 The context may also contain entries tagged `"category": "flight_traffic"`
@@ -155,8 +394,12 @@ Use them only like this:
   heuristic, and warships routinely sail with AIS switched off — so a quiet
   region is evidence of nothing, and you must never write that an area is calm
   because these feeds are quiet.
-- One snapshot is not a trend. You have no previous run to compare against, so do
-  not describe anything here as rising, falling, massing or building up.
+- One snapshot is not a trend **unless the `history` block gives you earlier
+  readings of that same region** (see "History" above). With them, a sustained
+  fall in merchant traffic is a real finding and you may say so, with the dates
+  and figures attached. Without them — and for military aircraft counts, which
+  are a callsign heuristic on a single instantaneous sample — do not describe
+  anything as rising, falling, massing or building up.
 
 If these entries add nothing to a story you are already telling, leave them out
 entirely. That is the expected outcome most days.
@@ -172,9 +415,30 @@ data showing falling merchant traffic through it is one correlated finding,
 not two coincidental ones. Only state a correlation the context actually
 supports; see "No speculation" above.
 
+**A marker is a briefing, not a pin.** Give every marker a `summary`: two to four
+sentences on what is happening at that place, in the analytical register of the
+rest of this section — what is at stake materially, for which class, and how it
+connects to anything else in this run. Give it a `sources` array too, under the
+same rules as the "Curating and quoting" section above. The renderer prints each
+marker's summary under the globe with its sources folded away beneath, so the
+globe is the index and the summaries are the section — a marker whose whole
+content is a place name wastes the space it occupies.
+
+A marker's `label` stays short (it is drawn on the globe itself); everything you
+want to say goes in `summary`.
+
 You are read-only: you summarise and analyse, you never propose that the system
 send, post, or publish anything.
 
+## Composing the section
+
+Inside the rules above, the layout is yours. You have `kind: "text"` for prose,
+`kind: "list"` for anything enumerable, and `kind: "globe"` for the world map,
+each in a window of its own, plus the fold-out `sources` block on any of them.
+Use as many or as few windows as the material justifies, order them by what
+matters most this run, and let a quiet run be a short section. Do not pad to a
+shape; do not invent a window to fill the canvas.
+
 ## Output
 
 Output **only** a single JSON object matching this schema — no prose before or
@@ -191,6 +455,16 @@ after it, no markdown code fence:
       "title": "string",
       "kind": "text" | "list" | "globe",
       "content": "markdown-ish string for kind=text, or an array of strings for kind=list",
+      "sources": [
+        {
+          "title": "the entry's own headline",
+          "outlet": "the entry's `feed`",
+          "owner": "the entry's `owner`, copied as given",
+          "bias": "the entry's `bias`, copied as given",
+          "url": "the entry's `link`, copied exactly — never composed",
+          "quote": "a verbatim span of that entry's text"
+        }
+      ],
       "globe_markers": [
         {
           "lat": 0.0,
@@ -198,7 +472,9 @@ after it, no markdown code fence:
           "label": "string",
           "icon": "star|hammer-sickle|default",
           "color": "#hex",
-          "glow": true
+          "glow": true,
+          "summary": "2-4 sentences on what is happening here and what is at stake",
+          "sources": [ "... same shape as the window's sources ..." ]
         }
       ]
     }
@@ -235,16 +511,35 @@ Rules:
   say so concisely (e.g. "Port of X — strike + falling traffic"), not just
   name the place.
 - `globe_markers` belongs only on `kind: "globe"` windows. Omit it everywhere else.
-- Use one `kind: "list"` window titled something like "Highlights" for the
-  curated, quoted items from "Curating and quoting" above — one array entry
-  per featured item, each entry holding the quote, the relevance line, and
-  (where warranted) its impact analysis. If a single item's analysis is long
-  enough to crowd the list, give it its own `kind: "text"` window instead and
-  keep a short pointer to it in the list entry.
-- Use `kind: "list"` for other enumerable material too (e.g. the financial
-  indicators, each line stating the move *and* what it means for working
-  people), and `kind: "text"` for standalone analysis that doesn't fit the
-  highlights list.
+- `summary` and `sources` on a marker are what the reader actually reads; the
+  globe itself is the index. See "Correlating on the globe" above.
+- **One window per question, in this order**, each holding the curated, quoted
+  items that answer it (see "Curating and quoting"):
+  - `political-global` — question 1, the global class struggle. Usually
+    `kind: "list"`, one entry per featured item.
+  - `political-local` — question 2, Austria and Vorarlberg. Always present, even
+    if its content is one line saying nothing local cleared the bar this run.
+  - `political-horizon` — question 3, the consequential-but-slower developments.
+    Omit the window entirely if the run genuinely has none.
+  - `political-rci` — question 4, the International and comrades' reports. Omit
+    if there is nothing; never manufacture organisational news.
+  - `political-agenda` — the upcoming meetings and their agenda points, per "The
+    agendas" above. Omit when no agenda arrived.
+  - `political-todo`, titled **"Political todos"** — the tasks those agendas
+    actually ask for, one line each with the quote they came from. Omit entirely
+    when nothing is asked.
+  - `political-watch-later` — new episodes, per "Watch later" above. Omit when
+    there are none.
+  You may add further windows beyond these when an item needs its own space (a
+  long piece of analysis, the financial indicators as their own `kind: "list"`
+  with each line stating the move *and* what it means for working people). Do not
+  drop or rename the four question windows to make room.
+- At `detail_level: "compact"` keep the globe and fold the questions into a
+  single `kind: "list"` window — one or two lines each for questions 1 and 2, one
+  line each for 3 and 4 if they have anything. Sources still attach; they cost no
+  space when folded. **Keep `political-todo` as its own window even here**: it is
+  the one part of this section a person acts on rather than reads, and it is the
+  first thing they will look for on a phone.
 - `narration` is spoken aloud by a TTS voice, so no markdown, no URLs, no emoji.
 - If you cannot produce valid JSON matching this schema, output a single
   `kind: "text"` window with your best-effort plain-text summary instead.
diff --git a/digest-engine/whatsapp-bridge/index.js b/digest-engine/whatsapp-bridge/index.js
index b1fcecd..7de8e10 100644
--- a/digest-engine/whatsapp-bridge/index.js
+++ b/digest-engine/whatsapp-bridge/index.js
@@ -17,14 +17,21 @@
 
 const fs = require('fs');
 const path = require('path');
+const crypto = require('crypto');
 const qrcode = require('qrcode-terminal');
 const { Client, LocalAuth } = require('whatsapp-web.js');
 
 const DATA_DIR = process.env.WHATSAPP_DATA_DIR || '/data';
 const MESSAGES_PATH = path.join(DATA_DIR, 'messages.jsonl');
 const AUTH_PATH = path.join(DATA_DIR, '.wwebjs_auth');
+// Document attachments land here. digest-engine mounts the parent of this bridge's
+// /data, so what is /data/documents here is /data/whatsapp-bridge/documents there —
+// see ingest/whatsapp_ingest.py, which resolves exactly that.
+const DOCUMENTS_DIR = path.join(DATA_DIR, 'documents');
+const MAX_DOCUMENT_BYTES = Number(process.env.WHATSAPP_MAX_DOCUMENT_BYTES || 10 * 1024 * 1024);
 
 fs.mkdirSync(DATA_DIR, { recursive: true });
+fs.mkdirSync(DOCUMENTS_DIR, { recursive: true });
 
 const client = new Client({
   authStrategy: new LocalAuth({ dataPath: AUTH_PATH }),
@@ -90,8 +97,46 @@ client.on('message', async (message) => {
       is_group: chat ? Boolean(chat.isGroup) : false,
       timestamp: message.timestamp,
       type: message.type,
-      body: message.body || ''
+      body: message.body || '',
+      has_media: Boolean(message.hasMedia),
+      // whatsapp-web.js exposes the document's own filename on the raw payload. This
+      // is not part of its documented API and may simply be undefined on some
+      // message types or library versions — in which case the digest still gets the
+      // caption and the type, and agenda matching falls back to those.
+      filename: (message._data && message._data.filename) || null,
+      mimetype: (message._data && message._data.mimetype) || null,
+      document_file: null
     };
+
+    // DOCUMENTS ONLY, and only documents: a meeting agenda arrives as a PDF, and
+    // digest-engine reads its text to pull out the agenda points. Photos, video and
+    // audio are never downloaded — they are the bulk of what a group chat carries,
+    // this container has no use for them, and every download is one more request
+    // through a session that is already the highest-risk part of this project.
+    if (message.hasMedia && message.type === 'document') {
+      try {
+        const media = await message.downloadMedia();
+        const size = media && media.data ? Buffer.byteLength(media.data, 'base64') : 0;
+        if (!media || !media.data) {
+          console.error('A document had no downloadable data; recording it by name only.');
+        } else if (size > MAX_DOCUMENT_BYTES) {
+          console.error(`Document is ${size} bytes, over the ${MAX_DOCUMENT_BYTES} cap; recording it by name only.`);
+        } else {
+          // Sanitised basename plus a hash, so a hostile or merely awkward filename
+          // ("../../etc/passwd", or the fourth "Tagesordnung.pdf" this month) can
+          // neither escape this directory nor overwrite an earlier file.
+          const raw = media.filename || record.filename || `${record.id || Date.now()}.bin`;
+          const safe = path.basename(raw).replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 80) || 'document.bin';
+          const stamp = crypto.createHash('sha256').update(String(record.id || raw)).digest('hex').slice(0, 8);
+          const target = path.join(DOCUMENTS_DIR, `${stamp}-${safe}`);
+          fs.writeFileSync(target, Buffer.from(media.data, 'base64'));
+          record.document_file = path.basename(target);
+        }
+      } catch (err) {
+        // A failed download costs the document's text, not the message.
+        console.error(`Could not save a document: ${err}`);
+      }
+    }
     // appendFileSync opens/appends/closes per message, so whatsapp_ingest.py can
     // rename the file out from under us mid-run without losing a partial write.
     fs.appendFileSync(MESSAGES_PATH, JSON.stringify(record) + '\n', 'utf8');
diff --git a/docs/project-plan.md b/docs/project-plan.md
index e5860b6..7401e5b 100644
--- a/docs/project-plan.md
+++ b/docs/project-plan.md
@@ -184,7 +184,7 @@ real hardware" callouts for everything downstream of this.)*
 | Thin-client browser | **Firefox (kiosk)** | General browsing + the rendering surface for the LLM-generated digest canvas |
 | Thin-client voice | **wyoming-satellite** + **openWakeWord** | Local wake-word spotting, streams to the existing Phase 3 Wyoming faster-whisper/Piper Assist pipeline — no new STT/TTS infrastructure |
 | Digest scheduling | **systemd timer** | 4x/day cadence, same `OnCalendar` pattern as the existing restic backup timer |
-| Digest engine | **digest-engine** (custom Python) | Ingests mail/messages/news/financial data, calls the Phase 3 Ollama host, renders the personal/political/household digest sections |
+| Digest engine | **digest-engine** (custom Python) | Ingests mail/messages/news/financial data, calls the Phase 3 Ollama host, renders the personal/political/household/network digest sections, generating only the ones the household ticked in `identity` |
 | Digest static serving | **digest-web** (nginx:alpine/Caddy) | Serves the rendered digest artifact read-only to both the thin client and an HA iframe card |
 | Digest ingestion — Signal | **signal-cli** | Linked-device (JSON-RPC) read access to Signal messages |
 | Digest ingestion — Telegram | **Telethon** | MTProto client logged in as the real account — the Bot API can't read personal DMs |
@@ -478,7 +478,7 @@ recurrence/TLS traps. Nextcloud itself is pre-existing; nothing in this repo dep
 8. **Voice interactivity**:
    - Only the rooms with a chosen thin client *and* an attached mic run `wyoming-satellite` (openWakeWord), streaming to the existing Phase 3 Wyoming faster-whisper/Piper pipeline — no new STT/TTS infrastructure.
    - `thinclient-agent` accepts an Assist-resolved "play my digest" intent: switches the dedicated Firefox workspace into the "full/thorough" canvas view (vs. the HA dashboard's "compact" view) and narrates via the existing Piper TTS output.
-   - **Room/person routing**: reuse Phase 2's presence system (`person.*`/area entities). If exactly one recognized person is in the room where the wake word fired, play that person's personal digest section. If more than one is present, Assist asks **"whose digest?"** and disambiguates by spoken name before playing the personal section — never guesses. Political/household sections always play regardless of presence ambiguity.
+   - **Room/person routing — voice-activated, automatically recognized, never asked**: the digest canvas is shown only when somebody asks for it (a spoken "play my digest", or the HA button). **Nothing displays it because a person walked past a screen**, and nothing here polls presence to decide to show something. When the wake word fires, HA calls `identity`'s `GET /speaker?area=`, which resolves who is asking from the two signals it already fuses: an IRK-resolved BLE identifier in that area, and a Frigate face sighting inside `FACE_PRESENCE_WINDOW_SECONDS`. One person in the room is them; several, and the most recent camera sighting decides. That answer becomes the `person` parameter the thin client passes to the canvas, which then shows exactly that person's chosen sections (Phase 12 step 5). **An unresolved answer means show less, not ask**: the canvas renders every section except the personal one, which is the same rule as before — automating the recognition is only acceptable because the unresolved case still fails closed. This replaces the earlier "Assist asks *whose digest?*" disambiguation: the household wanted recognition, not an interrogation. There is no speaker identification in this stack and `/speaker` does not pretend otherwise — it identifies who is in the room, not whose voice it was.
 9. Network placement: plain trusted LAN for now (no VLAN precedent exists for a general client device class yet — only the unbuilt camera-VLAN concept). Revisitable later as a Phase-10-style expansion item, not a blocker now.
 10. **Validate the image boots to a working kiosk session (Sway, local mpv/Spotify playback) with Mosquitto/HA/container-host powered off** — must not hang waiting on the network, same "reactive path never depends on a remote service" philosophy applied to the thin client's own boot path.
 
@@ -494,10 +494,14 @@ recurrence/TLS traps. Nextcloud itself is pre-existing; nothing in this repo dep
    - **WhatsApp** — no officially-sanctioned API option exists. Rather than a protocol-reimplementation library (Baileys), run a small **`whatsapp-bridge`** sidecar (Node.js, `digest-engine/whatsapp-bridge/`): a real Chromium logged into the actual web.whatsapp.com client via **whatsapp-web.js** (Puppeteer), inside its own container running **Xvfb** so Chromium executes **headful** (not `headless: true`) — WhatsApp's automation detection specifically fingerprints headless Chrome, so a virtual-display "real browser" session is meaningfully lower-risk than either Baileys or true-headless whatsapp-web.js, though not zero-risk (it's still automated use of a personal account). One-time interactive QR-code login persists a session directory (mounted volume) so subsequent runs don't need re-scanning. The bridge exposes incoming messages over a local-only channel (e.g. a Unix socket or a small internal HTTP endpoint on the compose network, never published to the LAN) that `digest-engine/ingest/whatsapp_ingest.py` reads each run. Still gate behind `ENABLE_WHATSAPP_INGEST="false"`, off by default, with a warning in script output + `digest-engine/README.md`; recommend a secondary/non-critical number if enabled. Build this one last.
    - **News** — `feedparser` over a curated OPML list (`digest-engine/feeds/curated-feeds.opml`), seeded with the confirmed `https://www.marxist.com/feed/rss` plus a mainstream-outlet list (exact outlets: see open decisions).
    - **Financial** — FRED API (macro/unemployment, e.g. `UNRATE`) + Stooq keyless CSV (stocks/oil/commodities, preferred over Alpha Vantage's tight free-tier cap).
-5. LLM synthesis: assemble the run's ingested content into context, call the existing Phase 3 Ollama host with three separate prompt templates (`digest-engine/synth/prompts/{personal,political,household}.md`):
+5. LLM synthesis: assemble the run's ingested content into context, call the existing Phase 3 Ollama host with one prompt template per section (`digest-engine/synth/prompts/{personal,political,household,network}.md`):
    - **Personal** — from personal-flagged mail/messages.
    - **Political** — Marxist/working-class analytical framing (marxist.com feed as theoretical basis) synthesizing mainstream news + financial indicators + politically-flagged mail, laid out on the "holo globe" with colored/glowing markers (e.g. revolutionary-situation markers in red with a hammer-and-sickle/star motif).
    - **Household/calendar** — from the existing Nextcloud CalDAV integration (Phase 8) and Grocy state (Phase 7).
+   - **Network** — the OPNsense/Suricata intrusion-detection summary, split out of the household section (implementation note, later than the original plan) so the two can be wanted separately.
+   - **The archive** (implementation note): `digest-engine/archive.py` keeps every ingested item and every measured number in SQLite across runs, so a run can say "up from 5.1% in June", "this IDS signature has fired every night this week", or "this story first appeared on Monday". Items are deduplicated on a fingerprint so `first_seen_at` means something; numbers are stored one row per measurement so a trend is a query. History enters each prompt as its own timestamped block, and it is what lifts the "one snapshot is not a trend" prohibition — but only with the figures and dates attached. It also persists mail and messages for its retention window, which `DIGEST_ARCHIVE_EXCLUDE_SOURCES` exists to bound.
+   - **Meeting agendas** (implementation note): `digest-engine/agenda.py` matches a Tagesordnung PDF arriving by mail or WhatsApp to the calendar event it belongs to (date in the filename/subject/heading, then wording in common, each labelled with its confidence), reads it with pypdf, and extracts its numbered points mechanically. The household section lists the points and derives a todo window from what the document actually asks for; the political section gets the points as a relevance filter — a story touching Thursday's agenda outranks a bigger one that doesn't. A scanned agenda extracts nothing and is named but never characterised; there is no OCR here.
+   - **Per-person section toggles** (implementation note): each person picks their own sections in `identity`'s admin panel (`people.digest_sections`, `GET /digest-preferences`). A run generates the **union** of what the household asked for — a section nobody wants costs neither an LLM call nor its sources' ingestion — and each surface filters to the person Home Assistant resolved. That last half is a display filter, not an access control: `digest-web` serves the whole artifact read-only to the LAN. An unreachable `identity` means "generate everything", never "generate nothing".
    - A **detail-level** parameter (`compact` for the HA iframe, `full` for the thin-client fullscreen view) makes the thin-client rendering genuinely more thorough without needing two independent generation passes.
 6. Rendering: vendor the offline **digest-canvas SDK** under `digest-engine/render/digest-canvas-sdk/` (globe + `addMarker()`, window/panel chrome, glow/holo CSS utility, no CDN dependency). Each run's LLM job is to call into this SDK with structured content, not hand-roll projection math. Use a custom inline SVG or Unicode ☭ (U+262D, explicit font-fallback + CSS glow) for hammer-and-sickle iconography since Nerd Fonts has no such glyph.
 7. **Live follow-up voice Q&A**: persist each run's actually-used ingested-context bundle (not full raw content) as `digest-engine/output//context.json`. Expose a small HA tool (`digest_followup_query`) so a spoken follow-up ("tell me more about the unemployment numbers") feeds the cached context + question back into Ollama for a grounded, low-latency answer — no fresh ingestion pass. The answer can push a new small window/card onto the already-open thin-client canvas via a websocket, keeping the "flexible windows" idea alive live, not just at generation time.
diff --git a/hosts/thin-client/README.md b/hosts/thin-client/README.md
index aa7c68f..60b859c 100644
--- a/hosts/thin-client/README.md
+++ b/hosts/thin-client/README.md
@@ -311,6 +311,9 @@ integration you should get one device per thin client with:
   plugged in (dynamic, re-scanned periodically — see "Capture-card /
   receiver-box viewing" above); switches to `5:capture` and shows the picked one
   full-screen via mpv.
+- **Display** (switch) — the TV's own power, over HDMI-CEC with a Sway DPMS
+  fallback. Meant to be driven by room presence; see "Turning the TV off when the
+  room is empty" below.
 - **Workspace** (select) — `1:web` / `2:digest` / `3:media` / `4:admin` / `5:capture`
 - **Launch Firefox**, **Launch web browser**, **Launch Steam Link** (buttons)
 - **Playback state** (sensor, with track metadata as attributes), **Volume** (number),
@@ -376,6 +379,77 @@ never the payload — that reaches `capture-view` as an argv element. An unknown
 or since-unplugged selection resolves to "no source," never to acting on
 whatever string HA sent.
 
+## Turning the TV off when the room is empty
+
+A wall-mounted TV showing a canvas to an empty room is the largest power draw
+this machine is attached to — 60–150 W of lit panel against the thin client's own
+handful of watts. The **Display** switch turns it off on demand, and the intended
+driver is room presence.
+
+**How the agent does it** (`display_power.py`): HDMI-CEC first, over the same
+cable that carries the picture — `cec-ctl --to 0 --standby` to sleep the panel,
+`--image-view-on` plus `--active-source` to wake it and claim the input back. No
+network path to the TV, no pairing, no account, and it keeps working with the LAN
+down. Then, always, `swaymsg output  power off`, which stops the compositor
+driving pixels — that is the fallback for a set whose CEC is broken or switched
+off, and belt-and-braces on one where it works.
+
+`cec-ctl` comes from `v4l-utils`, already in the image's package list. Most TVs
+ship CEC **disabled**; enable it once in the TV's settings, where it will be called
+HDMI-CEC, Bravia Sync, Anynet+, SimpLink, Viera Link or similar. `CEC_DEVICE`,
+`DISPLAY_OUTPUTS` and `DISPLAY_USE_CEC` in the agent's config cover the machine
+with two adapters, several screens, or a panel whose CEC you want left alone.
+
+**"Off" means standby, honestly.** A TV in CEC standby still draws roughly half a
+watt — that is what lets it hear the wake. This turns 60–150 W into ~0.5 W; it is
+not a smart plug and does not claim to be.
+
+**The presence decision stays in Home Assistant**, where presence already lives —
+this agent only does what it is told, same as every other entity here. A worked
+example, unverified against a running HA like every other HA snippet in this repo:
+
+```yaml
+# automations.yaml (excerpt). Replace the entity ids with your own.
+- alias: "Living room TV on when the room is occupied"
+  trigger:
+    - platform: state
+      entity_id: binary_sensor.living_room_occupancy
+      to: "on"
+  action:
+    - service: switch.turn_on
+      target:
+        entity_id: switch.thinclient_living_room_display
+
+- alias: "Living room TV off when the room empties"
+  trigger:
+    - platform: state
+      entity_id: binary_sensor.living_room_occupancy
+      to: "off"
+      # Long enough that walking to the kitchen for a glass of water does not
+      # cycle the panel. A TV that flickers off behind you is worse than one
+      # left on, and CEC wake takes a second or two.
+      for: "00:05:00"
+  condition:
+    # Don't black out a film. media_player state comes from the agent's own
+    # playback sensor — see "Home Assistant entities" above.
+    - condition: not
+      conditions:
+        - condition: state
+          entity_id: sensor.thinclient_living_room_playback_state
+          state: "playing"
+  action:
+    - service: switch.turn_off
+      target:
+        entity_id: switch.thinclient_living_room_display
+```
+
+**An Android TV with no thin client attached** is a Home Assistant question
+rather than one for this repo: pair it with the **Android TV Remote** integration
+and swap the `switch.turn_on`/`turn_off` calls above for
+`media_player.turn_on`/`turn_off` on that entity. Waking one over the network
+needs the TV's own "network standby"/"wake on cast" setting enabled — off by
+default on most sets, and the reason a TV that sleeps fine refuses to wake.
+
 ## Manual verification still outstanding
 
 None of this has been run on hardware. In rough order:
@@ -509,3 +583,12 @@ None of this has been run on hardware. In rough order:
     back up from being powered off, and whether the overlay's text is legible
     against a bright/high-contrast real photo rather than the dark backgrounds
     assumed while choosing the text-shadow-only styling in `eww.scss`.
+22. **Display power over CEC has never been run against a real TV.** The command
+    shapes are from `cec-ctl`'s own documentation, not from a session with a
+    panel on the other end. Three things to check on the first set it meets:
+    that CEC standby actually darkens it rather than just blanking the picture
+    (compare the mains draw, not the screen); that waking it comes back to *this*
+    input rather than to whatever it was on before; and that the Sway DPMS half
+    does not leave a "no signal" banner glowing on a set that ignored the CEC
+    standby. `DISPLAY_USE_CEC=false` is the escape hatch if a TV reacts badly to
+    being addressed at all.
diff --git a/hosts/thin-client/agent/thinclient_agent/display_power.py b/hosts/thin-client/agent/thinclient_agent/display_power.py
new file mode 100644
index 0000000..c535871
--- /dev/null
+++ b/hosts/thin-client/agent/thinclient_agent/display_power.py
@@ -0,0 +1,136 @@
+"""Turning the attached TV on and off, so an empty room does not power a panel.
+
+A wall-mounted Android TV driven by one of these thin clients draws 60-150 W while
+it shows a canvas nobody is in the room to look at. This module is what Home
+Assistant calls when presence says the room is occupied or empty — the decision
+lives in HA (an area's occupancy, the same presence system everything else here
+uses), and the doing lives here.
+
+TWO MECHANISMS, IN THIS ORDER
+-----------------------------
+1. **HDMI-CEC** (`cec-ctl`, from v4l-utils). The thin client is the HDMI *source*,
+   so it can put the display into standby and wake it again over the HDMI cable
+   itself. That is the one that actually saves the panel's power, and it needs no
+   network path to the TV, no pairing, no credentials, and no account — it keeps
+   working with the LAN down, which is this project's whole posture. Android TV
+   and Google TV sets implement CEC as "HDMI-CEC", "Bravia Sync", "Anynet+",
+   "Simplink" and a dozen other brand names for the same standard; it usually has
+   to be enabled in the TV's settings once.
+2. **Sway DPMS** (`swaymsg output  power on|off`) as the fallback, and as a
+   belt-and-braces companion: it stops the compositor driving pixels and drops the
+   HDMI signal, which most panels treat as "go to sleep" on their own. It always
+   works because it needs nothing but the compositor already running here — but on
+   its own it may leave a TV showing a "no signal" banner rather than sleeping,
+   which is why CEC is tried first.
+
+Both are attempted on every call unless CEC is switched off, because they fail in
+different ways and neither reports reliably.
+
+WHAT "OFF" HONESTLY MEANS
+-------------------------
+Standby, not disconnected. A TV in CEC standby still draws roughly half a watt to
+keep listening on the HDMI line — that is what makes waking it possible at all.
+This turns 60-150 W of lit panel into ~0.5 W of standby; it is not a smart plug
+and does not pretend to be. If a set is one of the ones that ignores CEC standby
+entirely, you will see it immediately (the panel stays lit) — that is what the
+verification note in hosts/thin-client/README.md is for.
+
+SECURITY POSTURE, UNCHANGED
+---------------------------
+This is another enumerated MQTT command, exactly like the workspace switch and the
+canvas buttons: HA -> MQTT -> a fixed action here. A payload never becomes an argv
+element — `set_power()` takes a boolean, and the device names come from local
+configuration, never from the message. See mqtt_discovery.py's module docstring.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+import shutil
+import subprocess
+
+log = logging.getLogger(__name__)
+
+CEC_TIMEOUT_SECONDS = 10
+
+
+class DisplayPower:
+    def __init__(self, sway, cec_device: str | None = None, outputs: str = "*",
+                 use_cec: bool = True):
+        self.sway = sway
+        # The CEC adapter, e.g. /dev/cec0. Most systems have exactly one and cec-ctl
+        # finds it on its own; this is for the machine that has two.
+        self.cec_device = cec_device or os.environ.get("CEC_DEVICE", "")
+        # Which Sway outputs to power down. "*" is every output, which is right for a
+        # thin client driving one TV; name an output (e.g. "HDMI-A-1") on a machine
+        # where only one of several screens is the TV.
+        self.outputs = outputs or "*"
+        self.use_cec = use_cec
+        self.state = True
+
+    # --- CEC ----------------------------------------------------------------
+    def _cec(self, *args: str) -> bool:
+        binary = shutil.which("cec-ctl")
+        if not binary:
+            log.info("cec-ctl is not installed; falling back to DPMS only")
+            return False
+
+        command = [binary]
+        if self.cec_device:
+            command += ["-d", self.cec_device]
+        command += list(args)
+
+        try:
+            result = subprocess.run(
+                command, capture_output=True, text=True, timeout=CEC_TIMEOUT_SECONDS
+            )
+        except (OSError, subprocess.SubprocessError) as exc:
+            log.warning("cec-ctl %s failed: %s", " ".join(args), exc)
+            return False
+
+        if result.returncode != 0:
+            log.warning("cec-ctl %s: %s", " ".join(args), (result.stderr or "").strip())
+            return False
+        return True
+
+    # --- the one public action ---------------------------------------------
+    def set_power(self, on: bool) -> bool:
+        """Turn the display on or off. Returns the state it believes it left it in.
+
+        Deliberately not idempotent-by-early-return: HA asking for "on" when this
+        object already thinks it is on must still send the wake, because the TV may
+        have been turned off with its own remote and nothing here would know. The
+        state field is for reporting, never for skipping work.
+        """
+        log.info("display: turning the panel %s", "on" if on else "off")
+
+        if self.use_cec:
+            # --to 0 addresses the TV specifically (logical address 0) rather than
+            # broadcasting, so a soundbar or receiver on the same bus is left alone.
+            if on:
+                self._cec("--to", "0", "--image-view-on")
+                # Ask to become the active source too: waking a TV that then shows a
+                # different input is the same as not waking it.
+                self._cec("--to", "0", "--active-source", "phys-addr=0.0.0.0")
+            else:
+                self._cec("--to", "0", "--standby")
+
+        # Always also drive the compositor: on a set that ignores CEC this is what
+        # stops it displaying, and on one that honours CEC it stops the thin client
+        # rendering to a panel nobody is looking at.
+        self.sway.swaymsg("output", self.outputs, "power", "on" if on else "off")
+
+        self.state = on
+        return self.state
+
+    def handle_command(self, payload: str) -> bool:
+        """MQTT payload -> action. Anything that isn't a known ON/OFF word is ignored
+        rather than guessed at, per the enumerated-command rule."""
+        value = (payload or "").strip().upper()
+        if value in ("ON", "TRUE", "1"):
+            return self.set_power(True)
+        if value in ("OFF", "FALSE", "0"):
+            return self.set_power(False)
+        log.warning("display: ignoring unknown power payload %r", payload)
+        return self.state
diff --git a/hosts/thin-client/agent/thinclient_agent/main.py b/hosts/thin-client/agent/thinclient_agent/main.py
index 1400cff..6d4087d 100644
--- a/hosts/thin-client/agent/thinclient_agent/main.py
+++ b/hosts/thin-client/agent/thinclient_agent/main.py
@@ -18,6 +18,7 @@ from .admin_canvas import AdminCanvas
 from .audio_control import AudioControl
 from .capture_control import CaptureControl, find_audio_card
 from .digest_canvas import DETAIL_LEVELS, DigestCanvas
+from .display_power import DisplayPower
 from .input_control import InputControl
 from .mpris_bridge import MprisBridge
 from .mqtt_discovery import Discovery
@@ -158,6 +159,15 @@ def main() -> int:
     sway = SwayControl()
     canvas = DigestCanvas(sway, config.get("DIGEST_WEB_URL", ""))
     admin_canvas = AdminCanvas(sway, config.get("ADMIN_WEB_URL", ""))
+    # The TV's own power. DISPLAY_OUTPUTS names which Sway output(s) are the TV
+    # ("*" on a machine driving one screen); DISPLAY_USE_CEC=false drops to DPMS
+    # only, for a panel whose CEC is broken or deliberately disabled.
+    display = DisplayPower(
+        sway,
+        cec_device=config.get("CEC_DEVICE", ""),
+        outputs=config.get("DISPLAY_OUTPUTS", "*"),
+        use_cec=str(config.get("DISPLAY_USE_CEC", "true")).strip().lower() == "true",
+    )
     apps = build_apps(config)
     audio = AudioControl(sway.session_env)
     capture = CaptureControl()
@@ -216,6 +226,13 @@ def main() -> int:
         admin_canvas.show()
         discovery.publish_workspace(WS_ADMIN)
 
+    def on_display_power(payload: str) -> None:
+        # Driven by an HA automation on room occupancy (see hosts/thin-client's
+        # README): a TV showing a canvas to an empty room is the single largest
+        # power draw this machine is attached to. The decision stays in HA, where
+        # presence already lives; this only does what it is told.
+        discovery.publish_display_power(display.handle_command(payload))
+
     def on_audio_output(payload: str) -> None:
         discovery.publish_audio_output(audio.select(payload))
 
@@ -264,6 +281,7 @@ def main() -> int:
         discovery.register_media_player(mpris.handle_command, mpris.set_volume)
         discovery.register_digest(on_show_digest, on_detail_level, DETAIL_LEVELS, canvas.detail_level)
         discovery.register_admin_canvas(on_show_admin_canvas)
+        discovery.register_display_power(on_display_power, display.state)
         discovery.register_app_launchers(apps, on_launch)
         discovery.register_workspace_select(WORKSPACES, on_workspace, WS_DIGEST)
         # audio.apply_preferred() already ran once at startup (before MQTT was even
diff --git a/hosts/thin-client/agent/thinclient_agent/mqtt_discovery.py b/hosts/thin-client/agent/thinclient_agent/mqtt_discovery.py
index 62961b1..30fa288 100644
--- a/hosts/thin-client/agent/thinclient_agent/mqtt_discovery.py
+++ b/hosts/thin-client/agent/thinclient_agent/mqtt_discovery.py
@@ -41,6 +41,7 @@ class Discovery:
         self.capture_source_state_topic = f"{self.base}/capture/source/state"
         self.remote_target_state_topic = f"{self.base}/remote/target/state"
         self.input_text_state_topic = f"{self.base}/input/text/state"
+        self.display_power_state_topic = f"{self.base}/display/power/state"
         self._handlers: dict[str, Callable[[str], None]] = {}
 
         self.device = {
@@ -211,6 +212,35 @@ class Discovery:
             },
         )
 
+    def register_display_power(self, on_set, current: bool) -> None:
+        """The TV's own power, as a switch HA can drive from room presence.
+
+        A switch rather than a button, because the interesting automation is "this
+        area became unoccupied" -> off, "somebody walked in" -> on, and that needs a
+        state HA can read back as well as set. The state is what this agent last
+        did, not what the panel reports: CEC gives no reliable read-back, and a
+        state that lies about the TV having been turned off by its own remote is
+        better than one that blocks the next wake — see display_power.set_power().
+        """
+        self._publish_config(
+            "switch",
+            "display_power",
+            {
+                "name": "Display",
+                "command_topic": self._command_topic("display/power/set", on_set),
+                "state_topic": self.display_power_state_topic,
+                "payload_on": "ON",
+                "payload_off": "OFF",
+                "icon": "mdi:television",
+            },
+        )
+        self.publish_display_power(current)
+
+    def publish_display_power(self, on: bool) -> None:
+        self.client.publish(
+            self.display_power_state_topic, "ON" if on else "OFF", qos=1, retain=True
+        )
+
     def register_app_launchers(self, apps, on_launch) -> None:
         for key, app in apps.items():
             self._publish_config(
diff --git a/identity/README.md b/identity/README.md
index 9e2e695..5ec8286 100644
--- a/identity/README.md
+++ b/identity/README.md
@@ -166,7 +166,8 @@ http://:8098/admin.html?api=http://:8097&token=
 ```
 
 Four tabs: **People** (tap anyone to edit every field, their devices, their door
-rights and their chores), **Prune**, **History**, and **Access log**.
+rights, their chores and which digests get generated for them), **Prune**,
+**History**, and **Access log**.
 
 > The token is in the URL, exactly like the two kiosk pages — that's the existing
 > pattern here, not a new decision, and it's why this service treats the token as the
@@ -393,6 +394,69 @@ where "how do I relate to this specific household member" facts belong, not dupl
 into `chores/`'s own database. Assignments get their own table only because they're
 many-per-person, not because they belong anywhere else.
 
+## "Who just spoke?" — automatic recognition for the voice path
+
+`GET /speaker?area=` answers who is asking, so a spoken "play my digest"
+shows *that person's* digest without anybody typing or spelling a name, and
+without the assistant interrogating the room.
+
+It resolves from the two presence signals this service already fuses:
+
+1. **One person in that area** — that's them.
+2. **Several** — the one a camera recognised most recently, if any did inside
+   `FACE_PRESENCE_WINDOW_SECONDS`. A face seen thirty seconds ago is the best
+   evidence available that a particular person is the one standing there talking.
+3. **Nobody in the area but exactly one person home** — them.
+4. **Otherwise `person` is null**, with the candidates named.
+
+**An unresolved answer means show less, not ask.** The caller's fallback is a
+digest with no personal section — never a prompt, never a guess. Automating the
+recognition is only defensible *because* the ambiguous case still fails closed;
+that is the same rule the registry applies to registration, applied to display.
+
+**Two things it is not.** It is not speaker identification — nothing here listens
+to a voice; it works out who is *in the room*, so two people in a kitchen where
+one was just recognised by a camera resolve to that one even if the other spoke.
+And it is not a display trigger: nothing in this project shows a digest because
+somebody walked past a screen. The canvas opens when it is asked for, and this
+endpoint only answers *by whom*.
+
+## Digest settings — owned here, used by `digest-engine/`
+
+Which of `digest-engine`'s four digests get generated for a person — **network**,
+**household**, **social** (its `personal` section: mail and messages) and **political /
+news** — is a per-person setting on this registry, editable in the admin panel's person
+editor. Same reasoning as the chore fields above: it is a standing fact about a
+household member, and `identity` is already this project's source of truth for those,
+so it lives as a column on `people` rather than in a second database over in
+`digest-engine`.
+
+`digest-engine` reads `GET /digest-preferences` once at the start of each of its four
+daily runs — the same shape-for-the-consumer pattern as `GET /chore-assignments` — and:
+
+- generates the **union** of what the household asked for. A section nobody has ticked
+  costs no LLM call, and `digest-engine` also skips ingesting the sources only that
+  section reads (turn the political one off for everybody and its news, financial and
+  flight/naval fetches stop happening at all).
+- filters each surface to the person Home Assistant resolved, using the per-person sets
+  this endpoint hands over.
+
+**The second half is a display filter, not an access control**, and it should not be
+described to anyone as privacy: `digest-web` serves the rendered digest read-only to
+anything on the LAN, so an unticked section is off somebody's screen and out of their
+narration, not out of their reach. The part that genuinely does not exist anywhere is
+the part that was never generated.
+
+Two deliberate defaults:
+
+- **Never set means all four.** A household that never opens this panel keeps exactly
+  the digest it had before the setting existed. An explicitly empty set is different and
+  is honoured as written — "generate nothing for me" is a real answer, and the column
+  stores `''` rather than `NULL` to keep the two distinguishable.
+- **This service being unreachable means all four too.** `digest-engine` treats any
+  failed lookup as "no preferences known" and generates everything, because one
+  container being down must never silently cost the household its whole digest.
+
 ## Camera face recognition — a second presence signal, never a registration one
 
 If Tapo pan/tilt cameras are wired into Frigate as additional camera sources
@@ -495,7 +559,7 @@ not network placement.
 | `POST /register` | `{"name", "device_id", "photo_id"?, "entity_id"?, "no_device"?}` -> registers, or returns a reason it couldn't (see above) |
 | `POST /register/guest` | `{"device_id", "photo_id"?}` -> registers "Guest N", no name needed |
 | `GET /people` | admin/audit list of every person: identifiers, device grants, chore assignments, `nickname`/`speak_name`, `last_visit_at`, `visit_count`, `currently_home_since` |
-| `POST /people/` | edit any editable field — `{"name"?, "nickname"?, "note"?, "chore_exempt"?, "chore_reminder_style"?, "notify_on_arrival"?, "announce_arrivals"?, "notify_topic"?, "clear_photo"?}`. Omitted keys are left alone |
+| `POST /people/` | edit any editable field — `{"name"?, "nickname"?, "note"?, "chore_exempt"?, "chore_reminder_style"?, "notify_on_arrival"?, "announce_arrivals"?, "notify_topic"?, "digest_sections"?, "clear_photo"?}`. Omitted keys are left alone |
 | `POST /people//test-notification` | push a test message to this person's ntfy topic, to prove it works |
 | `GET /people//photo` | the person's profile picture (raw JPEG) — their most recent registration photo |
 | `POST /people//identifiers` | `{"entity_id"}` — attach an identifier by hand (a fixed BLE tag not in range yet). Still enforces `TRUSTED_ENTITY_PREFIXES` |
@@ -512,6 +576,9 @@ not network placement.
 | `GET /device-access/events?limit=` | the audit log of every access check, allowed and denied |
 | `GET`/`POST /people//chore-assignments` | read/replace this person's assigned chore types (`{"chore_types": [...]}`) |
 | `GET /chore-assignments` | the same facts keyed by chore type — the shape `chores/` reads |
+| `GET`/`POST /people//digest-settings` | read/replace which digests are generated for this person (`{"digest_sections": ["network", "household", "personal", "political"]}`) |
+| `GET /digest-preferences` | every person's set plus `wanted`, the union — the shape `digest-engine/` reads |
+| `GET /speaker?area=` | who just spoke in that area — `{"person", "reason", "candidates"}`, `person` null when it can't tell (see above) |
 | `GET /floorplan` | every level and its drawn rooms (polygons in normalised 0–1 coordinates) |
 | `POST /floorplan/levels` | create or rename a level — `{"id"?, "name", "sort_order"?}` |
 | `DELETE /floorplan/levels/` | remove a level and its rooms |
@@ -599,3 +666,11 @@ no way to send an `Authorization` header.
     *outside* the `condition: template` guard would open the door regardless of what
     this service answered. `identity` cannot enforce that from its side — it only ever
     answers the question.
+15. **The digest-section list in `frontend/admin.js` is kept in step with `server.py`'s
+    `DIGEST_SECTIONS` by hand**, like `CHORE_TYPES` above it. Unlike `CHORE_TYPES` the
+    server does validate these, so drift shows up as a visible refusal rather than a
+    silent bad write — but the labels ("Social", "Political / news") are the panel's
+    own words and match nothing on the server, so renaming a section in
+    `digest-engine` needs all three places checked. `digest-engine`'s end of it (which
+    sections a run actually generates, and what a stopped `identity` does to a run) is
+    on that component's own verification list.
diff --git a/identity/frontend/admin.html b/identity/frontend/admin.html
index 0aaf8f5..40adc24 100644
--- a/identity/frontend/admin.html
+++ b/identity/frontend/admin.html
@@ -34,7 +34,7 @@
   

People

-

Tap a person to edit every field, their devices, door rights and chores.

+

Tap a person to edit every field, their devices, door rights, chores and digests.

Loading…

@@ -223,6 +223,20 @@ +
+ Digests +
+ Generate for this person +
+

+ A digest nobody has ticked is never generated at all — no ingestion of it + reaches the LLM. Untick all four to opt out entirely. Which sections a + screen then shows is a display filter, not a lock: anyone on the LAN can + read the whole digest file that digest-web serves. +

+
+
+
Arrival notifications