Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,10 @@ them without understanding why they exist.
- The content detector refuses to baseline a page whose extracted text is below a small threshold (a client-rendered
shell), so it never silently hashes nothing forever.
- Feed dedupe keys on a stable identity: the Atom `<id>` verbatim, else a normalized link URL. Persist the full
seen-set. An existing item counts as changed only when its title+summary hash moves.
seen-set. An existing item counts as changed only when its title+summary hash moves. A feed whose entry title is only
a date (the AndroidX aggregate feed titles every entry "June 24, 2026") gets a display title synthesized from the
summary's library/version link text; this is display-only — identity and the title+summary dedupe hash still use the
original feed values, so the rewrite never re-fires the seen-set.
- The `android_sitemap` detector is host-agnostic: it parses a host's sitemap (a `<sitemapindex>` of shards, or a single
`<urlset>`) once per run, cached on the `Fetcher` keyed by the sitemap-index URL derived from each source's host (
`<scheme>://<host>/sitemap.xml`). Sources on the same host share one download (guarded by an `asyncio.Lock`,
Expand Down Expand Up @@ -97,7 +100,12 @@ them without understanding why they exist.
`detected_at`).
- `record_change` is idempotent on `(source_id, url, fetched_hash)`: it returns the existing row id and never resets a
verdict.
- `set_verdict` is write-once. Triage only touches rows with `verdict IS NULL`.
- `set_verdict` is write-once. The triage worklist is the whole ledger — `changes_needing_triage()` returns every row
with `verdict IS NULL AND superseded = 0`, not just this run's detections. A change recorded during a run that could
not triage keeps a NULL verdict and is never re-detected (its content hash / feed seen-set already matches), so the
ledger is the only place to find it; a later run picks it up and resolves it. When triage cannot run at all (the
triager returns `unavailable`), the run fails open and marks every untriaged row `substantive` with no description, so
the digest still goes out (with the AI-unavailable banner) instead of silently stranding those changes.
- When a ranked change is delivered, `supersede_older` marks older undelivered rows for the same `(source_id, url)` so a
page that changed twice yields one digest line, not a stale one.
- Delivery is per `(change, channel)`, recorded in `deliveries`. Send, then record the delivery transactionally. A
Expand Down Expand Up @@ -126,7 +134,8 @@ them without understanding why they exist.

- `claude_cli` shells out to `claude -p --output-format json`, strips a markdown code fence from the result before
parsing, and on any failure returns `TriageResult(unavailable=<reason>)` without raising. The digest still goes out,
with a visible "AI unavailable" banner.
with a visible "AI unavailable" banner, and the run marks every untriaged change `substantive` so they are all sent
rather than withheld (same effect as the `noop` triager — when triage cannot classify, send all).
- Fetched page content is untrusted. Wrap it in per-run nonce-fenced blocks, length-cap it, and instruct the model to
treat it as data, never instructions.
- `noop` (AI off) marks every change substantive with no description and does not filter.
Expand Down
6 changes: 5 additions & 1 deletion src/android_watcher/catalog/catalog.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@ path_prefix = ""
feed_url = ""
content_selector = ""
default_weight = 0
exclude_prefixes = ["/reference"]
# /reference: per-symbol API docs. gki-android*-builds: auto-generated kernel
# build lists that regenerate with new build rows on nearly every crawl, so they
# churn the digest daily without telling a developer anything actionable (the
# curated gki-faq / gki-releases / gki-versioning docs are still watched).
exclude_prefixes = ["/reference", "/docs/core/architecture/kernel/gki-android"]

[[source]]
id = "android-security-bulletins"
Expand Down
8 changes: 7 additions & 1 deletion src/android_watcher/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ class ScheduleConfig:
at: str = "09:00" # one or more HH:MM, comma-separated
days: str = "mon" # weekly only: comma-separated weekday abbrevs (mon..sun)
cron: str = ""
# Extra environment variables baked into the native scheduler unit. The
# scheduled job (and the claude CLI it shells out to for triage) inherit a
# bare environment, so e.g. CLAUDE_ACCOUNT here lets an account-aware claude
# wrapper resolve a profile when it cannot from the job's working directory.
env: dict[str, str] = field(default_factory=dict)


