# digest-engine configuration template.
#
# Copy this to the container host as (for example) /opt/smart-home/digest/digest-engine.env,
# fill in the real values, and `chmod 600` it. That file is what the compose
# service loads via env_file; it must never be committed (the repo .gitignore
# already covers .env / *.env, keeping this in line with the restic-password
# convention in hosts/container-host/scripts/setup-container-host.sh).
#
# Every toggle below is the string "true" or "false", matching the ENABLE_X
# convention used throughout this repo.

# ---------------------------------------------------------------------------
# Run behaviour
# ---------------------------------------------------------------------------
# How far back each source looks. Should match the digest cadence (4x/day = 6h).
DIGEST_LOOKBACK_HOURS=6
DIGEST_OUTPUT_DIR=/output
LOG_LEVEL=INFO

# Which slot a run belongs to is derived from the container's local wall clock —
# it is NOT passed in by systemd, because the timer runs a bare
# `docker compose run --rm digest-engine` and a manual run is the same command.
# Keep DIGEST_SCHEDULE in step with DIGEST_SCHEDULE in
# hosts/container-host/scripts/setup-container-host.sh, which is what actually
# sets the timer's OnCalendar.
DIGEST_SCHEDULE=00,06,12,18
# The "I'm done working" slot. Only this run gets the recipe + shopping-list
# suggestion in the household section; the other three omit it entirely. Must be
# one of the DIGEST_SCHEDULE hours or no run will ever qualify.
DIGEST_EVENING_HOUR=18
# Testing override — makes any run behave as the evening run, so the recipe
# 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
# ---------------------------------------------------------------------------
OLLAMA_HOST=http://llm-host:11434
OLLAMA_MODEL=qwen2.5:14b-instruct
OLLAMA_TIMEOUT=600
OLLAMA_TEMPERATURE=0.4

# ---------------------------------------------------------------------------
# Counter run — the final hallucination filter, see synth/counter_run.py.
# Runs against OLLAMA_HOST/OLLAMA_TIMEOUT above unless overridden here.
# ---------------------------------------------------------------------------
# Leave unset to verify with the same model that generated the digest.
# Set this if you want verification done by a second, larger/different model.
COUNTER_RUN_MODEL=
# Lower than OLLAMA_TEMPERATURE on purpose: verification should be as
# deterministic as the model allows, not creative.
COUNTER_RUN_TEMPERATURE=0.1

# ---------------------------------------------------------------------------
# "Was the last digest viewed?" — see viewed_tracker.py. Same Mosquitto broker
# every HA/thinclient-agent already uses; digest-engine only ever subscribes,
# never publishes, to the smarthome/digest/viewed topic. Defaults assume the
# same-Docker-network reachability every other container-host service relies on.
# ---------------------------------------------------------------------------
MQTT_BROKER_HOST=mosquitto
MQTT_BROKER_PORT=1883
MQTT_USERNAME=
MQTT_PASSWORD=
# How long to wait for a retained message before giving up and assuming "viewed"
# (the safe default — see viewed_tracker.last_viewed_at()'s docstring).
MQTT_VIEWED_WAIT_SECONDS=3

# ---------------------------------------------------------------------------
# Ingestion toggles — every source is off until you have provisioned its
# credentials. A disabled or misconfigured source logs a warning and contributes
# nothing; it never fails the run.
# ---------------------------------------------------------------------------
ENABLE_EMAIL_INGEST=false
ENABLE_SIGNAL_INGEST=false
ENABLE_TELEGRAM_INGEST=false
ENABLE_DISCORD_INGEST=false
# Off by default — read digest-engine/README.md before enabling, real ban risk
# 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.
ENABLE_FLIGHT_TRAFFIC_INGEST=false
# Off by default because it does nothing without AISSTREAM_API_KEY.
ENABLE_NAVAL_TRAFFIC_INGEST=false
# Off by default on purpose — this one talks to your firewall. Read the OPNsense
# section at the bottom of this file and IDSconf.json.example before enabling.
ENABLE_OPNSENSE_IDS_INGEST=false
# The two household sources. Off until you have made a Grocy API key and a
# Nextcloud app password — see their sections below.
ENABLE_CALDAV_INGEST=false
ENABLE_GROCY_INGEST=false

