# digest-engine

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 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.

**Everything here is read-only.** No replies, no marking mail read or archived, no
calendar or Grocy writes, no message-platform writes of any kind. There is no
mutation path in this component by construction — that is a hard requirement from
the plan, not a default.

## Layout

```
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
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)
compose-fragment.yaml.txt     compose blocks to splice into setup-container-host.sh
```

## Configure

```sh
cp digest-engine/digest-engine.env.example /opt/smart-home/digest/digest-engine.env
chmod 600 /opt/smart-home/digest/digest-engine.env
$EDITOR /opt/smart-home/digest/digest-engine.env
```

Every source is off by default. Turn on only what you have credentials for — a
disabled or misconfigured source logs a warning and contributes nothing, and can
never take the rest of the run down with it.

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=<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
never prompt for anything.

**Telegram** — creates the session file `telegram_ingest.py` then reuses
non-interactively. You will be asked for your phone number, the code Telegram
sends, and your 2FA password if the account has one:

```sh
docker compose run --rm --entrypoint python digest-engine ingest/telegram_login.py
```

**WhatsApp** (only if you have opted in) — start the bridge and scan the QR code
it prints to its own logs with **WhatsApp -> Linked devices -> Link a device**:

```sh
docker compose up -d whatsapp-bridge
docker compose logs -f whatsapp-bridge
```

The session persists in the bridge's `/data/.wwebjs_auth` volume, so this is a
one-time scan unless WhatsApp invalidates the link.

## Run once, manually

```sh
docker compose run --rm digest-engine
```

Output lands in `output/<run-timestamp>/`:

- `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, 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
digest with no coordination with the scheduler.

## WhatsApp — read this before enabling

There is **no officially sanctioned way** to read your own WhatsApp messages
programmatically. `whatsapp-bridge` runs a real Chromium logged into
web.whatsapp.com as a linked device, deliberately **headful** under Xvfb because
WhatsApp's automation detection specifically fingerprints headless Chrome. That
is a meaningful mitigation. It is **not** immunity: this is still automated use of
a personal account and accounts do get banned for it, historically on a ~2–8 week
timescale.

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 rest of the
  digest works fine without it.

## Manual verification still outstanding

None of this has been run against real credentials or real accounts. Before
trusting a scheduled run, verify by hand:

1. Each source in isolation, e.g.
   `docker compose run --rm --entrypoint python digest-engine -c "import logging,os; logging.basicConfig(level='INFO'); from ingest import news_rss; print(len(news_rss.fetch(6)))"`.
2. That the IMAP mailbox shows **no** newly-read messages after a run (the folder
   is opened `readonly=True`, but confirm it against your provider).
3. That the Ollama model actually honours `format: "json"` and the schema — check
   `output/<run>/digest.json` for `"degraded": true`, which marks a section that
   fell back to plain text.
4. That both templates render: open `http://<host>:8091/full.html` and
   `http://<host>:8091/compact.html`.
5. The malformed-output fallback, by hand-editing `output/latest.json` into
   invalid JSON and reloading — the page must show a `<pre>` dump, never a blank
   screen.
6. The feed URLs in `curated-feeds.opml` — several mainstream outlets have
   changed or restricted their public RSS.
7. That the Nextcloud app password works over CalDAV and that recurring events
   land on the right day (the expansion path above is the one most likely to
   differ between Nextcloud versions).
8. The evening recipe, with `DIGEST_FORCE_EVENING=true` on a manual run — then
   confirm in Grocy that **nothing** was added to its shopping list and no stock
   moved.
9. The unviewed-digest merge: run once, do NOT show it on a thin client, run again
   (or set `DIGEST_LOOKBACK_HOURS`/wait) — confirm the second run's `context.json`
   has `"merged_unviewed_previous_run": true` and its digest actually carries
   forward the first run's content. Then show a digest on a thin client and run a
   third time — confirm that one merges nothing.