@dataclass
Expand Down Expand Up @@ -233,8 +238,9 @@ def _load_schedule(d: dict[str, Any]) -> ScheduleConfig:
f"schedule.cron is set but interval is {interval!r}; "
"set interval = 'cron' or clear cron"
)
env = {str(k): str(v) for k, v in d.get("env", {}).items()}
return ScheduleConfig(
interval=interval, at=d.get("at", "09:00"), days=d.get("days", "mon"), cron=cron
interval=interval, at=d.get("at", "09:00"), days=d.get("days", "mon"), cron=cron, env=env
)


Expand Down
63 changes: 61 additions & 2 deletions src/android_watcher/detect/feed.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from __future__ import annotations

import hashlib
import html as _html
import re
from urllib.parse import urlsplit, urlunsplit

from defusedxml import ElementTree as ET
Expand All @@ -10,6 +12,62 @@

_ATOM = "{http://www.w3.org/2005/Atom}"

# A whole feed title that is *only* a date (e.g. the AndroidX aggregate feed,
# which titles every entry "June 24, 2026"). Such a title makes a useless digest
# headline, so it is replaced by the library/version names from the summary.
_DATE_TITLE_RE = re.compile(
r"^(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|"
r"aug(?:ust)?|sep(?:tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\s+"
r"\d{1,2},\s+\d{4}$",
re.IGNORECASE,
)
_LINK_TEXT_RE = re.compile(r"<a\b[^>]*>(.*?)</a>", re.IGNORECASE | re.DOTALL)
_TAG_RE = re.compile(r"<[^>]+>")


def _is_date_title(title: str) -> bool:
return bool(_DATE_TITLE_RE.match(title.strip()))


def _summary_items(summary: str) -> list[str]:
"""Library/version labels from a summary's <a> link texts, in order.

"Media3 Version 1.11.0-alpha01" -> "Media3 1.11.0-alpha01" (the boilerplate
word "Version" is dropped). Returns [] when the summary has no links.
"""
items: list[str] = []
for raw in _LINK_TEXT_RE.findall(summary):
text = _html.unescape(_TAG_RE.sub("", raw))
text = re.sub(r"\bversion\b", "", text, flags=re.IGNORECASE)
text = re.sub(r"\s+", " ", text).strip()
if text:
items.append(text)
return items


def _synthesize_title(summary: str) -> str | None:
"""A digest headline built from a date-titled entry's summary, or None.

One library -> its label; several -> the first two labels then "+N more".
"""
items = _summary_items(summary)
if not items:
return None
if len(items) == 1:
return items[0]
head = ", ".join(items[:2])
rest = len(items) - 2
return f"{head} +{rest} more" if rest else head


def _display_title(item: dict) -> str:
"""The entry's own title, unless it is a bare date and the summary yields a
better headline. Identity and the dedupe hash still use the original title."""
title = item["title"]
if _is_date_title(title):
return _synthesize_title(item["summary"]) or title
return title


def _normalize_link(link: str) -> str:
parts = urlsplit(link.strip())
Expand Down Expand Up @@ -107,14 +165,15 @@ async def detect(self, source: Source, store, fetcher) -> list[Change]:
if not identity:
continue
content_hash = _hash(item["title"], item["summary"])
title = _display_title(item)
prior = store.seen_feed_item(source.id, identity)
if prior is None:
changes.append(
Change(
source_id=source.id,
url=item["link"] or identity,
change_kind="new",
title=item["title"],
title=title,
raw_diff=f"{item['title']}\n\n{item['summary']}".strip()[:500],
fetched_hash=content_hash,
)
Expand All @@ -126,7 +185,7 @@ async def detect(self, source: Source, store, fetcher) -> list[Change]:
source_id=source.id,
url=item["link"] or identity,
change_kind="updated",
title=item["title"],
title=title,
raw_diff=f"{item['title']}\n\n{item['summary']}".strip()[:500],
fetched_hash=content_hash,
)
Expand Down
19 changes: 15 additions & 4 deletions src/android_watcher/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,15 +250,20 @@ def run_once(config: Config, *, force: bool = False, dry_run: bool = False) -> D
for change in changes:
change.id = store.record_change(change) # idempotent on (source,url,hash)