# ---------------------------------------------------------------------------
# Email (IMAP)
# ---------------------------------------------------------------------------
# Auth is an app-specific password, NOT OAuth2. For Gmail: enable 2FA, then
# generate one at myaccount.google.com -> Security -> App passwords. Other
# providers have an equivalent. The mailbox is opened read-only, so nothing is
# ever marked \Seen, archived or deleted.
EMAIL_IMAP_HOST=imap.gmail.com
EMAIL_IMAP_PORT=993
EMAIL_USERNAME=you@example.com
EMAIL_PASSWORD=
EMAIL_FOLDER=INBOX
# Overrides DIGEST_LOOKBACK_HOURS for mail only; leave unset to inherit it.
#EMAIL_LOOKBACK_HOURS=6

# ---------------------------------------------------------------------------
# Signal
# ---------------------------------------------------------------------------
# digest-engine does NOT run signal-cli. Add your own signal-cli JSON-RPC daemon
# as a separate compose service on the same network (linked as a secondary device
# to your account) and point this at it. See
# https://github.com/AsamK/signal-cli/blob/master/man/signal-cli-jsonrpc.5.adoc
SIGNAL_CLI_URL=http://signal-cli:8080
# Your registered number in E.164 form; only needed if the daemon serves more
# than one account.
SIGNAL_ACCOUNT=
SIGNAL_RECEIVE_TIMEOUT=10

# ---------------------------------------------------------------------------
# Telegram (Telethon / MTProto userbot)
# ---------------------------------------------------------------------------
# Get the API id/hash from https://my.telegram.org -> API development tools.
# The session file is created ONCE, interactively:
#   docker compose run --rm --entrypoint python digest-engine ingest/telegram_login.py
TELEGRAM_API_ID=
TELEGRAM_API_HASH=
TELEGRAM_SESSION_PATH=/data/telegram.session
TELEGRAM_MAX_DIALOGS=25
TELEGRAM_MAX_MESSAGES_PER_DIALOG=50

# ---------------------------------------------------------------------------
# Discord
# ---------------------------------------------------------------------------
# A bot token from https://discord.com/developers/applications, with the
# "Message Content" privileged gateway intent enabled. The bot can only ever see
# guilds it was invited to — Discord enforces that, not this code.
DISCORD_BOT_TOKEN=
DISCORD_MAX_MESSAGES_PER_CHANNEL=50
DISCORD_TIMEOUT=120

# ---------------------------------------------------------------------------
# WhatsApp (whatsapp-bridge sidecar)
# ---------------------------------------------------------------------------
# Path, inside the digest-engine container, of the JSON-lines file the bridge
# writes. Drained (renamed, read, deleted) once per run.
WHATSAPP_MESSAGES_PATH=/data/whatsapp-bridge/messages.jsonl

# ---------------------------------------------------------------------------
# News
# ---------------------------------------------------------------------------
NEWS_OPML_PATH=/app/feeds/curated-feeds.opml
NEWS_MAX_ENTRIES_PER_FEED=15

# ---------------------------------------------------------------------------
# Financial
# ---------------------------------------------------------------------------
# Free FRED API key: https://fred.stlouisfed.org/docs/api/api_key.html
FRED_API_KEY=
# Comma-separated FRED series IDs. Default UNRATE = US unemployment rate.
# Browse more at https://fred.stlouisfed.org (e.g. UNRATE,CPIAUCSL,FEDFUNDS).
FRED_SERIES=UNRATE
# Comma-separated Stooq symbols (keyless CSV, no rate cap worth worrying about).
# ^spx / ^dax / ^ndq = indices, cl.f = WTI crude futures, gc.f = gold,
# aapl.us style = individual US equities.
STOOQ_SYMBOLS=^spx,^dax,cl.f