10. The counter run, deliberately: hand-edit a generated document before it's
    written (or patch `synth/counter_run.py` to run against a document with a
    fabricated quote spliced in) and confirm it actually gets dropped, not waved
    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=<name>` 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
    five 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. The roll-call (`political-struggles`) is the window
    to read most sceptically on that first run: confirm every line traces to an
    entry that is actually in the context rather than to the model's own memory
    of a famous strike, and that a long-running dispute stays on the list across
    consecutive runs instead of being dropped as stale.
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 five questions

The political digest answers five 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.
5. What class struggles are **currently going on around the world** — the
   `political-struggles` roll-call. This one is an inventory, not a curation:
   one line per live strike, occupation or mass movement, kept on the list while
   it runs even in a quarter that carried no fresh news of it, which is what the
   archive's `times_seen`/`first_seen_at` annotations are for. It is deliberately
   the one window where a story being old is not a reason to drop it, and it
   survives the compact pass (shortened, and saying it is shortened) because a
   roll-call folded into a summary line stops being a roll-call. Everything on it
   still has to come from this run's context — the prompt says in as many words
   that the model's own knowledge of the world is not a source, since an
   inventory question is exactly the shape of prompt that invites one.

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

`ingest/flight_traffic.py` and `ingest/naval_traffic.py` feed the **political**
section as extra `category`-tagged evidence, alongside news and financial data.
They are not a new digest section, and `synth/prompts/political.md` is explicitly
told to drop them when they corroborate nothing. Both are off by default.

**Air traffic** — OpenSky Network, bounded boxes over configured regions.
FlightRadar24 and ADS-B Exchange were not used: FR24 prohibits scraping and sells
API access, and ADS-B Exchange ended its freemium RapidAPI tier on 2025-03-01
(paid from $10/month). Before enabling, read the licensing note in
`digest-engine.env.example` — OpenSky's Terms of Use require a prior written
agreement for use of the REST API "in any operational capacity", which arguably
covers a timer-driven digest. Attribution to OpenSky is required.

**Naval traffic** — aisstream.io, a free keyed WebSocket stream, sampled briefly
per run. Read what it is for before enabling it: **AIS cannot show naval force
posture.** Warships sail with AIS off routinely, and the "military ops" AIS type
code is self-declared. What it shows is *merchant* traffic through chokepoints,
which is genuinely useful in the negative — shipping abandoning a route arrives
as freight, insurance and fuel costs. AISHub was not used: it is still
contribute-to-access and needs an AIS receiver this project does not have.
MarineTraffic, VesselFinder and Spire are paid.

**Military movement — deliberately not built as a data source.** There is no free
structured feed of military movements. ACLED is retrospective conflict-*event*
data, needs registration, and its EULA forbids redistribution and non-transformative
derivative works; UCDP is keyless but lags by roughly a month; everything
real-time is either commercial or a person on social media. Rather than invent an
integration, the military-movement signal comes from OSINT/defence outlets added
to `feeds/curated-feeds.opml` under `category="osint_military"`, which reuses the
existing news ingestion with no new code. Verified live 2026-07-28; Liveuamap has
no free RSS feed (`/rss` is a paid-API signup page) and ISW and Long War Journal
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, 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.
The `os-intrusion-detection-content-*` plugins are ruleset *content* only, and
ET Open needs none of them. Suricata is nonetheless **off** on a stock install;
enable it and download a ruleset first, or every run will report `ids_status`
and no alerts, which is the honest answer and not a quiet network.

**It is a pull, like every other source here.** Two endpoints, both read-only in
effect: `GET /api/ids/service/status` and `POST /api/ids/service/query_alerts`.
The latter is a POST only because that is how OPNsense routes filtered queries —
it runs `queryAlertLog.py`, which reads `/var/log/suricata/eve.json` backwards.
No SSH or file access to the firewall is needed, and no push agent runs on it.

Three limits worth knowing before you read the output:

- **No server-side time filter.** `searchPhrase` matches signature/action/src/dst
  text only. Rows come back newest-first, so the window is applied client-side by
  paging until a row falls out of it, bounded by `max_alerts_scanned`. When that
  bound is hit — or when the alert log rotated mid-window — the entry carries
  `window_truncated: true` and the prompt is told the counts are a lower bound.
- **No severity.** OPNsense flattens each eve.json record to signature + SID +
  action before returning it, discarding `alert.severity` and `alert.category`.
  `get_alert_info` uses the same flattening, so it does not help. Alerts are
  ranked by frequency, and the household prompt is told it cannot see severity.
- **Page-level ACLs.** The "Services: Intrusion Detection" privilege matches
  `api/ids/*`, which covers start/stop/reconfigure/drop-alert-log as well as the
  alert query. OPNsense has no narrower built-in privilege, so the read-only
  guarantee is enforced by this code (which calls two endpoints and no others)
  and not by the firewall. Give the API key its own user with that one privilege
  and nothing else, and treat it as a credential that could restart your IDS if
  it leaked — the container host and the firewall are on the same flat LAN, since
  no VLAN segmentation is implemented in this project yet.

**Raw packet captures — deliberately not done here.** OPNsense does expose
Interfaces: Diagnostics: Packet Capture over the API
(`/api/diagnostics/packet_capture/{set,start,stop,remove}`), but every one of
those is a POST that writes a job file and spawns `tcpdump` on the firewall.
Starting a capture is a write action on someone else's router and is barred by
this component's read-only invariant. Downloading and parsing pcaps into the
digest would also mean hand-rolling malware detection over raw packets, which is
strictly worse than reading the verdicts of a maintained ruleset that already
inspected the same traffic in real time.

If you still want a rotating raw capture for manual inspection, keep it **on
OPNsense**, e.g. a `tcpdump -G 600 -W 12 -w /var/log/captures/cap-%F-%H%M.pcap`
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 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

`ingest/caldav.py` and `ingest/grocy.py` are what the household section actually
runs on. Both are off by default and both need one credential created by hand.

**Calendar** — Nextcloud over CalDAV (Phase 8), via the maintained `caldav`
library rather than hand-written REPORT XML. Auth is a **Nextcloud app
password** (Settings → Security → Devices & sessions → Create new app password),
not the account password and not OAuth2 — mandatory once 2FA is on, since the DAV
endpoints cannot prompt for a second factor, and revocable on its own regardless.
Point `CALDAV_URL` at the DAV root (`https://<host>/remote.php/dav`) and the
client discovers the principal's calendars from there.

The window is deliberately asymmetric — `DIGEST_LOOKBACK_HOURS` backwards, so
this morning's appointment and anything still running are still visible, and
`CALDAV_LOOKAHEAD_HOURS` (default 48) forwards, because a calendar is mostly
useful in the future tense. Recurring events are requested **expanded**, so a
weekly standup arrives as the occurrence in this window rather than as the master
event with an RRULE; if a server rejects expansion outright, the search is
retried without it and a recurring series shows up as its master event.

**Kitchen inventory** — Grocy (Phase 7), reached at `http://grocy` on the shared
compose network (port 80 inside the container; the published 9283 is not
involved). One endpoint does most of the work: `GET /api/stock/volatile`, which
returns `due_products`, `overdue_products`, `expired_products` and
`missing_products` directly. **Watch the naming** — Grocy renamed
`expiring_products` → `due_products` in v3.0.0, so older third-party examples are
wrong against a current install. Chores and batteries come from `GET /api/chores`
and `GET /api/batteries`; both use `2999-12-31 23:59:59` as a "no schedule"
sentinel, which is filtered out rather than reported as a due date.

Auth is a `GROCY-API-KEY` header, generated at Grocy → Settings → Manage API
keys. **A Grocy API key is not scoped**: it carries that user's full read *and*
write rights, so give this one its own Grocy user, and note that the read-only
guarantee is enforced by `ingest/grocy.py` calling nothing but GETs — the
module's docstring names every write endpoint it deliberately does not use — and
not by Grocy.

**No compose or systemd changes were needed for either.** digest-engine and
grocy are already on the same default compose network, so the container name
resolves; Nextcloud is external and reached over its normal URL; and the
credentials are ordinary env vars in the `digest-engine.env` the service already
loads.

## The evening recipe suggestion

On one run a day — `DIGEST_EVENING_HOUR`, default `18` — the household section
also suggests a dish built around whatever Grocy says is about to go off, plus a
shopping list for the ingredients that dish needs and the house does not have.
The other three runs omit it entirely rather than padding it in.

**It is a suggestion, and nothing else.** Nothing is written to Grocy: no item is
added to its shopping list, no stock is consumed, no order is placed anywhere.
Grocy's API supports all of that with the same key and this component uses none
of it, per the read-only invariant above. You read the list and go shopping.

Which run is "evening" is derived from the container's local wall clock, not
passed in by the caller. The systemd unit runs a bare
`docker compose run --rm digest-engine` with no arguments and a manual run is the
same command, so an argument or a unit-specific env var would have to be threaded
through both and would silently misbehave on a hand-run digest; the container
already has the host's `TZ` and `/etc/localtime`, which is the same clock the
timer's `OnCalendar` fires against. The run is attributed to the most recent
`DIGEST_SCHEDULE` slot at or before now rather than to an exact hour match,
because the timer is `Persistent=true` — a host asleep at 18:00 fires late, and
an exact match would drop the feature on precisely the days the digest is late.

Set `DIGEST_FORCE_EVENING=true` for a one-off run to test it at any hour.
Keep `DIGEST_SCHEDULE` in step with the variable of the same name in
`tools/setup-container-host.sh`, which is what sets the
timer.

## Merging an unviewed digest into the next one

If nobody actually looked at a run before the next one was due, its content is
folded into the new run instead of being silently thrown away — see
`viewed_tracker.py`, `run.py`'s `should_merge()`/`previous_section_document()`, and
the merge instruction `synth/llm_client.py` adds to the prompt when it applies.

**"Viewed" means a thin client actually displayed the full canvas** — the "Show
digest canvas" button or a voice-resolved "play my digest" request, both of which go
through `thinclient_agent/main.py`'s `on_show_digest()`, which publishes a retained
`{"viewed_at": ...}` to `smarthome/digest/viewed` on the same Mosquitto broker
everything else in this project already shares. **The compact HA-dashboard iframe
view does not count** — it's a browser rendering a static page, with no path back to
MQTT at all, so leaving it open on a phone can never mark a digest viewed.

Each run compares that timestamp against the previous run's `generated_at`
(`output/latest/digest.json`). If the previous run is newer than the last time
anything was viewed — or nothing has ever been marked viewed, or no previous run
exists yet — this run proceeds exactly as before. Otherwise, each section's own
previous content (from the `full` detail level, the richest version) is handed to
that section's synthesis pass as `previous_unviewed_digest`, with an instruction to
combine it with the new material into one digest rather than repeating or discarding
either — nothing is dropped, but nothing doubles up either.

If MQTT is unreachable, `paho-mqtt` isn't installed, or the retained message can't be
parsed, `viewed_tracker.last_viewed_at()` returns `None`, which is treated the same
as "viewed" — the safer of the two wrong answers, since it costs at most one merge
that should have happened, rather than gluing every future run onto the last
forever. `MQTT_VIEWED_WAIT_SECONDS` (default 3) bounds how long a run will wait for
that retained message before moving on, so a dead broker never stalls a digest run.

**Not yet run against a real broker or a real thin client** — the retained-message
round trip, the `on_show_digest` publish, and a genuine multi-cycle unviewed→merged
sequence are all still on the manual-verification list.

## The counter run — a final filter against hallucination

Before anything is written to `output/`, every generated document is checked
by a second, independent LLM call (`synth/counter_run.py`) against the exact
same context it was generated from. This is the final filter the plan calls
for against false or unsourced information reaching the digest — it is not a
substitute for the "No speculation" instructions already in each prompt, it's
the backstop for when those instructions don't work.

It checks, per window: is every quotation an actual excerpt of something in
the context (not a plausible-sounding invention); is every figure, date, or
name traceable to something in the context; does every named theoretical
connection (Lenin's imperialism, Marx's labour theory of value, etc.)
correspond to a real event the context actually describes that way; does
every stated correlation between two data sources actually have both halves
present, not one assumed.

**This does not mean second-guessing the digest's Marxist framing itself.**
The counter run shares the same RCI-derived theoretical basis as the document
it's checking (see `synth/prompts/counter_run.md`) — its job is to confirm the
underlying facts are real and a theoretical reading of them is a genuine
structural match, not to apply a bourgeois-neutral standard of "objectivity"
that would flag correct class analysis as unverifiable "opinion." That would
smuggle in a different politics than the one this digest is written from,
which is exactly the kind of error this pass exists to prevent, not commit.

What happens to something it flags:

- A specific window it can't ground is dropped; the rest of the document is
  kept.
- Narration it can't ground is cleared to empty — better silent than a false
  claim read aloud by the TTS voice.
- A quote the model itself claims is "found in context" is also checked
  mechanically (a plain substring search against the same context), and
  overridden if it isn't actually there — the one claim type this doesn't
  have to take the verifying call's own word for.
- If every window in a document gets dropped, the whole section is replaced
  with an honest "withheld pending verification" placeholder rather than
  shown empty or not at all.
- If the counter run can't run at all (Ollama unreachable a second time, an
  unparseable verdict), the original document is kept but marked
  `unverified` — not silently passed through unchecked, and not blanked
  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 (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
other. Set `COUNTER_RUN_MODEL` if you want verification done by a different
(e.g. larger) model than the one that generated the digest.

**Not yet run for real** — whether the counter-run prompt actually catches a
genuinely hallucinated quote, versus over-flagging real ones, needs checking
against actual model output before this can be trusted as more than
plausible-sounding on paper.