# Triage is WRITE-ONCE: only rows whose verdict is still NULL. Re-detected
# rows already carry a final verdict and must not be re-triaged.
untriaged = [c for c in changes if c.verdict is None]
# Triage is WRITE-ONCE and ledger-sourced: the worklist is every row whose
# verdict is still NULL, not just this run's detections. A change recorded
# during a run that could not triage (the triager returned unavailable,
# leaving the verdict NULL) is never re-detected — its content hash / feed
# seen-set already matches — so the ledger is the only place to find it.
untriaged = store.changes_needing_triage()
mode = config.ai.mode if config.ai.mode != "off" else "noop"
t_triage = time.monotonic()
result = _triage_batched(TRIAGERS.get(mode)(), untriaged, config.ai)
log.info("triage phase: %.1fs (%d triaged)", time.monotonic() - t_triage, len(untriaged))
for change in result.changes:
if change.id is not None and change.verdict is not None:
if change.id is None:
continue
if change.verdict is not None:
store.set_verdict(
change.id,
change.verdict,
Expand All @@ -267,6 +272,12 @@ def run_once(config: Config, *, force: bool = False, dry_run: bool = False) -> D
change.group_summary,
change.group_title,
)
elif result.unavailable is not None:
# Could not triage: fail open and SEND ALL. Mark every untriaged
# change substantive (no description) so the digest still goes out
# — with the AI-unavailable banner — instead of silently withholding
# the change until some later run does manage to triage it.
store.set_verdict(change.id, "substantive", None)