# ---------------------------------------------------------------------------
# Air traffic (OpenSky Network)
# ---------------------------------------------------------------------------
# Extra evidence for the political section, not a section of its own.
#
# LICENSING — decide this before setting ENABLE_FLIGHT_TRAFFIC_INGEST=true.
# OpenSky's Terms of Use (https://opensky-network.org/about/terms-of-use) license
# the data for non-profit research/education and personal use, and say that using
# the REST API "in any operational capacity — including integration into a live
# product, service, or automated system (even if only internal)" needs a prior
# written agreement. A timer-driven digest is arguably such a system. Personal,
# non-commercial household use is the intended case here; if that is not you,
# mail contact@opensky-network.org first. Attribution to OpenSky is required.
#
# RATE LIMITS — a daily credit budget per endpoint. Anonymous (by IP): 400/day,
# current state only, 10s resolution. With client credentials: 4,000/day, 5s
# resolution. Running an ADS-B feeder: 8,000/day. A /states/all call costs 1
# credit for a box <=25 sq deg, 2 for 25-100, 3 for 100-400, 4 for global. The
# default region list costs ~12 credits/run, ~48/day at 4 runs/day — fine even
# anonymously, which is why the credentials below are optional.
#
# Optional. Create an API client at https://opensky-network.org/my-opensky (the
# account page). Username/password basic auth stopped working on 2026-03-18.
OPENSKY_CLIENT_ID=
OPENSKY_CLIENT_SECRET=
# REVIEW THIS LIST. Semicolon-separated `Name:lamin,lomin,lamax,lomax` boxes in
# decimal degrees. What ships below is a placeholder set of currently-tense
# regions, in the same spirit as the placeholder outlets in curated-feeds.opml —
# conflict zones move and nothing here updates itself. Keep boxes small: cost per
# call and context size both scale with area.
FLIGHT_REGIONS=Eastern Mediterranean / Levant:30,32,37,37;Black Sea / Ukraine:44,29,53,41;Persian Gulf / Strait of Hormuz:24,50,30,60;Red Sea / Bab el-Mandeb:12,38,20,45;Taiwan Strait:21,117,27,124;Baltic / Kaliningrad:53,17,60,28
# Callsign prefixes treated as military. A heuristic that only catches aircraft
# flying under published national callsigns (REACH/ASCOT/CANFORCE and friends);
# anything with its transponder off is invisible to ADS-B entirely.
FLIGHT_MILITARY_CALLSIGN_PREFIXES=RCH,CNV,RRR,CFC,GAF,IAM,FAF,BAF,NAF,PLF,HAF,NATO,SVF
FLIGHT_MAX_MILITARY_PER_REGION=20

# ---------------------------------------------------------------------------
# Naval traffic (aisstream.io)
# ---------------------------------------------------------------------------
# Free key, no card, from https://aisstream.io after signing in (GitHub etc.).
# The service is WebSocket-only and self-describes as beta with no uptime SLA, so
# a run that returns nothing is normal rather than broken.
#
# What this measures: civil merchant traffic through chokepoints. What it does
# NOT measure: naval force posture. Warships sail with AIS off as a matter of
# routine and the AIS "military ops" type code is self-declared, so a quiet box
# here means nothing at all about warships. It is useful for the opposite
# reading — merchant traffic abandoning a route, which shows up in freight and
# insurance costs and pairs with the financial indicators above.
AISSTREAM_API_KEY=
# Same `Name:lamin,lomin,lamax,lomax` format as FLIGHT_REGIONS.
NAVAL_REGIONS=Red Sea / Bab el-Mandeb:12,38,20,45;Strait of Hormuz:24,54,28,58;Black Sea:41,27,47,42;Taiwan Strait:21,117,27,124;Suez Canal approaches:29,32,32,34
# Seconds the socket is held open per run. This is a density sample, not a
# census; raising it makes a oneshot digest sit on a socket for longer.
NAVAL_SAMPLE_SECONDS=20