# Digest comes from the ledger (undelivered backlog), not this-run changes.
digest = _build_ledger_digest(store, config, channels, result.tldr, result.unavailable)
Expand Down
31 changes: 23 additions & 8 deletions src/android_watcher/schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,8 @@ def render_plist(

A launchd job inherits a bare PATH (``/usr/bin:/bin:/usr/sbin:/sbin``), so
when *path_env* is given it is embedded as ``EnvironmentVariables/PATH`` —
without it the run cannot reach the ``claude`` CLI for triage.
without it the run cannot reach the ``claude`` CLI for triage. Any
``sched.env`` entries are embedded in the same ``EnvironmentVariables`` dict.
"""
intervals = _calendar_intervals(sched)
payload: dict[str, object] = {
Expand All @@ -185,8 +186,11 @@ def render_plist(
"RunAtLoad": False,
"StartCalendarInterval": intervals[0] if len(intervals) == 1 else intervals,
}
env_vars = dict(sched.env)
if path_env:
payload["EnvironmentVariables"] = {"PATH": path_env}
env_vars["PATH"] = path_env
if env_vars:
payload["EnvironmentVariables"] = env_vars
return plistlib.dumps(payload, sort_keys=True).decode("utf-8")


Expand Down Expand Up @@ -243,15 +247,22 @@ def _on_calendar(sched: ScheduleConfig, tz: str) -> str:
raise ScheduleError(f"unknown interval {sched.interval!r}")


def render_service(exec_path: str, args: list[str], path_env: str | None = None) -> str:
def render_service(
exec_path: str,
args: list[str],
path_env: str | None = None,
env: dict[str, str] | None = None,
) -> str:
"""Render a systemd .service unit for android-watcher.

systemd user services start from a minimal PATH, so when *path_env* is given
it is embedded as ``Environment=PATH=`` — without it the run cannot reach the
``claude`` CLI for triage.
``claude`` CLI for triage. Each *env* entry is emitted as its own
``Environment=KEY=value`` line.
"""
exec_start = " ".join([exec_path, *args])
env_line = f"Environment=PATH={path_env}\n" if path_env else ""
env_lines = "".join(f"Environment={k}={v}\n" for k, v in (env or {}).items())
env_line = (f"Environment=PATH={path_env}\n" if path_env else "") + env_lines
return (
"[Unit]\n"
"Description=android-watcher scheduled run\n"
Expand Down Expand Up @@ -307,11 +318,13 @@ def render_crontab(

cron runs with a minimal PATH, so when *path_env* is given a ``PATH=``
assignment is emitted ahead of the schedule lines — without it the run cannot
reach the ``claude`` CLI for triage.
reach the ``claude`` CLI for triage. Each ``sched.env`` entry is emitted as
its own ``KEY=value`` assignment, also ahead of the schedule lines.
"""
body = "\n".join(f"{spec} {line_command}" for spec in _cron_lines(sched))
path_line = f"PATH={path_env}\n" if path_env else ""
return f"{CRON_BEGIN}\nCRON_TZ={tz}\n{path_line}{body}\n{CRON_END}\n"
env_lines = "".join(f"{k}={v}\n" for k, v in sched.env.items())
return f"{CRON_BEGIN}\nCRON_TZ={tz}\n{path_line}{env_lines}{body}\n{CRON_END}\n"


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -408,7 +421,9 @@ def _install_systemd(config: Config) -> None:
d = Path(_systemd_dir())
d.mkdir(parents=True, exist_ok=True)
exe, *run_args = _program_args()
(d / f"{SYSTEMD_UNIT_NAME}.service").write_text(render_service(exe, run_args, _env_path()))
(d / f"{SYSTEMD_UNIT_NAME}.service").write_text(
render_service(exe, run_args, _env_path(), config.schedule.env)
)
(d / f"{SYSTEMD_UNIT_NAME}.timer").write_text(render_timer(config.schedule, _local_tz()))
_run(["systemctl", "--user", "daemon-reload"])
_run(["systemctl", "--user", "enable", "--now", f"{SYSTEMD_UNIT_NAME}.timer"])
Expand Down
18 changes: 18 additions & 0 deletions src/android_watcher/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,24 @@ def record_change(self, change: Change) -> int:
change.id = int(row["id"])
return change.id

def changes_needing_triage(self) -> list[Change]:
"""Every ledger row still awaiting a verdict (verdict IS NULL, not superseded).

The triage worklist is sourced from the ledger, not just this run's fresh
detections, so a change recorded during a run that could not triage (the
triager returned unavailable, leaving the verdict NULL) is picked up and
triaged on a later run. Without this it would never be re-detected — its
content hash / feed seen-set already matches — and so would strand forever.
"""
rows = self._conn.execute(
"""
SELECT * FROM changes
WHERE verdict IS NULL AND superseded = 0
ORDER BY detected_at DESC, id DESC
"""
).fetchall()
return [self._row_to_change(r) for r in rows]

def changes_for_digest(self, channels: set[str]) -> list[Change]:
"""Substantive changes not yet delivered to EVERY channel in `channels`.

Expand Down
10 changes: 8 additions & 2 deletions src/android_watcher/triage/claude_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,14 @@
logger = logging.getLogger(__name__)

MAX_CONTENT_CHARS: int = 4000
MAX_TRIAGE_BATCH: int = 25
SUBPROCESS_TIMEOUT: float = 120.0
# Batch size and per-call timeout are sized for a large backlog drain, not just a
# steady-state run: a batch of N changes carries up to N*MAX_CONTENT_CHARS of page
# content, and `claude -p` must read it and emit JSON for every item within the
# timeout. A timed-out batch trips the run's AI-unavailable banner and falls back
# to send-all, so keep the batch small and the timeout generous to avoid that on a
# one-shot drain of hundreds of changes.
MAX_TRIAGE_BATCH: int = 12
SUBPROCESS_TIMEOUT: float = 300.0

_INSTRUCTIONS_TEMPLATE = (
"You are triaging changes detected on official Android documentation and blog\n"
Expand Down
6 changes: 6 additions & 0 deletions src/android_watcher/tui/configio.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@ def config_to_toml(config: Config) -> str:
lines.append(f"cron = {_toml_str(sc.cron)}")
lines.append("")

if sc.env:
lines.append("[schedule.env]")
for key, val in sc.env.items():
lines.append(f"{_toml_str(key)} = {_toml_str(val)}")
lines.append("")

lines.append("[ai]")
lines.append(f"mode = {_toml_str(ai.mode)}")
lines.append(f"model = {_toml_str(ai.model)}")
Expand Down
24 changes: 24 additions & 0 deletions tests/detect/test_android_sitemap.py
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,30 @@ async def test_exclude_prefixes_drops_subtree():
assert got == {"/studio/y", "/develop/z"}


@pytest.mark.asyncio
async def test_source_android_excludes_gki_build_lists():
"""The per-version GKI build-list pages auto-regenerate constantly (new build
rows), so the shipped source-android catalog entry must drop them while still
watching the curated GKI docs."""
from android_watcher.catalog import load_catalog

source_android = next(s for s in load_catalog() if s.id == "source-android")
got = await _watched_paths(
source_android,
[
"/docs/core/architecture/kernel/gki-android14-5_15-release-builds",
"/docs/core/architecture/kernel/gki-android17-6_18-deprecated-builds",
"/docs/core/architecture/kernel/gki-faq",
"/docs/core/architecture/kernel/gki-releases",
],
host="https://source.android.com",
)
assert got == {
"/docs/core/architecture/kernel/gki-faq",
"/docs/core/architecture/kernel/gki-releases",
}


@pytest.mark.asyncio
async def test_require_segment_keeps_android_drops_others():
src = _src(
Expand Down
Loading
Loading