# ---------------------------------------------------------------------------
# Household calendar (Nextcloud CalDAV, Phase 8)
# ---------------------------------------------------------------------------
# Auth is a Nextcloud APP PASSWORD, not your account password and not OAuth2 —
# same reasoning as the IMAP section above. Create one at Nextcloud -> Settings ->
# Security -> Devices & sessions -> Create new app password. It is mandatory once
# two-factor authentication is enabled (the DAV endpoints cannot prompt for a
# second factor) and it is revocable on its own if this host is ever compromised.
#
# Point CALDAV_URL at the DAV root; the client discovers the principal and its
# calendars from there. The per-user form
# https://cloud.example.com/remote.php/dav/principals/users/<username>/ also works.
#
# Read-only: this only ever issues CalDAV read requests. It never creates, moves
# or deletes an event.
CALDAV_URL=https://cloud.example.com/remote.php/dav
CALDAV_USERNAME=
CALDAV_PASSWORD=
# Comma-separated calendar display names to include. Empty = every calendar the
# account can see (which includes ones shared with you).
CALDAV_CALENDARS=
# The calendar window is asymmetric: DIGEST_LOOKBACK_HOURS backwards (to catch
# what already happened today and anything still in progress) and this forwards.
CALDAV_LOOKAHEAD_HOURS=48
CALDAV_MAX_EVENTS=100
# Only set false for a self-signed internal cert, and understand that it sends
# the app password over an unverified session.
CALDAV_VERIFY_TLS=true

# ---------------------------------------------------------------------------
# Kitchen inventory (Grocy, Phase 7)
# ---------------------------------------------------------------------------
# GROCY_URL is the container name on the shared compose network — Grocy listens
# on port 80 inside the container (published to the LAN as 9283, which this does
# not need to go through). Use http://<host-ip>:9283 only if Grocy runs elsewhere.
#
# The API key is per user: Grocy -> Settings -> Manage API keys -> add. Note that
# a Grocy key is NOT scoped — it carries that user's full read and write rights,
# so give this one its own Grocy user. The read-only guarantee comes from
# ingest/grocy.py issuing GETs and nothing else; see the invariant at the top of
# that file, which also names the write endpoints it deliberately never calls.
#
# Nothing in this component ever adds to Grocy's shopping list, consumes stock,
# executes a chore or charges a battery — including the evening recipe feature,
# whose shopping list is a suggestion printed in the digest for you to act on.
GROCY_URL=http://grocy
GROCY_API_KEY=
# Days ahead a product counts as "due soon" (Grocy's /stock/volatile default is 5).
GROCY_DUE_SOON_DAYS=5
# Days ahead a chore or battery charge is worth mentioning.
GROCY_TASK_HORIZON_DAYS=7
# Cap on the in-stock product list, which is only sent on the evening run so the
# recipe's shopping list can tell "already have it" from "buy it". Raise it if
# your pantry is bigger than this, or the shopping list will suggest things you
# already own (a warning is logged when the cap bites).
GROCY_MAX_STOCK_ITEMS=200

# ---------------------------------------------------------------------------
# Home network intrusion detection (OPNsense + Suricata)
# ---------------------------------------------------------------------------
# Feeds a short "is anything wrong with the home network" item into the
# HOUSEHOLD section. Everything except the toggle and the path below lives in
# IDSconf.json, because that file carries an API key/secret — copy
# IDSconf.json.example to the digest data volume, chmod 600 it, and read its
# _readme keys; they cover creating the OPNsense API key and scoping the user.
#
# Suricata is part of OPNsense core (no os-suricata plugin to install), but it
# is OFF on a stock install — enable it at Services -> Intrusion Detection and
# download a ruleset first, or every run will honestly report that the IDS was
# not running.
#
# This is a pure pull over HTTPS: GET /api/ids/service/status and POST
# /api/ids/service/query_alerts (a POST that only reads /var/log/suricata/
# eve.json), and nothing else, ever. It never starts a packet capture, never
# reconfigures or restarts Suricata, and never clears an alert log.
#
# POSTURE NOTE: the container host and the firewall sit on the same flat LAN —
# no VLAN segmentation is implemented in this project yet (docs/project-plan.md
# only plans one for cameras). Scope the OPNsense user to the single "Services:
# Intrusion Detection" privilege and nothing else. Be aware that OPNsense ACLs
# are page-level: that privilege still covers the IDS start/stop endpoints, so
# the read-only guarantee comes from this code, not from the firewall.
#
# Path INSIDE the container. /data is the bind-mounted digest data volume.
OPNSENSE_IDS_CONF_PATH=/data/IDSconf.json
