From 1e803c4dc21097032535c1ffd644781df18f222b Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Mon, 26 Oct 2026 14:47:47 -0400 Subject: [PATCH] =?UTF-8?q?chore:=20repo=20cleanup=20=E2=80=94=20human-fir?= =?UTF-8?q?st=20README,=20setup=20guide,=20AGENTS.md,=20CHANGELOG,=20`arje?= =?UTF-8?q?v=20daily`=20+=20contrib=20units;=20v0.6.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 107 +++++++++ CHANGELOG.md | 101 ++++++++ README.md | 201 +++++++--------- .../io.github.harmoniqs.arjev.daily.plist | 37 +++ contrib/systemd/arjev-daily.service | 14 ++ contrib/systemd/arjev-daily.timer | 11 + docs/README.md | 17 ++ docs/agent-setup.md | 94 ++++---- docs/architecture.md | 138 +++++++++++ docs/history/README.md | 6 + .../history}/plan-20261001-arjev-v1.md | 0 .../history}/spec-20261001-113338-arjev-v1.md | 0 docs/release-notes-v0.3.0.md | 83 ------- docs/release-notes-v0.4.0.md | 51 ---- docs/release-notes-v0.4.1.md | 41 ---- docs/release-notes-v0.5.0.md | 42 ---- docs/release-notes-v0.5.1.md | 19 -- docs/release-notes-v0.5.3.md | 15 -- docs/release-notes-v0.5.4.md | 13 - docs/release-notes-v0.5.5.md | 13 - docs/release-notes-v0.5.6.md | 17 -- docs/setup.md | 225 ++++++++++++++++++ docs/slack-setup.md | 135 ++++++----- pyproject.toml | 19 +- src/arjev/__init__.py | 9 +- src/arjev/cli.py | 16 +- src/arjev/daily.py | 119 +++++++++ src/arjev/digest.py | 12 +- src/arjev/render.py | 8 +- tests/conftest.py | 3 +- tests/test_daily.py | 114 +++++++++ tests/test_docs_commands.py | 2 +- tests/test_packaging.py | 17 ++ tests/test_slack.py | 25 ++ uv.lock | 2 +- 35 files changed, 1201 insertions(+), 525 deletions(-) create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 contrib/launchd/io.github.harmoniqs.arjev.daily.plist create mode 100644 contrib/systemd/arjev-daily.service create mode 100644 contrib/systemd/arjev-daily.timer create mode 100644 docs/README.md create mode 100644 docs/architecture.md create mode 100644 docs/history/README.md rename {config/plans => docs/history}/plan-20261001-arjev-v1.md (100%) rename {config/specs => docs/history}/spec-20261001-113338-arjev-v1.md (100%) delete mode 100644 docs/release-notes-v0.3.0.md delete mode 100644 docs/release-notes-v0.4.0.md delete mode 100644 docs/release-notes-v0.4.1.md delete mode 100644 docs/release-notes-v0.5.0.md delete mode 100644 docs/release-notes-v0.5.1.md delete mode 100644 docs/release-notes-v0.5.3.md delete mode 100644 docs/release-notes-v0.5.4.md delete mode 100644 docs/release-notes-v0.5.5.md delete mode 100644 docs/release-notes-v0.5.6.md create mode 100644 docs/setup.md create mode 100644 src/arjev/daily.py create mode 100644 tests/test_daily.py create mode 100644 tests/test_packaging.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5e8caea --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,107 @@ +# AGENTS.md + +Instructions for AI agents working on the arJev codebase. If you are here to +**install arJev for a person**, stop and follow +[docs/agent-setup.md](docs/agent-setup.md) instead. + +arJev is a Python 3.11+ CLI: a daily arXiv digest ranked against an Obsidian +vault, with an optional Slack surface. Runtime dependencies are PyYAML, httpx, +and pypdf; nothing else. + +## Commands + +```bash +uv sync --extra dev # set up the environment +uv run --extra dev pytest # the full suite (~10 s, no network) +uv run --extra dev pytest tests/test_slack.py -k who_line # one area +uv run --extra dev ruff check . # lint (CI runs exactly this) +uv run arjev --help # the CLI from the working tree +``` + +CI (`.github/workflows/ci.yml`) runs `ruff check .` and `pytest` on Python 3.11. +Both must pass before a merge. + +## Layout + +| Path | What lives there | +|---|---| +| `src/arjev/cli.py` | every verb: argparse tree + dispatch; nothing else | +| `src/arjev/config.py` · `init.py` | the TOML config and the commented starter config `arjev init` writes | +| `src/arjev/feed.py` | arXiv RSS fetch and parse (author names are split here) | +| `src/arjev/fold.py` · `profile.py` · `directives.py` | the vault scan, the taste profile, and taste directives | +| `src/arjev/score.py` · `rank.py` · `rerank.py` · `jev.py` | lexical scoring, ranking, the Jev layer, and the digest journal | +| `src/arjev/digest.py` · `render.py` | one digest run and its sinks (stdout, vault note, Slack) | +| `src/arjev/affil.py` | the who-line: affiliations parsed from a paper's own PDF | +| `src/arjev/slack.py` · `ingest.py` | the Slack client, pick rendering, reaction harvest; seeding from BibTeX/PDFs/Slack | +| `src/arjev/keep.py` · `labels.py` · `ledger.py` · `state.py` | keeps, the label ledger, posted ids | +| `src/arjev/paper_content.py` | PDF text extraction and the library | +| `src/arjev/calibrate.py` · `review.py` · `rate.py` | calibration, the week-in-review, rating proposals | +| `src/arjev/daily.py` | `arjev daily`: the scheduled run and its retry/weekend rules | +| `src/arjev/inspect.py` · `sanitize.py` · `latex.py` | config verification, fixture sanitizing, LaTeX → Unicode | +| `tests/` · `tests/fixtures/` | the suite; recorded, sanitized fixtures (never live data) | +| `contrib/` | systemd and launchd units that run `arjev daily` | +| `scripts/` | `brand.py` regenerates `docs/brand/`; `capture_fixtures.py` records fixtures (networked, maintainer-only) | +| `docs/` | user docs, [ADRs](docs/adr/), [history](docs/history/) | + +## Invariants — do not break these + +1. **The tool never writes prose.** Note bodies and why-lines come from an + agent or a person; arJev renders deterministically and Jev only makes typed + decisions ([ADR 0001](docs/adr/0001-vault-generation-is-the-agents-contract.md)). +2. **Never edit an existing note.** arJev writes new files only. The single + exception is `arjev rate accept`, which a human invokes. +3. **Never default a category.** Empty `feeds` blocks the digest with an error + naming the slot. +4. **Attribution is printed or curated, never inferred.** Affiliations come + from the paper's own PDF and are never bound to an author; groups come only + from the roster ([ADR 0002](docs/adr/0002-at-digest-time-affiliations-ride-from-the-papers-own-pdf.md)). +5. **External seams fail open.** A Jev, PDF, or enrichment failure degrades + that feature and the run continues; the `mode:` line says what ran. +6. **State lives under XDG state** (`~/.local/state/arjev/`), never in the + vault or a repo. Tests isolate it automatically (`tests/conftest.py`). +7. **Secrets never touch config or the repo.** Keys and tokens come from env + vars or mode-600 files (`ARJEV_JEV_KEY_FILE`, `ARJEV_SLACK_TOKEN_FILE`). +8. **Persisted schemas are frozen.** Journal rows, the label ledger, and posted + state keep bare arXiv ids; extend with new fields, never change existing ones. +9. **The Slack content boundary.** Posts carry feed-derived text, printed + affiliations, curated roster labels, and matched terms only — never vault + paths, note filenames, or a note's `why` text. + +## How to change things + +- **Test first.** Write the failing test, then the code. Tests exercise public + behavior; network calls go through injectable seams (`SlackClient(transport=…)`, + `http_transport=`, `pdf_fetcher=`, `JevClient` stubs), never real services. +- **The docs lint is real.** `tests/test_docs_commands.py` checks that every + arjev command shown in `README.md`, `CONTEXT.md`, `AGENTS.md`, and `docs/` + exists in the CLI, verb and flags. Add the flag before you document it, and + never put a placeholder verb inside a code span. +- **Docs have one job each.** `README.md` is for a human deciding whether to use + arJev; setup steps go in `docs/setup.md` (human) and `docs/agent-setup.md` + (agent); how it works goes in `docs/architecture.md`. New domain terms go in + `CONTEXT.md`; hard-to-reverse decisions get an ADR in `docs/adr/`. +- **Record user-visible changes** in `CHANGELOG.md` with the release that ships them. + +## Releasing + +1. Bump `version` in `pyproject.toml` — the only place it lives (`arjev.__version__` + reads the installed metadata; `tests/test_packaging.py` enforces this). +2. Add the `CHANGELOG.md` entry. +3. Open the PR, wait for CI to pass, merge. +4. Tag the **merge commit on `main`** and push the tag: + `git tag vX.Y.Z -m vX.Y.Z && git push origin vX.Y.Z`. Never tag before the + merge lands — a tag on an unmerged branch ships an install without the fix. + +## External behavior worth knowing + +Lessons from live runs that the tests now pin: + +- arXiv RSS puts **all authors in one comma-joined field**; `feed.py` splits it. +- arXiv publishes **no new listings on Saturday or Sunday**; empty weekend + feeds are expected (`daily.py`). +- arXiv RSS is **occasionally empty mid-day on weekdays**; retry, don't alarm. +- Slack's read methods (`reactions.get`, `conversations.replies`) **ignore JSON + bodies**; every call is form-encoded (`slack.py`). +- Slack needs a channel **ID**, not a name, for history and ingest. +- No external index (arXiv API, Semantic Scholar, INSPIRE, OpenAlex) carries + affiliations on publication day; the paper's PDF is the only source. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..cd4153e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,101 @@ +# Changelog + +All notable changes, newest first. Dates are release tags (UTC). Install a +specific release with `uv tool install git+https://github.com/harmoniqs/arJev@vX.Y.Z`. + +## v0.6.0 — 2026-10-26 + +**One command for the whole day, and a setup kit in the repo.** + +- **`arjev daily`** runs the full day for a timer: the digest (retried with + backoff when arXiv's RSS is flaky), Slack ingest and reaction sync, and on + Sundays the calibration report and week-in-review. On Saturdays and Sundays + arXiv publishes no new listings, so an empty feed is logged as a quiet day + instead of an outage. One failing step never stops the others. +- **`ARJEV_SLACK_TOKEN_FILE`** reads the Slack token from a mode-600 file, the + same way `ARJEV_JEV_KEY_FILE` already worked for the Jev key. +- **`contrib/`** ships a systemd service + timer and a macOS launchd plist that + call `arjev daily`. +- The version is single-sourced from `pyproject.toml`. +- Digest headers say "ranked against your vault" instead of "the lab corpus", + since arJev serves individual researchers as well as labs. +- Docs reorganized: a human-first README, a step-by-step `docs/setup.md`, + `AGENTS.md` for agents working on the repo, and this changelog in place of + per-release notes files. + +## v0.5.6 — 2026-10-26 + +- `arjev digest --post slack,vault` ranks once and writes to every listed sink: + the dated vault note first, then Slack. If one sink fails the others still + run, the failed sink is named on stderr, and the exit code is 1. Config + errors are caught before ranking, so a misconfigured run spends no Jev calls. + +## v0.5.5 — 2026-10-25 + +- **Fixed:** Slack Web API calls are form-encoded. `reactions.get` and + `conversations.replies` ignore JSON bodies, so `arjev slack sync` never + worked against real Slack before this release. +- The Slack app manifest adds `channels:join`, so the bot can join its channel. + +## v0.5.4 — 2026-10-25 + +- `arjev review --post slack` posts a Slack-native week-in-review: bold section + titles, titles linked to arXiv, readable dates, plain labels. The full report + (stdout, `review-YYYYMMDD.md`) is unchanged. +- `\( … \)` inline math in titles is cleaned like `$ … $`. + +## v0.5.3 — 2026-10-25 + +- **Fixed:** the week-in-review ranked by Jev's confidence in *any* label, so + papers Jev was surest were *irrelevant* led "The Buried". It now orders by + label, then confidence, and only relevant papers are listed. The digest was + never affected. + +## v0.5.2 — 2026-10-21 + +- Affiliation parsing strips email parentheticals and handles an abstract + heading run into its first word (`ABSTRACTCloud…`). + +## v0.5.1 — 2026-10-21 + +- **Fixed:** arXiv RSS puts every author in one comma-joined field, and names + were never split. Splitting them fixes the who-line's author list and lets + the tracked-author lane match anyone on the paper, not just the last name. + +## v0.5.0 — 2026-10-21 + +- **The who-line:** every Slack pick shows its authors (first three + `+N`), + tracked people as `⭐ Name (Group)`, and the institutions printed on the + paper's own PDF. Affiliations are never inferred or bound to individual + authors; an ambiguous parse shows authors only (ADR 0002). +- Roster entries may carry a display-only group: `"M. Lukin — Harvard-Lukin"`. +- New config slot `[slack] who_style = "full" | "authors" | "none"` + (default `authors`; `full` fetches each pick's PDF). + +## v0.4.1 — 2026-10-12 + +- `arjev review --days 7`: the week-in-review, a read-only re-read of the + journal (papers that missed the daily cap, tracked-author matches, + Jev-vs-keyword divergences, the week's labels). + +## v0.4.0 — 2026-10-08 + +- The context engine: an opt-in `budget-greedy` state policy that fills a byte + budget with directives and per-paper taste cards (your why, the abstract, the + conclusions), weighted by recency. `arjev calibrate --replay-arms` measures + each arm on your labels; defaults change only on a measured win. +- `arjev fetch` and `arjev backfill` extract PDF text into the library. + +## v0.3.0 — 2026-10-02 + +- Field-neutral: `feeds` defaults to empty and the digest refuses to run until + you choose categories. `arjev init` writes a self-explaining config and never + overwrites an existing one. +- New verbs: `arjev inspect` (verify the config) and `arjev staged` (the notes + awaiting prose). + +## Earlier + +v0.1–v0.2 (2026-10-01): the first digest, Slack picks and reaction labels, +taste directives, multi-feed digests, `arjev ingest`, and Jev-first ranking. +The original spec and plan are in [`docs/history/`](docs/history/). diff --git a/README.md b/README.md index 682f61a..dc34bc7 100644 --- a/README.md +++ b/README.md @@ -7,140 +7,113 @@

Your daily arXiv, ranked against your vault.

-# arJev +**arJev** reads the new papers in the arXiv categories you follow every day, +ranks them against your Obsidian vault — the papers you've read, kept, and +written about — and sends you the few worth your time, each with a reason. Your +reactions teach it, so the picks improve the longer you use it. -A daily arXiv digest engine that ranks against your **living Obsidian vault** — so the picks get better every week you use it, because *you* are the training data. +It works for any field that posts to arXiv, from condensed matter to economics, +and it reads nothing but arXiv. It never picks categories for you. -arJev serves **any field that posts to arXiv** — condensed matter, economics, quantitative biology, mathematics, any discipline with an arXiv category — and it sources nothing beyond arXiv: no PubMed, no RePEc, no journal feeds. If your field doesn't post to arXiv, arJev is not for you yet, and it will never silently rank a category you didn't choose. +## What a pick looks like -## The context engine: how your corpus becomes the ranking +Each pick arrives in the terminal, a dated note in your vault, or a Slack channel: -The design invariant: **your corpus can grow without bound; the model's window never does — and that's the mechanism, not a limitation.** - -Every digest runs the same fold: - -1. **Fold** — every note under your roots is scanned once; the papers you've kept become first-class records (title, your `why` line, your rating, tags, the vault's wikilink graph). -2. **Taste cards** — each kept paper becomes a card: your why, the paper's own abstract, and its conclusions/outlook section (extracted from your local PDF library; your directives ride whole and first). Every card carries a 180-day half-life decay and your own rating verdict. -3. **The budgeted window** — every candidate is judged against a fixed, byte-budgeted context assembled fresh for it: your directives, then cards, terms, and why-lines filled greedily by recency-weighted priority. - -Two phases fall out of that one mechanism: - -- **Small corpus → maximal fill.** When you've kept a handful of papers, everything you've ever kept rides the window — the early picks are judged against all of it. -- **Growing corpus → recency-proportional width.** As the corpus grows past the window, the decay ranking gives recent work proportionally more of it. A paper read 300 days ago holds a quarter the width of one read 90 days ago. No reconfiguration — the window tracks your drift. - -The corpus never rides into the model wholesale: the fold is a local, linear scan, and the model's context is a fixed-budget summary regardless of vault size. A 10,000-note vault costs the same per digest as a 10-note one. - -### The numbers - -What the window holds, per budget (generic): - -| Taste budget | What rides | -|---|---| -| 2.5 KB (default) | full directives + ~15 weighted terms + recent why-lines | -| 8 KB | full directives + ~4–6 recent taste cards (abstract + conclusions + why) + terms | -| 12 KB | full directives + ~8–10 cards + terms — around 12% of the model's context window | - -What it costs (measured in daily use at [Harmoniqs](https://harmoniqs.ai), three feeds): - -- **~650–980 papers screened per day** — every item in the multi-feed union is scored; the full feed is never truncated. -- **~470 model calls per digest** (every eligible item is screened by [Jev](https://typesafe.ai), the calibrated ranking model), **median 171 ms** per call, p90 212 ms. -- **~$0.02/day** at the default budget; the largest arm (a 12 KB taste budget) is still ~$0.08/day. The model's own window is 32k tokens — the largest arm uses about an eighth of it. -- **105 paper notes, a 64-PDF library**, and a median digest call already running at 99% of the old fixed budget — which is exactly why the budget became a knob. - -The honest caveat that shaped this design: the vendor documents *context rot* — accuracy falls as irrelevant state grows. So every enlargement is an **opt-in arm gated on measurement**: `arjev calibrate --replay-arms` re-scores your labeled candidates under each arm and reports Brier, precision@5, and the realized state size per arm, with honest n. Defaults flip only on a measured win, and the tool never edits its own config. - -## The division of labor - -Three sentences are the product: - -- **The decision model judges.** [Jev](https://typesafe.ai), the ranking model, is text-free by design: it scores, rescues, and rates with calibrated confidence, and it can never write a sentence. Every call is a typed decision, logged as a receipt. -- **Your agent writes — or your own hand does.** All the prose in the loop (note bodies, why-lines, takes on the picks) comes from an LLM agent or from you. The tool itself never writes prose and never edits an existing note. -- **The vault stays yours.** Your Obsidian vault is the source of truth: the taste profile is folded from your paper notes, your authored **taste directives** state in plain English what you want, and papers you keep become ground truth. Slack is an optional attention layer on top — the vault is the product. - -## Two ways to start - -**You already have a vault.** Your agent maps it: every note field is remappable (Zotero-style exports, bare-DOI notes, any custom schema), and `arjev inspect` proves the mapping landed by printing exactly what the config sees — checklist state, notes scanned, identity-bearing count — and exiting nonzero when something blocks. - -**You're starting fresh.** `arjev init` writes the config as a **checklist-file**: every decision slot (feeds, note-type, identity fields, taste knobs) carries its own one-line explanation, `feeds` starts empty, and your agent fills the file in with you — one interview question at a time. `arjev inspect` proves it green before the first digest. - -**No agent?** The same loop, your hand: `arjev init` → edit the file (every slot explains itself inline) → `arjev inspect` → `arjev digest`. The hand path is the same contract traversed slower — nothing here requires an agent. - -## Install - -Python 3.11+: - -```bash -uv tool install git+https://github.com/harmoniqs/arJev@v0.4.1 -arjev init --vault /path/to/your/vault # writes the checklist-file config -# fill feeds = [...] with your arXiv categories — yours or your agent's hand -arjev inspect # proves the config green (exit 0) -arjev digest # today's picks, explainable, to stdout +```text +arJev pick 2/5 for 2026-10-21 — mode: jev, ok +Parallel Omnidirectional Atom Rearrangement with Dynamic Holograms + who: Samuel A. Engel, Dewei Gong, Ziguang Lin +3 · Harvard University, Cambridge, Harvard-MIT Center for Ultracold Atoms, Cambridge + why: control, quantum, transport, array, atoms (score 5496.1) ``` -Point it at any Obsidian vault with literature notes (a frontmatter `type` value and an `arxiv:` or `doi:` identity field — both remappable for any schema). No vault yet? `arjev ingest` seeds one from what you already have. - -## The loop, and the staged worklist - -1. **Digest** — every day, the full multi-feed union is screened and ranked against the vault's taste profile; each pick carries its why-line. -2. **Keep** — a Slack reaction, `arjev keep`, or a checkbox scaffolds the paper into your vault as a note with `status: staged` — metadata only, zero taste contribution until prose exists. The staged gate is exact: an untouched stub provably changes nothing (the fold audits this every digest). -3. **Write** — `arjev staged` lists the staged notes awaiting prose; your agent (or your hand) writes each note's body and `why` line, then flips `status: staged` → `status: written` in the frontmatter. -4. **Enrich the library** — `arjev fetch ` downloads the paper's PDF into your library and emits the extracted text alongside it; `arjev backfill` does the whole corpus at once (paced, idempotent, arXiv-polite, converging on libraries that predate it). Library text is what gives each kept paper its abstract and conclusions inside the taste cards — a paper without it still counts, just with less to say. (A digest under the greedy policy also pays a missing PDF's extraction once, writing the `.txt` back — idempotent, same content fetch would write.) -5. **Learn** — a written body feeds taste exactly like a hand-authored note's; `arjev calibrate` replays past digests against your labels and reports Brier scores, reliability, precision@5 — with honest n, never fake confidence. +The *why* line shows which of your interests it matched. The *who* line shows +the authors, a ⭐ when someone you track wrote it, and the institutions printed +on the paper itself. -## Why +## How it works -Keyword-based paper alerts miss everything that isn't spelled the same way ("jitter robustness" never matches "amplitude noise"), never learn from what you actually read, and can't be installed by anyone outside the lab that built them. arJev fixes all three: +- **Your vault is the taste profile.** arJev reads your paper notes — what you + kept, how you rated it, the one line on why it mattered — plus an optional + note where you say in plain English what you want more and less of. +- **Every new paper gets judged.** With a [Jev](https://typesafe.ai) key, a + small calibrated model screens every paper in your feeds, so relevant work + surfaces even when it shares no keywords with your notes. Without a key, + arJev ranks by your vault's vocabulary. +- **Your reactions are the training data.** 👍 keeps a paper (it becomes a + vault note), 👀 saves it for later, ❌ skips it. Kept papers shape tomorrow's + ranking once they're written up. +- **It never writes your notes.** The ranking model only makes decisions; every + sentence in your vault comes from you or an AI agent you choose. -- **A decision model at the front line.** With a [Jev](https://typesafe.ai) key (the default), a cheap, calibrated model screens the **full feed** — every item, every day — so papers with zero keyword overlap still surface when they're relevant. Every call is a logged receipt. -- **The vault still explains every pick.** Your taste profile supplies the matched terms on each why-line and the tiebreak — and it carries the whole digest without a key: no Jev, no outage, no problem; the ranking falls back to the deterministic lexical order and the `mode:` line says exactly which engine ran. -- **A learning loop with a budget that scales.** The corpus grows, the context window doesn't — and every enlargement of that window earns its keep through measured calibration, not fashion. +## Quick start -## Seeding and taste - -- `arjev ingest --bibtex library.bib` — from a Google Scholar or Zotero export -- `arjev ingest --pdf-dir ~/papers` — from a PDF collection (ids from filenames or the arXiv stamp) -- `arjev ingest --slack-channel-id C0123456ABC` — from the papers **people** share in your Slack (bot posts are filtered; each seeded note carries who/where/when provenance) -- **Taste directives** — drop an `arjev-directives.md` in your vault: labs, companies, topics as lists, plus a prose body that rides first-class into the model's state. You write taste in English; the ranking follows. - -## Multi-feed - -```toml -feeds = ["cond-mat.mes-hall", "econ.TH", "q-bio.NC"] +```bash +uv tool install git+https://github.com/harmoniqs/arJev +arjev init --vault ~/path/to/your/vault # writes ~/.config/arjev/arjev.toml +# edit the config: set feeds = ["your", "arxiv", "categories"] +arjev inspect # checks the setup (exit 0 = ready) +arjev digest # today's picks, printed ``` -The union is deduped across feeds (cross-listings score once), and ranked as one list. No category is ever defaulted: an empty `feeds` blocks the digest with a plain-language error naming the slot. - -## Slack, on top of the vault (optional) +No paper notes yet? Seed the vault from a Zotero or Google Scholar export with +`arjev ingest --bibtex library.bib`, or from a folder of PDFs. -Each pick posts as its own message, so a 👍 maps 1:1 to a paper: reaction → label → **staged stub in your vault, automatically** — decision in Slack, memory in Obsidian. Thread replies count as `discussed`. A keepers allowlist decides whose reactions become labels. Full setup in **[docs/slack-setup.md](docs/slack-setup.md)** — five minutes, one Slack app. +**Full setup** — your existing notes, a Jev key, Slack, and a daily +schedule — is in **[docs/setup.md](docs/setup.md)**, about fifteen minutes. +**Using an AI agent?** Hand it **[docs/agent-setup.md](docs/agent-setup.md)** +and say "set this up for me". -Sinks combine: `arjev digest --post slack,vault` ranks once and both posts to Slack and writes the day's dated digest note into the vault (`digest_dir`, else `/digests/`). The vault note is written first and is the durable record — a Slack outage never costs it, a vault write failure never blocks Slack, and either failure exits 1 naming the sink. `--post` takes `stdout` (the default preview, never combined), `vault`, `slack`, or a comma list of the last two. +## Every day -## Have your agent set it up +Schedule `arjev daily` once (the [setup guide](docs/setup.md#9-run-it-every-day) +ships systemd and launchd files) and it runs the whole day: the digest, retried +if arXiv's feed is flaky; Slack ingest and reaction sync; and on Sundays, a +week-in-review of good papers that missed a daily top 5. -arJev is designed to be installed *by an AI agent on your behalf* — the interview, the schema mapping, Slack, seeding, timers, the staged prose loop, with acceptance checks at every step. Hand **[docs/agent-setup.md](docs/agent-setup.md)** to your agent and say "set this up for me." The runbook is field-agnostic: it works the same for a condensed-matter lab, an econ group, or a q-bio team. - -## Prose: the two seams - -arJev's core is deliberately **text-free** — deterministic rendering, and the decision model can't generate strings by design. Prose rides two seams, both filled by your agent (or your hand): - -- **Enrich (digest out)** — configure `enrich_command` and arJev pipes the canonical picks JSON to it, appending whatever comes back to the digest. Any LLM CLI works; **[docs/amicode-integration.md](docs/amicode-integration.md)** has the contract. -- **Write (vault in)** — the staged worklist above: `arjev staged` is the pull, and the prose lands in your notes where you review it like any other vault edit. - -## Calibration, honestly - -`arjev calibrate` joins the label ledger, the digest journal, and the decision receipts: Brier per primitive, reliability bins, precision@5. The report states its n, names its censoring (labels only exist for papers you were shown), and recommends thresholds — **you** apply them; the tool never tunes itself. - -`arjev calibrate --replay-arms` goes one further: it re-assembles each labeled candidate's context under every state arm — the incumbent fixed budget, the budget-greedy policy at 4/8/12 KB, each with and without candidate conclusions — re-scores them live (conclusions fetched once, paced, from the shared cache), and reports Brier, n, and the realized state size per arm. That report is the only road to changing a default in this tool. - -## The week-in-review +| You | arJev | +|---|---| +| 👍 a pick in Slack | creates a vault note for it, waiting for a summary | +| 👀 / ❌ a pick | saves it to read later / marks it not for you | +| reply in a pick's thread | marks it discussed | +| write a kept note's summary and `why:` | uses it to rank tomorrow's papers | -The daily digest is capped at top-5 and sees one day of the firehose. `arjev review --days 7` is the across-days view: a pure re-read of the journal the digests already wrote — no fetches, no model calls, deterministic — in four sections. **The Buried**: the week's never-posted candidates re-ranked by Jev confidence (the cap's casualties). **Your People**: the author-lane matches, posted or not. **The Divergences**: the two tails where Jev and the words disagreed. **The Week's Receipts**: kept vs. skipped with the calibration headline, every n stated. Read-only — the review proposes, never stages; `arjev review --post slack` sends it to the channel the digest posts to. +## Commands -One network disclosure: corpus content resolves from local files only. The candidate-content arm is the one fetch a digest can make — bounded to the finalists, paced at 3 s, cached in the state dir, and fail-open: a dead network degrades that arm to abstract-only and the digest still ships, with the `mode:` line saying what ran. +| Command | What it does | +|---|---| +| `arjev init --vault PATH` | writes a commented starter config | +| `arjev inspect` | shows what the config sees; exits 1 if setup is incomplete | +| `arjev digest` | ranks today's papers; `--post vault`, `--post slack`, or `--post slack,vault` to deliver | +| `arjev daily` | the whole day, for a timer | +| `arjev ingest --bibtex FILE` | seeds the vault (also `--pdf-dir`, `--slack-channel-id`) | +| `arjev keep ARXIV_ID` | keeps a paper from a digest as a vault note | +| `arjev staged` | lists kept notes still waiting for a write-up | +| `arjev fetch ARXIV_ID` | downloads a paper's PDF into your library | +| `arjev backfill` | fetches PDFs for every paper in the vault | +| `arjev slack sync` | turns Slack reactions and replies into labels | +| `arjev labels sync` | records keeps from checked boxes in vault digest notes | +| `arjev review --days 7` | the week-in-review (`--post slack` to share it) | +| `arjev calibrate` | how well the ranking matches your reactions | +| `arjev rate propose` | suggests ratings for your notes; `arjev rate accept` applies one | + +Every command takes `--config PATH`, and `--help` on any command lists its options. + +## Documentation + +| | | +|---|---| +| [docs/setup.md](docs/setup.md) | step-by-step setup and troubleshooting | +| [docs/slack-setup.md](docs/slack-setup.md) | the Slack app, in five minutes | +| [docs/agent-setup.md](docs/agent-setup.md) | the runbook for an AI agent installing arJev | +| [docs/architecture.md](docs/architecture.md) | how ranking, the context window, and calibration work | +| [docs/amicode-integration.md](docs/amicode-integration.md) | connecting an agent to write notes and enrich digests | +| [CONTEXT.md](CONTEXT.md) · [docs/adr/](docs/adr/) | the glossary and design decisions | +| [CHANGELOG.md](CHANGELOG.md) | what changed in each release | +| [AGENTS.md](AGENTS.md) | working on arJev itself — layout, tests, releases | ## Status -v0.4.1. v1 in daily use at [Harmoniqs](https://harmoniqs.ai). [Issue #1](https://github.com/harmoniqs/arJev/issues/1) is the plan of record (spec, compiled plan, obligation register); the context engine is [issue #75](https://github.com/harmoniqs/arJev/issues/75); the week-in-review is [issue #83](https://github.com/harmoniqs/arJev/issues/83). +In daily use at [Harmoniqs](https://harmoniqs.ai) since October 2026. The plan +of record is [issue #1](https://github.com/harmoniqs/arJev/issues/1). Apache-2.0. diff --git a/contrib/launchd/io.github.harmoniqs.arjev.daily.plist b/contrib/launchd/io.github.harmoniqs.arjev.daily.plist new file mode 100644 index 0000000..fc01c02 --- /dev/null +++ b/contrib/launchd/io.github.harmoniqs.arjev.daily.plist @@ -0,0 +1,37 @@ + + + + + + Label + io.github.harmoniqs.arjev.daily + ProgramArguments + + __HOME__/.local/bin/arjev + daily + + EnvironmentVariables + + ARJEV_JEV_KEY_FILE + __HOME__/.config/arjev/jev.key + ARJEV_SLACK_TOKEN_FILE + __HOME__/.config/arjev/slack-token + + StartCalendarInterval + + Hour + 9 + Minute + 0 + + StandardOutPath + __HOME__/Library/Logs/arjev-daily.log + StandardErrorPath + __HOME__/Library/Logs/arjev-daily.log + + diff --git a/contrib/systemd/arjev-daily.service b/contrib/systemd/arjev-daily.service new file mode 100644 index 0000000..afe2b87 --- /dev/null +++ b/contrib/systemd/arjev-daily.service @@ -0,0 +1,14 @@ +[Unit] +Description=arJev daily digest (digest, ingest, slack sync; Sundays add the review) +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +# Credentials stay in mode-600 files; a missing file just disables that feature +# (no Jev key → lexical-only ranking; no Slack token → vault-only digest). +Environment=ARJEV_JEV_KEY_FILE=%h/.config/arjev/jev.key +Environment=ARJEV_SLACK_TOKEN_FILE=%h/.config/arjev/slack-token +ExecStart=%h/.local/bin/arjev daily +# room for the digest's retries on a flaky feed (5 attempts, 10 min apart) +TimeoutStartSec=5400 diff --git a/contrib/systemd/arjev-daily.timer b/contrib/systemd/arjev-daily.timer new file mode 100644 index 0000000..4d80f1c --- /dev/null +++ b/contrib/systemd/arjev-daily.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Run the arJev daily digest every day at 13:00 UTC + +[Timer] +# arXiv's new listings are out by 13:00 UTC on weekdays +OnCalendar=*-*-* 13:00:00 UTC +Persistent=true +RandomizedDelaySec=300 + +[Install] +WantedBy=timers.target diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..a8e77a2 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,17 @@ +# arJev docs + +**Setting up** +- [setup.md](setup.md) — step by step, from install to a daily schedule, plus troubleshooting +- [slack-setup.md](slack-setup.md) — the Slack app ([manifest](slack-app-manifest.yaml)), the token, the channel +- [agent-setup.md](agent-setup.md) — the same setup as a runbook for an AI agent + +**Understanding it** +- [architecture.md](architecture.md) — ranking, the context window, the who-line, calibration, what touches the network +- [amicode-integration.md](amicode-integration.md) — how an agent writes notes and enriches digests +- [../CONTEXT.md](../CONTEXT.md) — the glossary +- [adr/](adr/) — design decisions and the alternatives they rejected + +**Reference** +- [../CHANGELOG.md](../CHANGELOG.md) — releases +- [brand/](brand/) — the icon and banner (generated by `scripts/brand.py`) +- [history/](history/) — the original v1 spec and plan diff --git a/docs/agent-setup.md b/docs/agent-setup.md index b337903..984f11f 100644 --- a/docs/agent-setup.md +++ b/docs/agent-setup.md @@ -11,6 +11,8 @@ which field; the interview (step 2) asks. Follow it top to bottom. Every step has an acceptance check you run verbatim — do not claim a step done without its check passing. Ask the human only where a browser or a taste decision is genuinely theirs (marked **HUMAN STEP**). +The human-facing version of these steps is [setup.md](setup.md); link the human +there for anything they do by hand. ## 0. What you are building — and what you write @@ -38,10 +40,11 @@ must respect:** ## 1. Install ```bash -uv tool install git+https://github.com/harmoniqs/arJev@v0.3.0 +uv tool install git+https://github.com/harmoniqs/arJev ``` -(`pipx install` works too. Python 3.11+ required.) +(`pipx install` works too. Python 3.11+ required. To pin, append `@vX.Y.Z` +from the [changelog](../CHANGELOG.md).) **Check:** `arjev --version` prints a version. @@ -224,48 +227,54 @@ arjev ingest --slack-channel-id C0123456ABC --limit 200 ## 6. Keys (optional, both fail open) -- **Jev** (semantic ranking of the full feed): get a key from - , drop it in a mode-600 file, and set - `ARJEV_JEV_KEY_FILE=` in the timer environment. Cost is ~pennies/day/feed. - Without it: lexical-only, clearly flagged. -- **Slack** (optional attention layer): see [slack-setup.md](slack-setup.md). - **HUMAN STEP** — creating the Slack app requires their browser. Scopes: - `chat:write`, `reactions:read`, `channels:history` (manifest: `docs/slack-app-manifest.yaml`). Token in a mode-600 - file; `ARJEV_SLACK_TOKEN` env. Set `channel` (the channel ID), `keepers` - (user IDs whose reactions count as labels), `why_style` in the config. - -**Check:** with a Jev key, the digest's `mode:` line says `jev`. +Both credentials live in mode-600 files under `~/.config/arjev/`; a missing +file disables that feature and nothing else. + +- **Jev** (semantic ranking of the full feed). **HUMAN STEP** — the human signs + up at for their own key, then saves it: + `install -m 600 /dev/stdin ~/.config/arjev/jev.key`. arJev reads it through + `ARJEV_JEV_KEY_FILE`. Without it: lexical-only ranking, flagged in the `mode:` line. +- **Slack** (optional attention layer). **HUMAN STEP** — creating the app needs + their browser. Walk them through [slack-setup.md](slack-setup.md): create the + app from `docs/slack-app-manifest.yaml` (four scopes: `chat:write`, + `reactions:read`, `channels:history`, `channels:join`), save the token to + `~/.config/arjev/slack-token` (read through `ARJEV_SLACK_TOKEN_FILE`), and set + `[slack] channel` (the channel **ID**), `keepers`, `who_style`, `why_style`. + Never put a token in the config or a repo. + +**Check:** `ARJEV_JEV_KEY_FILE=~/.config/arjev/jev.key arjev digest` shows +`mode: jev`. With Slack: `ARJEV_SLACK_TOKEN_FILE=~/.config/arjev/slack-token arjev slack sync` +exits 0. ## 7. The daily run + timers -One manual run first: `arjev digest --post slack` (or `vault`/`stdout`). The -digest reads the configured `feeds` as one deduped union. To post to Slack AND -write the dated digest note into the vault from the same run, list both: -`arjev digest --post slack,vault` — one ranking (one Jev pass), the vault note -written first, posted ids consumed once. A failure in one sink never costs the -other: the run still prints, names the failed sink on stderr, and exits 1. -`stdout` is the preview and cannot be combined with a durable sink. Then a timer — Linux -systemd user unit (daily 13:00 UTC): - -```ini -# ~/.config/systemd/user/arjev-digest.timer -[Timer] -OnCalendar=*-*-* 13:00:00 UTC -Persistent=true +`arjev daily` is the whole day in one command: the digest (retried with backoff +when arXiv's RSS is flaky; a quiet day on weekends, when arXiv publishes +nothing), then — when a Slack token and channel are configured — ingest of +shared papers and `slack sync`; on Sundays, the calibration report and the +week-in-review. Without Slack, the digest goes to the vault note only. Run it +once by hand first: + +```bash +arjev daily ``` -with a `.service` running a wrapper that exports the key env vars from their -mode-600 files, then `arjev digest --top 5 --post slack,vault` and `arjev slack sync` -(idempotent; harvests reactions → labels → staged stubs). macOS: a launchd plist -with the same shape. **Check:** `systemctl --user list-timers` shows it armed -(or `launchctl list` on macOS), and the first manual run posted. +Then schedule it with the shipped units, which set both credential file paths: +on Linux the systemd service + timer in `contrib/systemd/`, on macOS the +launchd plist in `contrib/launchd/`. The exact install commands are in +[setup.md](setup.md#9-run-it-every-day); on Linux also run +`loginctl enable-linger "$USER"` so the timer fires while they're logged out. + +**Check:** `systemctl --user list-timers` lists `arjev-daily.timer` (or +`launchctl list | grep arjev` on macOS), and the manual `arjev daily` ended +with `done`. ## 8. The staged loop — your prose obligation Keeping a paper (Slack reaction, `arjev keep`, or a checkbox) scaffolds a note with `status: staged` — metadata only, zero taste contribution until prose exists. Writing that prose is **your** obligation (the model can't; the tool -won't): +won't). Schedule this pass right after the daily run: 1. Pull the worklist: `arjev staged` — one stable line per staged note, `id | title | kept-when | kept-by`, and nothing else. @@ -284,12 +293,11 @@ this pass with the daily run. - React 👍/👀/❌ on picks (if keepers-listed) — each becomes a label + a vault stub. - Fill `rating:` and `why:` on notes — that's what makes notes taste. -- `arjev calibrate` (weekly; add it to the timer on Sundays) — read the report; - thresholds are human-applied, by design. -- `arjev review --days 7` (Sundays, after the digest) — the across-days view: - the buried, the roster matches, the divergences, the week's receipts. Read-only, - deterministic, n-stated; posts with `arjev review --days 7 --post slack` if the - channel is configured. +- `arjev calibrate` — the weekly report (`arjev daily` writes it to the state + dir on Sundays); thresholds are human-applied, by design. +- `arjev review --days 7` — the week-in-review: relevant papers that missed a + daily top 5, tracked-author matches, and the week's labels. `arjev daily` + posts it to Slack on Sundays. - Rate-proposals: `arjev rate propose` asks the model for advisory first-pass ratings (side table only, vault untouched); `arjev rate accept ` is the human gate that writes the rating. @@ -299,9 +307,11 @@ this pass with the daily run. - **`feeds not configured`** at digest, or `blocked: no feeds configured` at `arjev inspect` — the `feeds` slot is empty; fill it from the taxonomy, never with a guessed default. -- **"feed parsed to zero items"** — arXiv RSS occasionally returns junk mid-day; - the digest refuses to post empty and the next run recovers. -- **Slack `missing_scope`** — the app lacks a scope; the setup doc lists all three. +- **`feed failure — every configured feed returned 0 items`** — on a weekday, + arXiv RSS is briefly empty and `arjev daily` retries; on Saturday or Sunday it + is expected (no new listings) and logged as a quiet weekend. +- **Slack `missing_scope`** — the app lacks a scope; recreate it from the manifest. +- **Slack `not_in_channel`** — the bot isn't in the digest channel; `/invite @arJev`. - **`profile-degraded` in the mode line** — fewer than 5 notes or no `why` lines yet; seed more, or write a few (that's your job anyway — step 8). - **A type mismatch** (`fold type-matched: 0` but `fold identity-bearing` healthy) diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..d0b6e3d --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,138 @@ +# How arJev works + +The design in one paragraph: arJev folds your vault into a taste profile, judges +every new paper in your feeds against a fixed-size summary of that profile, and +posts a few picks with their reasons. Your reactions become labels; the papers +you keep become notes; written notes feed the profile. The ranking model only +ever makes typed decisions — every sentence in the system comes from you or +your agent. + +## The division of labor + +- **The decision model judges.** [Jev](https://typesafe.ai), the ranking model, + is text-free by design: it scores, rescues, and rates with calibrated + confidence, and it cannot write a sentence. Every call is logged as a receipt. +- **Your agent writes, or you do.** All prose — note bodies, why-lines, takes on + the picks — comes from an LLM agent or a person. arJev itself never writes + prose and never edits an existing note ([ADR 0001](adr/0001-vault-generation-is-the-agents-contract.md)). +- **The vault stays yours.** Your Obsidian vault is the source of truth: the + profile is folded from your paper notes, your taste directives say in plain + English what you want, and papers you keep become ground truth. Slack is an + optional attention layer on top. + +## The context engine: how a corpus becomes a ranking + +The invariant: **your corpus can grow without bound; the model's window never +does — and that is the mechanism, not a limitation.** Every digest runs the +same fold: + +1. **Fold** — every note under your roots is scanned once; kept papers become + records (title, your `why`, your rating, tags, the vault's link graph). +2. **Taste cards** — each kept paper becomes a card: your why, the paper's own + abstract, and its conclusions (extracted from your local PDF library), with a + 180-day half-life and your rating as weights. Your directives ride first. +3. **The budgeted window** — each candidate is judged against a fixed, + byte-budgeted context assembled for it: directives, then cards, terms, and + why-lines filled greedily by recency-weighted priority. + +Two phases fall out of that one mechanism: + +- **Small corpus → maximal fill.** With a handful of kept papers, everything + rides the window. +- **Growing corpus → recency-proportional width.** Past the window's size, decay + gives recent work proportionally more of it — a paper read 300 days ago holds + a quarter the width of one read 90 days ago. The window tracks your drift with + no reconfiguration. + +The corpus never rides into the model wholesale: the fold is a local linear +scan, and the model's context is a fixed-budget summary, so a 10,000-note vault +costs the same per digest as a 10-note one. + +| Taste budget (`taste_budget_bytes`) | What rides | +|---|---| +| 2.5 KB (default) | full directives + ~15 weighted terms + recent why-lines | +| 8 KB | full directives + ~4–6 recent taste cards + terms | +| 12 KB | full directives + ~8–10 cards + terms — about 12% of the model's window | + +Measured in daily use at [Harmoniqs](https://harmoniqs.ai) (three feeds): about +650–980 papers screened per day (every paper in the union, never truncated), +about 470 model calls per digest at a median 171 ms, and about $0.02/day at the +default budget ($0.08/day at 12 KB). + +The vendor documents *context rot* — accuracy falls as irrelevant state grows — +so every enlargement is an opt-in arm gated on measurement: +`arjev calibrate --replay-arms` re-scores your labeled candidates under each arm +(the fixed budget, `budget-greedy` at 4/8/12 KB, each with and without candidate +conclusions) and reports Brier, precision@5, and realized state size, with n. +Defaults change only on a measured win, and the tool never edits its own config. + +## Ranking without a key + +With a Jev key (the default ranking, `jev-first`), the model screens the full +feed, so papers with no words in common with your notes still surface. Without +one, the ranking falls back to the deterministic lexical order from your taste +profile, and the digest's `mode:` line says which engine ran. Either way, every +pick's why-line shows the matched terms from your vault. + +## The who-line + +Each Slack pick shows who wrote the paper: the first three authors (`+N` for +the rest), tracked people as `⭐ Name (Group)` from your directives, and — with +`who_style = "full"` — the institutions printed on the paper's own first page. +At digest time no external index carries affiliations yet, so the paper's PDF +is the only same-day source; the parse is deliberately conservative, never +binds an institution to a person, and shows authors only when the page is +ambiguous ([ADR 0002](adr/0002-at-digest-time-affiliations-ride-from-the-papers-own-pdf.md)). + +## The loop + +1. **Digest** — the multi-feed union is screened and ranked; each pick carries + its why-line (and in Slack, its who-line). +2. **Keep** — a 👍 (harvested by `arjev slack sync`) or `arjev keep ` + creates a note with `status: staged`: metadata only, no taste yet. Checking a + pick's box in the vault digest note records a keep label (`arjev labels sync`). + An untouched stub provably changes nothing; the fold audits this every digest. +3. **Write** — `arjev staged` lists notes awaiting prose; your agent or you + write the body and a `why:`, then set `status: written`. +4. **Enrich the library** — `arjev fetch ` downloads a paper's PDF and + extracted text; `arjev backfill` does the whole corpus (paced, idempotent). + Library text gives taste cards their abstract and conclusions. +5. **Learn** — written notes feed taste; `arjev calibrate` replays past digests + against your labels and reports Brier scores, reliability, and precision@5 + with honest n. It recommends thresholds; you apply them. + +## Calibration, honestly + +`arjev calibrate` joins three records — the label ledger, the digest journal, +and the decision receipts — and reports Brier per decision type, reliability +bins, and precision@5. It states its n and its censoring: labels exist only for +papers you were shown, so it grades selection, not recall in general. + +## The week-in-review + +The daily digest shows the top 5 of one day. `arjev review --days 7` is the +across-days view, a deterministic re-read of the journal with no fetches and no +model calls: relevant papers that missed a daily top 5, tracked-author matches, +where Jev and the keyword ranking disagreed (file report only), and the week's +labels. `arjev review --post slack` sends a channel-friendly version. + +## What touches the network + +- arXiv RSS, once per feed per digest. +- Jev, once per screened candidate (with a key). +- Slack, when configured: posting picks, reading reactions and replies. +- arXiv PDFs: `arjev fetch`/`backfill`, `who_style = "full"` (one per pick, + library-cached), and the opt-in candidate-content arm (finalists only, paced + at 3 s, cached). Every PDF fetch is fail-open: a dead network degrades the + feature and the digest still ships. + +Your vault and library are only ever read locally. + +## Records + +Everything arJev records lives under `~/.local/state/arjev/` (XDG state), +never in your vault or a repo: the digest journal, Jev receipts, the label +ledger, posted ids, and the calibration and review reports. + +Further reading: [CONTEXT.md](../CONTEXT.md) (the glossary) and the +[ADRs](adr/) (decisions with their alternatives). diff --git a/docs/history/README.md b/docs/history/README.md new file mode 100644 index 0000000..53864d9 --- /dev/null +++ b/docs/history/README.md @@ -0,0 +1,6 @@ +# History + +The original v1 spec and compiled plan (2026-10-01), kept for provenance. +They describe arJev as first designed; the code, the [CHANGELOG](../../CHANGELOG.md), +and the [ADRs](../adr/) are the current record. The plan of record lives on +[issue #1](https://github.com/harmoniqs/arJev/issues/1). diff --git a/config/plans/plan-20261001-arjev-v1.md b/docs/history/plan-20261001-arjev-v1.md similarity index 100% rename from config/plans/plan-20261001-arjev-v1.md rename to docs/history/plan-20261001-arjev-v1.md diff --git a/config/specs/spec-20261001-113338-arjev-v1.md b/docs/history/spec-20261001-113338-arjev-v1.md similarity index 100% rename from config/specs/spec-20261001-113338-arjev-v1.md rename to docs/history/spec-20261001-113338-arjev-v1.md diff --git a/docs/release-notes-v0.3.0.md b/docs/release-notes-v0.3.0.md deleted file mode 100644 index 66e5097..0000000 --- a/docs/release-notes-v0.3.0.md +++ /dev/null @@ -1,83 +0,0 @@ -# arJev v0.3.0 - -**The one-line version:** arJev now serves any arXiv-served field by default instead of -presuming one lab's physics vault, the generation loop (keep → write → learn) is a -first-class verb surface, and the config file became a self-explaining checklist. - -## Behavior changes — read before upgrading - -**1. Neutral defaults: the feeds list is now empty.** -In v0.2.x, a config that never set `feeds` silently digested `quant-ph` — a hardcoded -category fallback. v0.3.0 defaults `feeds` to `[]`, and unconfigured feeds block plainly: - -- `arjev digest` exits with an error that names the fix: `feeds not configured — set - feeds = ["", …] in arjev.toml` — arJev ranks only what you configure - and never defaults to a category. -- `arjev inspect` prints `blocked: no feeds configured` and exits nonzero. - -**If you relied on the v0.2.x default, add `feeds = ["quant-ph"]` (or your own -categories) to `[vault]` in your `arjev.toml`.** arJev never silently ranks a category -you didn't choose. - -**2. `arjev init` refuses to rewrite an existing config.** -v0.2.x init overwrote `arjev.toml` unconditionally. v0.3.0 writes only where no config -exists and exits nonzero when one is already there. - -**3. The init template is now the checklist-file.** -The config init writes is fully commented — every decision slot (feeds, note type, -identity fields, taste knobs) carries its own one-line explanation — and field-neutral: -zero lab names, `feeds` starts empty with a pointer to the arXiv category taxonomy, and -the note-schema mapping ships with three complete remap examples (a physics vault, a -Zotero-style vault, an econ vault). A successful init is provably runnable: the -`--smoke-feed-file` pass digests through the written config itself. - -## New verbs - -**`arjev inspect`** — the read-only verification verb. It prints the checklist state -and exactly what the config sees in the vault — notes scanned, identity-bearing count, -fold warnings, directives, the staged count — and exits nonzero precisely when a -blocking condition holds: a closed set of no feeds, no roots, or zero identity-bearing -notes. It is the agent's acceptance check and the self-server's edit-verify loop, and it -never writes anything. - -**`arjev staged`** — the prose worklist. It lists, read-only, the kept notes awaiting a -body (`status: staged`) — which closes the generation loop: keep scaffolds, the -worklist names what awaits prose, your agent (or your own hand) writes each note's body -and why-line, and flipping the status to `written` feeds taste exactly like a -hand-authored note. - -## Internal, zero behavior change - -**Feed items carry a normalized `(source, id)` identity**, populated once at parse, and -every join routes on the pair. Persisted records (journal rows, posted-state, label -ledger) keep the bare id — those schemas are frozen — and re-join through the arxiv -namespace. This is the seam a future source adapter implements; nothing user-visible -changes in v0.3.0. - -## Docs - -- **README restructured user-path-first** — "you already have a vault" / "you're - starting fresh" / "no agent" as the three ways in, with the honest bound stated up - front: arJev serves any field that posts to arXiv and sources nothing beyond arXiv. -- **`docs/agent-setup.md`** — the agent install runbook, now field-agnostic. -- **`docs/amicode-integration.md`** — the two-way agent integration contract (enrich: - prose appended to the digest; write: prose written into staged notes) and who - generates what: the decision model judges, the agent writes, the vault stays yours. -- **`CONTEXT.md`** — the repo's first domain glossary (adoption, vault generation, - checklist-file, staged/written, inspect). -- **`docs/adr/0001-vault-generation-is-the-agents-contract.md`** — the doctrine on - record: vault prose generation is the agent's contract, because the decision model is - text-free by design — the tool never writes prose and never edits an existing note. - -## Upgrade - -```bash -uv tool install --reinstall git+https://github.com/harmoniqs/arJev@v0.3.0 -arjev --version -# prints: arjev 0.3.0 -``` - -Config format and CLI vocabulary are otherwise unchanged from v0.2.x; Python 3.11+. -The full verb surface: `arjev init`, `arjev digest`, `arjev keep`, `arjev fetch`, -`arjev staged`, `arjev inspect`, `arjev labels sync`, `arjev slack sync`, -`arjev ingest`, `arjev rate propose`, `arjev rate accept`, `arjev calibrate`. diff --git a/docs/release-notes-v0.4.0.md b/docs/release-notes-v0.4.0.md deleted file mode 100644 index fe9b210..0000000 --- a/docs/release-notes-v0.4.0.md +++ /dev/null @@ -1,51 +0,0 @@ -# arJev v0.4.0 - -**The one-line version:** the Jev state grew up — the taste context is now a budgeted, -recency-weighted window that can carry the papers' own words (abstracts, conclusions), -and every enlargement of that window is an opt-in arm gated on measured calibration. - -## What's new - -**The context engine (issue #75).** The corpus had outgrown the fixed-15 state (the -median production call ran at 4055 of 4096 bytes) while note bodies were compressed to -token frequencies — the papers' own prose never reached the model. One mechanism -replaces the fixed counts: - -- **Budget-greedy assembly** (`state_policy = "budget-greedy"`): directives ride whole - — the 800-char truncation of authored taste is gone — then per-paper **taste cards** - (your why + the paper's abstract + its conclusions/outlook excerpt) and terms fill - the configured `taste_budget_bytes` by recency-weighted priority. A small corpus fits - everything; a growing one gives recent work proportionally more of the window. The - 180-day half-life rides the weights, so the window tracks your drift with no - reconfiguration. -- **Paper content, both halves**: kept papers get their abstract and conclusions - extracted from your local PDF library (`arjev fetch` and `arjev backfill` emit the - extracted text at fetch time, paced and idempotent — backfill converges on libraries - that predate this release), and digest finalists can be re-scored with their fetched - conclusions in the candidate state (`candidate_content = true`; cached in the state - dir, never the PDF library; fail-open to abstract-only on any fetch failure). -- **The A/B that gates every default**: `arjev calibrate --replay-arms` re-scores your - labeled candidates under each arm — fixed-15, budget-greedy at 4/8/12 KB, each with - and without candidate conclusions — and reports Brier, n, and the realized state - size per arm. Arm receipts are quarantined from the production calibration join. - -## Behavior changes — read before upgrading - -There are none: **defaults are byte-identical to v0.3.x.** The incumbent fixed-15 -policy ships as the default; `budget-greedy` and `candidate_content` are opt-in arms -until `calibrate --replay-arms` shows a measured win (the vendor documents context -rot — accuracy falls as irrelevant state grows — so evidence decides, not economics). -One disclosure: under `budget-greedy`, a digest pays a missing PDF's text extraction -once and writes the `.txt` back into your library (idempotent, same content -`arjev fetch` would write). - -## New knobs - -- `state_policy` — `"fixed-15"` (default) | `"budget-greedy"` -- `taste_budget_bytes` — the taste half of the Jev state (default 2500) -- `candidate_content` — finalist conclusions enrichment (default false) -- `candidate_pace_s` — politeness gap between candidate PDF fetches (default 3.0) - -All three decision slots carry their one-line explanations in the init checklist-file -and appear in `arjev inspect` output. New dependency: `pypdf` (pure-Python, PDF text -extraction at the fetch/backfill seams). diff --git a/docs/release-notes-v0.4.1.md b/docs/release-notes-v0.4.1.md deleted file mode 100644 index 8fd5b56..0000000 --- a/docs/release-notes-v0.4.1.md +++ /dev/null @@ -1,41 +0,0 @@ -# arJev v0.4.1 - -**The one-line version:** the digest grew its across-days view — `arjev review` -re-reads the week's own records (no fetches, no model calls) and shows the -patterns a top-5-per-day cap structurally cannot. - -## What's new - -**The week-in-review (issue #83).** The journal already records every candidate from -every run — scores, pools, posted flags, author-lane matches. `arjev review --days 7` -is a pure, deterministic re-read of that record in four sections: - -- **The Buried** — the cap's casualties: never-posted candidates re-ranked by Jev - confidence, deduped across runs at their best judgment, top 10 of the window's - total (both numbers stated). -- **Your People** — the author-signal-lane matches this window, posted or not. -- **The Divergences** — the two rank-gap tails where Jev and the words disagreed, - biggest divergence first: jev-hot/lexical-buried and lexical-hot/jev-cold. -- **The Week's Receipts** — kept vs. skipped with the calibration headline - (precision@5, Brier n); an unlabeled week says so plainly instead of reading hollow. - -Read-only throughout — the review proposes, never stages; the staged worklist stays -human-gated. An empty window renders an honest report, a missing journal fails loud. -`--post slack` rides the digest's client seam to the configured channel, and the -Sunday timer wiring posts it after the digest (`review --days 7 --post slack`, report -filed in the state dir alongside the calibration report). - -Fail-open Jev rows (a lone `fail_reason` carries no judgment) never rank — no mass, -no line. On the live Harmoniqs journal the first run did its job immediately: the -jev-hot tail surfaced old cs.AI survey spam carrying suspicious `confidence: 1.0` -values while the lexical-hot tail was real physics (trapped-ion gates, bosonic-code -protection, Kitaev-chain readout) that Jev scored cold — exactly the pattern the -calibration needs labels to grade. - -## Repairs - -- The `v0.4.0` tag was never pushed at its release — the README install pin - `@v0.4.0` pointed at nothing. Both tags ship with this train: `v0.4.0` (retroactive, - at its bump commit) and `v0.4.1`. - -Upgrading: `uv tool install git+https://github.com/harmoniqs/arJev@v0.4.1 --force`. diff --git a/docs/release-notes-v0.5.0.md b/docs/release-notes-v0.5.0.md deleted file mode 100644 index e5156bf..0000000 --- a/docs/release-notes-v0.5.0.md +++ /dev/null @@ -1,42 +0,0 @@ -# arJev v0.5.0 - -**The one-line version:** the who-line — every pick now carries who wrote the -paper: authors, affiliations, and tracked-people hits, so the reaction moment -(👍/❌) finally shows *whose* work is being judged. - -## What's new - -**The who-line (issue #87).** Each Slack pick message gains a `_who:_` line -between the title and the why-line: - -- authors as the feed reports them — first three plus `+N` -- tracked-people hits always pulled in, even beyond the first three, each - rendered `⭐ Name (Group)` from the roster's curated group label -- affiliations as the paper's own PDF prints them — a deduped paper-level set, - never bound to individual authors, capped at four with `+k` - -Attribution doctrine (ADR 0002): at digest time no external source carries -affiliations — the arXiv API returned 0/25 in our probe, Semantic Scholar and -INSPIRE index same-week but with empty fields, OpenAlex lags by days — so the -only same-day source is the paper's own PDF. The parse is conservative by -construction: ambiguity degrades to authors-only, never to a guess, and a -degraded who-line never blocks the post. The 2026-10-02 roster-audit constraint -stands: attribution is the paper's printed claim or human curation, never -graph or model inference. - -**Roster group labels (issue #87).** Directives `authors:` entries may carry a -group — `authors: ["M. Lukin — Harvard-Lukin"]` — display-only; the matching -rule (surname + first-initial) ignores the suffix, and scoring is unchanged. - -**New config slot.** `[slack] who_style = "full" | "authors" | "none"` — -default `authors` (the PDF fetch stays opt-in for pip-installers); `full` -resolves each pick's PDF through the library-cached fetch, so a later -`arjev fetch` on a kept pick is a no-op. The vault digest note carries the same -attribution block per pick; the digest header gains a nonzero-only roster-hit -count; journal/state schemas are untouched. - -## Prior art and doctrine - -- ADR 0002 — *At-digest-time affiliations ride from the paper's own PDF* (with - the empirical source probe and the no-binding rule) -- `CONTEXT.md` — the who-line glossary entry diff --git a/docs/release-notes-v0.5.1.md b/docs/release-notes-v0.5.1.md deleted file mode 100644 index 51d468a..0000000 --- a/docs/release-notes-v0.5.1.md +++ /dev/null @@ -1,19 +0,0 @@ -# arJev v0.5.1 - -**The one-line version:** the first live who-line run caught the author blob — -arXiv RSS emits one comma-joined creator element, and until now names were -never split, so the who-line showed the whole blob and the author lane could -only fire on a list's last surname. - -## What's fixed - -**Names split at parse (the normalize-at-parse doctrine).** The feed parser -splits the creator blob into individual authors — comma forms, "and" forms, -single names. This fixes both surfaces downstream: the who-line's first-three -+ `+N` cap and tracked-people ⭐ hits (the live 2026-10-21 run rendered a -tracked co-author with no star), and the author-signal lane itself (issue #66), -which had been nearly blind in production — the weekly review's "Your People" -section can now match mid-list authors. - -**The affiliation marker class** also strips list punctuation (`)`, brackets) -left behind by superscript-adjacent author blocks. diff --git a/docs/release-notes-v0.5.3.md b/docs/release-notes-v0.5.3.md deleted file mode 100644 index 6da1ab7..0000000 --- a/docs/release-notes-v0.5.3.md +++ /dev/null @@ -1,15 +0,0 @@ -# arJev v0.5.3 - -**The one-line version:** the week-in-review ranked papers by Jev's certainty in -*whatever* label it picked — so papers Jev was 100% sure were irrelevant led -"The Buried". Fixed. - -## What's fixed - -The journal keeps only (top label, confidence in it). The review now orders by -label first, then confidence within it, and "The Buried" holds only papers Jev -judged must-read or worth-reading that the top-N cap dropped — the section's -actual meaning. The Divergences use the same ordering. Lines now print the label -next to the number (`jev must-read 0.59`), so a bare "1.0" can never read as -relevance again. The digest itself was unaffected: it always ranked by -must-read + worth-reading mass. diff --git a/docs/release-notes-v0.5.4.md b/docs/release-notes-v0.5.4.md deleted file mode 100644 index a9de02c..0000000 --- a/docs/release-notes-v0.5.4.md +++ /dev/null @@ -1,13 +0,0 @@ -# arJev v0.5.4 - -**The one-line version:** the week-in-review reads like a Slack message now. - -## What's fixed - -- **A Slack-native review.** `arjev review --post slack` posts Slack mrkdwn: bold - section titles instead of raw `#` headings, every title linked to arXiv, - readable dates, plain labels ("must-read", "worth reading"), and a one-line - nudge to react. The debugging-only divergence section and internal notes stay - in the file report (`render()`, stdout and `review-YYYYMMDD.md`), unchanged. -- **`\\( … \\)` inline math** in titles is cleaned like `$ … $` — in the review - and in daily picks. diff --git a/docs/release-notes-v0.5.5.md b/docs/release-notes-v0.5.5.md deleted file mode 100644 index 8ccc5b1..0000000 --- a/docs/release-notes-v0.5.5.md +++ /dev/null @@ -1,13 +0,0 @@ -# arJev v0.5.5 - -**The one-line version:** the reaction harvest works against real Slack. - -## What's fixed - -- **Form-encoded Slack calls.** The client sent every Web API call as a JSON - body. `chat.postMessage` accepts that, but `reactions.get` and - `conversations.replies` ignore JSON, so the first live `arjev slack sync` - failed with `no_item_specified`. Every call is now form-encoded, which all - methods accept. A wire-format test drives the real HTTP path. -- **Manifest:** adds `channels:join`, so the bot can join the digest channel - itself. diff --git a/docs/release-notes-v0.5.6.md b/docs/release-notes-v0.5.6.md deleted file mode 100644 index 06d86f6..0000000 --- a/docs/release-notes-v0.5.6.md +++ /dev/null @@ -1,17 +0,0 @@ -# arJev v0.5.6 - -**The one-line version:** one digest run can post to Slack and write the vault note. - -## What's new - -- **`--post` takes a comma list of sinks** (`arjev digest --post slack,vault`). The run - ranks once, writes the dated vault digest note, then posts to Slack. Posted ids are - consumed once and the journal is written once. -- **Partial failure is honest.** If one sink fails, the others still run. The CLI prints - the digest, names the failed sink on stderr, and exits 1. The vault note is written - first, so a Slack outage never loses the day's record. -- **Config errors fail early.** The Slack channel and token checks run before ranking, - so a misconfigured run spends no Jev calls. -- `stdout` (the preview) cannot be combined with a durable sink. - -Part of amicode rd-system v2.1, decision A8(a) (harmoniqs/arJev#106). diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 0000000..87633f0 --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,225 @@ +# Setting up arJev + +From nothing to a daily digest in about fifteen minutes. Each step ends with a +check you can run; if a check fails, see [Troubleshooting](#troubleshooting). + +Prefer to have an AI agent do this for you? Give it +[agent-setup.md](agent-setup.md) — the same steps, written as a runbook. + +**You need:** Python 3.11+, [uv](https://docs.astral.sh/uv/), and an Obsidian +vault (or an empty folder that will become one). Optional: a Jev key for +semantic ranking, and a Slack workspace for the shared channel. + +## 1. Install + +```bash +uv tool install git+https://github.com/harmoniqs/arJev +``` + +To pin a release instead, append `@vX.Y.Z` (see the [changelog](../CHANGELOG.md)). +`pipx install git+https://github.com/harmoniqs/arJev` works too. + +**Check:** `arjev --version` prints a version. + +## 2. Point it at your vault + +```bash +arjev init --vault ~/path/to/your/vault +``` + +This writes `~/.config/arjev/arjev.toml`. Every setting in it has a one-line +comment explaining it, and `arjev init` never overwrites an existing config. + +**If your vault already has paper notes**, tell arJev how they're written. A +paper note needs a `type` value marking it as a paper, and an identity field +holding its arXiv id or DOI. The defaults expect: + +```yaml +type: paper +arxiv: "2610.01234" +date_read: 2026-10-20 +rating: core # core | useful | marginal +why: "one line on why this paper matters to you" +tags: [topic, another] +``` + +If your notes use different field names, remap them under `[vault.fields]` +rather than changing your notes: + +| Your notes use… | Set this in `arjev.toml` | +|---|---| +| a different type value, e.g. Zotero's `journalArticle` | `[vault]` `type = "journalArticle"` | +| only DOIs | `[vault.fields]` `identity = ["doi"]` | +| `dateAdded` instead of `date_read` | `[vault.fields]` `read_date = "dateAdded"` | +| `takeaway` instead of `why` | `[vault.fields]` `why = "takeaway"` | +| `keywords` instead of `tags` | `[vault.fields]` `tags = "keywords"` | + +**If you're starting fresh**, leave the defaults; step 4 fills the vault. + +## 3. Choose your feeds + +arJev never picks categories for you. In `arjev.toml`, set the arXiv +categories you follow ([the full list](https://arxiv.org/category_taxonomy)): + +```toml +[vault] +feeds = ["quant-ph", "cond-mat.mes-hall"] +``` + +Cross-listed papers are counted once, and every new paper in every feed is +screened each day. + +## 4. Seed the vault + +Start the vault from what you already have. Every option is safe to re-run: +existing papers are skipped, and existing notes are never edited. + +```bash +arjev ingest --bibtex ~/Downloads/library.bib # a Google Scholar or Zotero export +arjev ingest --pdf-dir ~/Papers # a folder of PDFs +arjev ingest --slack-channel-id C0123456ABC # links people shared in Slack (needs step 8) +``` + +**Check:** `arjev inspect` exits 0, and its `fold identity-bearing:` line counts +your papers. It prints `blocked: …` and exits 1 if something is missing. + +## 5. Say what you want (optional, recommended) + +Create `arjev-directives.md` at the root of your vault. The lists become +high-weight terms; the prose is read by the ranking model as written. + +```markdown +--- +type: directives +labs: [Some Lab, Another Group] +companies: [A Company] +topics: [optimal control, error correction] +authors: ["A. Researcher — Their Lab", "Another Person"] +--- + +I want experimental results on superconducting and neutral-atom hardware, and +control methods with real benchmarks. De-prioritize pure complexity theory. +``` + +Authors are matched by surname and first initial. The `— Group` suffix is +display-only: it's shown next to the name when that person is on a pick. + +**Check:** `arjev inspect` shows `fold directives: loaded ( terms)`. + +## 6. Run your first digest + +```bash +arjev digest +``` + +This prints today's picks, each with its reason. Nothing is posted or saved; +`stdout` is a preview, so run it as often as you like. + +## 7. Add a Jev key (optional, recommended) + +Without a key, arJev ranks by matching your vault's vocabulary. With one, the +[Jev](https://typesafe.ai) model screens every paper in your feeds, so relevant +papers surface even when they share no words with your notes. It costs a few +cents a day. + +Get a key at , then save it to a file only you can +read (paste the key, press Enter, then Ctrl-D): + +```bash +install -m 600 /dev/stdin ~/.config/arjev/jev.key +``` + +**Check:** `ARJEV_JEV_KEY_FILE=~/.config/arjev/jev.key arjev digest` shows +`mode: jev` in its header. + +## 8. Connect Slack (optional) + +Slack turns the digest into a shared channel: each pick is its own message, and +reactions teach the ranking. Follow **[slack-setup.md](slack-setup.md)** (about +five minutes), which ends with the token saved at +`~/.config/arjev/slack-token` and this in `arjev.toml`: + +```toml +[slack] +channel = "C0123456ABC" # the channel ID, not its name +keepers = ["U0YOURID"] # Slack user IDs whose reactions count +``` + +## 9. Run it every day + +`arjev daily` runs the whole day: the digest (retried if arXiv's feed is +flaky), Slack ingest and reaction sync, and on Sundays the week-in-review. It +reads the key and token files from steps 7 and 8; either can be missing. + +**Linux (systemd):** + +```bash +mkdir -p ~/.config/systemd/user +for f in arjev-daily.service arjev-daily.timer; do + curl -fsSL -o ~/.config/systemd/user/$f https://raw.githubusercontent.com/harmoniqs/arJev/main/contrib/systemd/$f +done +systemctl --user daemon-reload +systemctl --user enable --now arjev-daily.timer +loginctl enable-linger "$USER" # so the timer also runs when you're logged out +``` + +**macOS (launchd):** + +```bash +P=~/Library/LaunchAgents/io.github.harmoniqs.arjev.daily.plist +curl -fsSL https://raw.githubusercontent.com/harmoniqs/arJev/main/contrib/launchd/io.github.harmoniqs.arjev.daily.plist \ + | sed "s|__HOME__|$HOME|g" > "$P" +launchctl bootstrap gui/$(id -u) "$P" +``` + +The macOS job runs at 9:00 local time; edit `Hour` in the plist if you're not +on US Eastern time (13:00 UTC is when arXiv's new listings are out). + +**Check:** run it once now with `arjev daily`. Then `systemctl --user +list-timers` (Linux) or `launchctl list | grep arjev` (macOS) shows it armed. +Logs: `journalctl --user -u arjev-daily` on Linux, `~/Library/Logs/arjev-daily.log` +on macOS. + +## 10. Day to day + +| You do | What happens | +|---|---| +| 👍 a pick in Slack | It becomes a note in your vault, waiting for a summary | +| 👀 a pick | It's saved to read later | +| ❌ a pick | It's marked as not for you | +| Reply in a pick's thread | It's marked as discussed | +| Fill `rating:` and `why:` on a note | That note starts shaping the ranking | + +Without Slack, keep a paper from a digest with `arjev keep `. + +Kept papers start as **staged** notes (metadata only). They begin to count once +someone writes them up: `arjev staged` lists them; write the summary and a +`why:` line, then change `status: staged` to `status: written`. An agent can do +this for you; see [agent-setup.md](agent-setup.md#8-the-staged-loop--your-prose-obligation). + +Weekly, `arjev review --days 7` shows relevant papers that missed a daily top 5, +and `arjev calibrate` reports how well the ranking matches your reactions. +`arjev daily` runs both on Sundays. + +## Troubleshooting + +| You see | What it means | Fix | +|---|---|---| +| `feeds not configured` | No categories chosen | Set `feeds` (step 3) | +| `blocked: zero identity-bearing notes` | arJev can't find paper notes | Seed the vault (step 4), or remap fields (step 2) | +| `fold type-matched: 0` with papers present | The `type` value doesn't match your notes | Set `[vault] type` to your notes' value | +| `profile-degraded` in the digest header | Fewer than 5 notes, or none with a `why:` | Seed more papers, write a few `why:` lines | +| `feed failure — every configured feed returned 0 items` on a weekday | arXiv's feed is temporarily empty | `arjev daily` retries automatically | +| The same message on a Saturday or Sunday | arXiv doesn't publish on weekends | Nothing; `arjev daily` logs a quiet weekend | +| `no Slack token` | The token file is missing or empty | Redo the token step in [slack-setup.md](slack-setup.md) | +| Slack `missing_scope` | The Slack app lacks a permission | Recreate it from the manifest in [slack-setup.md](slack-setup.md) | +| Slack `not_in_channel` | The bot isn't in the channel | `/invite @arJev` in the channel | + +## Upgrading + +```bash +uv tool upgrade arjev +``` + +Or reinstall a pinned release: `uv tool install --force git+https://github.com/harmoniqs/arJev@vX.Y.Z`. +The [changelog](../CHANGELOG.md) lists what changed. diff --git a/docs/slack-setup.md b/docs/slack-setup.md index b27aeb1..936870a 100644 --- a/docs/slack-setup.md +++ b/docs/slack-setup.md @@ -1,81 +1,90 @@ -# Slack setup — the dedicated arJev bot app +# Slack setup -arJev posts each digest pick as its own message, so Slack reactions map 1:1 to papers: -a `👍` on a pick message is a *keep* for that paper. Five minutes of setup, once. +arJev posts each digest pick as its own Slack message, so a reaction maps to +exactly one paper: 👍 keeps it, 👀 saves it for later, ❌ skips it. This takes +about five minutes, once. -## 1. Create the app +## 1. Create the app from the manifest -The fastest path is the manifest: [api.slack.com/apps](https://api.slack.com/apps) → -**Create New App** → *From an app manifest* → pick your workspace → paste -[`slack-app-manifest.yaml`](slack-app-manifest.yaml) → **Create**. Then skip to step 4. +1. Open → **From an app manifest** → pick + your workspace → **Next**. +2. Choose the **YAML** tab, replace its contents with + [`slack-app-manifest.yaml`](slack-app-manifest.yaml), then **Next** → **Create**. +3. Click **Install to Workspace** → **Allow**. +4. On **OAuth & Permissions**, copy the **Bot User OAuth Token** (`xoxb-…`). -By hand instead: +The manifest asks for four permissions, one per feature: -1. Go to [api.slack.com/apps](https://api.slack.com/apps) → **Create New App** → *From scratch*. -2. Name it (e.g. `arJev`), pick your workspace. -3. **OAuth & Permissions** → add the three Bot Token Scopes: - - `chat:write` — post digest picks - - `reactions:read` — harvest 👍 / 👀 / ❌ on pick messages - - `channels:history` — read thread replies (`discussed` labels) and human-shared links - for `arjev ingest` in a public channel (add `groups:history` for a private one). - Slack has no `conversations:replies` scope — reading replies is a history scope. -4. **Install to Workspace** → copy the **Bot User OAuth Token** (`xoxb-…`). -5. Invite the bot to your digest channel: `/invite @arJev`. +| Scope | Used for | +|---|---| +| `chat:write` | posting picks and the weekly review | +| `reactions:read` | turning 👍 / 👀 / ❌ into labels | +| `channels:history` | reading thread replies and papers people share (add `groups:history` for a private channel) | +| `channels:join` | letting the bot join the channel itself | -## 2. Configure arJev +Optional: on **Basic Information**, set the app icon to +[`brand/icon-light-1024.png`](brand/icon-light-1024.png). + +## 2. Save the token + +Paste the token, press Enter, then Ctrl-D: + +```bash +install -m 600 /dev/stdin ~/.config/arjev/slack-token +``` + +The file is readable only by you. arJev finds it through +`ARJEV_SLACK_TOKEN_FILE`, which the daily service in [setup.md](setup.md#9-run-it-every-day) +sets for you. Never put the token in `arjev.toml` or a repo. + +## 3. Configure the channel + +arJev needs the channel's **ID**, not its name. In Slack, open the channel, click +its name at the top, and copy the ID at the bottom of the panel (it starts with +`C`). Add to `~/.config/arjev/arjev.toml`: ```toml [slack] -channel = "#papers" # the digest channel -keepers = ["U0YOURID", "U0TEAMMATE"] # Slack user ids whose reactions count as labels -why_style = "terms" # terms | opaque | none — what the why-line reveals -who_style = "authors" # full | authors | none — the who-line under each pick -emoji_map = { thumbsup = "keep", eyes = "read-later", x = "skip" } - -[slack.emoji_map] # long form, equivalent -thumbsup = "keep" +channel = "C0123456ABC" # the digest channel's ID +keepers = ["U0123ABCD"] # Slack user IDs whose reactions count as labels +who_style = "full" # full | authors | none — see below +why_style = "terms" # terms | opaque | none — see below ``` -- **keepers** is the allowlist: a reaction from anyone *not* on the list labels nothing. - Find user ids with `arjev slack sync --config …` run once with everyone reacting, or - via the Slack API `users.list`. -- **why_style** controls what the why-line shows: `terms` (matched taste terms — your - own channel, fine), `opaque` (just "profile match"), or `none` (just the score). -- **who_style** controls the who-line — authors, affiliations, tracked-people hits: - `full` (authors plus affiliations parsed from each pick's own PDF — one paced fetch - per pick, library-cached, fail-open per ADR 0002), `authors` (authors + tracked - hits, no PDF fetch — the default), or `none`. Roster entries may carry a group: - `authors: ["M. Lukin — Harvard-Lukin"]`; the group is display-only and matching - ignores it. +- **keepers** — only reactions from these people become labels; anyone else's + reaction is ignored. Find your user ID in Slack under your profile → ⋮ → + **Copy member ID**. Every keeper's 👍 shapes the shared ranking, so list the + people whose taste the channel should follow. +- **who_style** — the line of authors under each pick. `full` also shows the + institutions printed on the paper (arJev fetches each pick's PDF to read them), + `authors` shows authors and tracked people only, `none` hides the line. +- **why_style** — the reason line. `terms` shows the matched taste terms, + `opaque` just says "profile match", `none` shows only the score. + +Then add the bot to the channel: type `/invite @arJev` there. -## 3. Environment +## 4. Check it ```bash -export ARJEV_SLACK_TOKEN="xoxb-…" # never in the repo, never in config -export ARJEV_JEV_KEY="…" # optional — without it the digest runs lexical-only +export ARJEV_SLACK_TOKEN_FILE=~/.config/arjev/slack-token +arjev digest --post slack,vault +arjev slack sync ``` -## 4. Run +The first command posts today's picks to the channel and writes the same +digest as a dated note in your vault. Put a 👍 on one pick, then run +`arjev slack sync` again: it should report a label, and a new note for that +paper appears in your vault with `status: staged`. -```bash -arjev digest --feed quant-ph --post slack # the daily digest (cron/launchd) -arjev digest --feed quant-ph --post slack,vault # same run, also the dated vault note -arjev slack sync # after: harvest reactions → labels + stubs -``` +From here, `arjev daily` (the scheduled service) does both every day. + +## What arJev posts, and what it reads -`slack sync` reads the digest journal for posted messages, harvests reactions -(keepers-gated) and thread replies, writes label rows, and **auto-scaffolds a staged -vault stub** for every new keep — decision in Slack, record in Obsidian, zero steps -between. Each stub lands with `status: staged`, awaiting prose (the write seam — -your agent or your hand writes the body and flips it to `written`; see -[agent-setup.md](agent-setup.md)). Re-run `arjev slack sync` as often as you like; -it's idempotent. Rate discipline is built in (≤ 1 request/s, 429 backoff). - -## Security notes - -- The token lives in the environment, never in the repo or config (the repo's - `.gitignore` blocks `*token*` defensively anyway). -- Digest messages carry only feed-derived content (title, abstract excerpt, link, - score) plus matched taste terms per `why_style` — never vault paths, note filenames, - or your `why` text. -- A thread reply marks a paper `discussed` — a flavor label; it never auto-keeps. +- **Posts:** each pick's title and arXiv link, its authors and printed + affiliations (per `who_style`), and the reason line (per `why_style`). Never + vault paths, note filenames, or your notes' text. +- **Reads:** reactions and thread replies on its own pick messages, and arXiv + links that people (not bots) share in the channel. +- A thread reply marks a paper as discussed; it never keeps it on its own. +- arJev stays under Slack's rate limits (at most one request per second, with + backoff on `ratelimited`). diff --git a/pyproject.toml b/pyproject.toml index f24584f..f43c5e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,16 +1,33 @@ [project] name = "arjev" -version = "0.5.6" +version = "0.6.0" description = "A daily arXiv digest engine that ranks against your living Obsidian vault." readme = "README.md" license = { file = "LICENSE" } requires-python = ">=3.11" +authors = [{ name = "Harmoniqs" }] +keywords = ["arxiv", "obsidian", "papers", "research", "digest", "slack"] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Topic :: Scientific/Engineering", +] dependencies = [ "PyYAML>=6.0", "httpx>=0.27", "pypdf>=4.0", ] +[project.urls] +Homepage = "https://github.com/harmoniqs/arJev" +Documentation = "https://github.com/harmoniqs/arJev/blob/main/docs/setup.md" +Changelog = "https://github.com/harmoniqs/arJev/blob/main/CHANGELOG.md" +Issues = "https://github.com/harmoniqs/arJev/issues" + [project.optional-dependencies] dev = [ "pytest>=8.0", diff --git a/src/arjev/__init__.py b/src/arjev/__init__.py index 9eb30ea..f969112 100644 --- a/src/arjev/__init__.py +++ b/src/arjev/__init__.py @@ -3,4 +3,11 @@ The brand is arJev (capitalized J, mirroring arXiv); identifiers (package, CLI, config paths) are lowercase arjev — the same convention arXiv itself uses.""" -__version__ = "0.5.6" +from importlib.metadata import PackageNotFoundError, version + +# the version lives in exactly one place — pyproject.toml — and is read from the +# installed package's metadata; two hand-synced copies drifted twice in v0.5.x +try: + __version__ = version("arjev") +except PackageNotFoundError: # a bare source tree with no install + __version__ = "0+unknown" diff --git a/src/arjev/cli.py b/src/arjev/cli.py index 177b1fa..35b527d 100644 --- a/src/arjev/cli.py +++ b/src/arjev/cli.py @@ -1,4 +1,4 @@ -"""arjev CLI: init + digest (the verb surface grows per slice).""" +"""The arjev CLI: the argparse tree for every verb, and its dispatch.""" from __future__ import annotations @@ -101,6 +101,13 @@ def main(argv: list[str] | None = None) -> int: ) p_cal.add_argument("--json", action="store_true", help="machine-readable report") + p_daily = sub.add_parser( + "daily", help="the whole day for a timer: digest (retried), ingest, slack sync; Sundays add review" + ) + p_daily.add_argument("--config", default=None) + p_daily.add_argument("--retries", type=int, default=5, help="digest attempts on weekdays (default: 5)") + p_daily.add_argument("--backoff-min", type=float, default=10, help="minutes between attempts (default: 10)") + p_review = sub.add_parser( "review", help="the week-in-review: the buried, your people, the divergences, the receipts (read-only)" ) @@ -323,6 +330,13 @@ def main(argv: list[str] | None = None) -> int: print(json.dumps(dataclasses.asdict(report), indent=2, sort_keys=True) if args.json else report.render()) return 0 + if args.cmd == "daily": + from .config import load_config + from .daily import run_daily + + config_arg = ["--config", args.config] if args.config else [] + cfg = load_config(Path(args.config).expanduser() if args.config else None) + return run_daily(main, cfg, config_arg=config_arg, retries=args.retries, backoff_s=args.backoff_min * 60) if args.cmd == "review": from arjev.config import load_config from arjev.review import post_review, review diff --git a/src/arjev/daily.py b/src/arjev/daily.py new file mode 100644 index 0000000..35088d1 --- /dev/null +++ b/src/arjev/daily.py @@ -0,0 +1,119 @@ +"""`arjev daily`: the whole day in one command, so a timer is one line. + +The order and the operational lessons are encoded here rather than in each +deployment's shell glue: + +1. digest — retried with backoff, because arXiv RSS sometimes returns zero items + mid-day and the announced batch stays in the window all day. On Saturdays and + Sundays (UTC) arXiv publishes no new listings, so an empty feed is a quiet + day: one attempt, no retries, no failure. +2. ingest — papers people shared in the digest channel become vault notes. +3. slack sync — reactions and thread replies become labels; keeps become notes. +4. Sundays — the calibration report (to the state dir) and the week-in-review. + +Steps 2–4 run only when a Slack token is available. A failed step is logged +and the rest still run; the exit code is 1 if any step failed. +""" + +from __future__ import annotations + +import io +import sys +import time +from collections.abc import Callable +from contextlib import redirect_stdout +from datetime import UTC, datetime + +Runner = Callable[[list[str]], int] + + +def run_daily( + runner: Runner, + cfg, + *, + config_arg: list[str], + now: datetime | None = None, + retries: int = 5, + backoff_s: float = 600, + sleep: Callable[[float], None] = time.sleep, + has_slack: bool | None = None, + log: Callable[[str], None] | None = None, +) -> int: + now = now or datetime.now(UTC) + log = log or (lambda line: print(f"[{datetime.now(UTC):%Y-%m-%dT%H:%M:%SZ}] {line}", flush=True)) + slack = _slack_available() if has_slack is None else has_slack + slack_posting = slack and bool(cfg.slack_channel) + failed: list[str] = [] + + weekend = now.isoweekday() in (6, 7) + attempts = 1 if weekend else max(1, retries) + sinks = "slack,vault" if slack_posting else "vault" + for attempt in range(1, attempts + 1): + log(f"digest (attempt {attempt}/{attempts}, post {sinks})") + if _ok(runner, ["digest", "--post", sinks, *config_arg], log): + break + if attempt < attempts: + log(f"digest attempt {attempt} failed — retrying in {backoff_s / 60:g} min") + sleep(backoff_s) + elif weekend: + log("quiet weekend — arXiv publishes no new listings on Saturday or Sunday; no digest (not an outage)") + else: + log(f"digest FAILED after {attempts} attempts") + failed.append("digest") + + if slack_posting: + log("ingest — papers shared in the digest channel") + if not _ok(runner, ["ingest", "--slack-channel-id", cfg.slack_channel, *config_arg], log): + failed.append("ingest") + log("slack sync — reactions and replies → labels") + if not _ok(runner, ["slack", "sync", *config_arg], log): + failed.append("slack sync") + else: + log("slack steps skipped — no Slack token or no channel configured") + + if now.isoweekday() == 7: + log("calibrate — the weekly report") + if not _ok(runner, ["calibrate", *config_arg], log, save_as=f"calibration-{now:%Y%m%d}.md"): + failed.append("calibrate") + log("review — the week in review") + review = ["review", "--days", "7", *(["--post", "slack"] if slack_posting else []), *config_arg] + if not _ok(runner, review, log, save_as=f"review-{now:%Y%m%d}.md"): + failed.append("review") + + log("done" if not failed else f"done with failures: {', '.join(failed)}") + return 1 if failed else 0 + + +def _ok(runner: Runner, argv: list[str], log, save_as: str | None = None) -> bool: + """Run one verb; a SystemExit or exception is a logged failure, never a crash. + With `save_as`, the verb's stdout is also written to the state dir.""" + buffer = io.StringIO() + try: + with redirect_stdout(buffer): + code = runner(argv) + except SystemExit as exc: + code = exc.code if isinstance(exc.code, int) else 1 + if exc.code and not isinstance(exc.code, int): + log(f" {exc.code}") + except Exception as exc: # one step's crash must not cost the others + log(f" {type(exc).__name__}: {exc}") + code = 1 + out = buffer.getvalue() + if out: + sys.stdout.write(out) + if save_as and out: + from .rerank import state_dir + + state_dir().mkdir(parents=True, exist_ok=True) + (state_dir() / save_as).write_text(out) + return code == 0 + + +def _slack_available() -> bool: + from .digest import slack_token + + try: + slack_token() + return True + except SystemExit: + return False diff --git a/src/arjev/digest.py b/src/arjev/digest.py index 5a90f9d..044fc59 100644 --- a/src/arjev/digest.py +++ b/src/arjev/digest.py @@ -109,9 +109,17 @@ def fingerprint_picks(picks: list[Pick]) -> str: def slack_token() -> str: + """ARJEV_SLACK_TOKEN, else the contents of ARJEV_SLACK_TOKEN_FILE — the same + file form the Jev key takes, so the token can stay in a mode-600 file.""" token = os.environ.get("ARJEV_SLACK_TOKEN") + if not token and (path := os.environ.get("ARJEV_SLACK_TOKEN_FILE")): + try: + token = Path(path).expanduser().read_text().strip() + except OSError: + token = None if not token: - raise SystemExit("ARJEV_SLACK_TOKEN not set (docs/slack-setup.md)") + raise SystemExit("no Slack token: set ARJEV_SLACK_TOKEN_FILE to a mode-600 token file, " + "or ARJEV_SLACK_TOKEN (docs/slack-setup.md)") return token @@ -266,7 +274,7 @@ def post_slack(): sc.post_message( cfg.slack_channel, f"*arJev daily digest — {today.isoformat()}* — {len(picks)} picks · {mode} · " - f"{len(items)} new items ranked against the lab vault{roster_note}", + f"{len(items)} new items ranked against your vault{roster_note}", ) for i, p in enumerate(picks): msg = render_pick_message(p, i + 1, len(picks), today, mode, cfg.why_style, diff --git a/src/arjev/render.py b/src/arjev/render.py index cf9918b..0af9f8e 100644 --- a/src/arjev/render.py +++ b/src/arjev/render.py @@ -42,14 +42,14 @@ def _why_terms(p: Pick, limit: int = 5) -> str: def render_mrkdwn(picks: list[Pick], feed_name: str, total: int, today: date, mode: str, skipped_corpus: int) -> str: header = f"*arXiv {feed_name} picks for {today.isoformat()}* ({len(picks)} of {total} new" - lines = [f"{header}, ranked against the lab corpus)", f"_{mode}_"] + lines = [f"{header}, ranked against your vault)", f"_{mode}_"] for i, p in enumerate(picks): lines.append(f"{i + 1}. ") lines.append(f" _why:_ {_why_terms(p)}") if not picks: - lines.append("_nothing matched the lab profile today_") + lines.append("_nothing matched your profile today_") if skipped_corpus: - lines.append(f"_{skipped_corpus} already in the lab corpus — skipped_") + lines.append(f"_{skipped_corpus} already in your vault — skipped_") return "\n".join(lines) @@ -77,7 +77,7 @@ def render_vault_note(picks: list[Pick], feed_name: str, total: int, today: date if who is not None: lines.append(f" - who: {render_who_line(who)}") if not picks: - lines.append("- nothing matched the lab profile today") + lines.append("- nothing matched your profile today") lines.append("") lines.append(f"_{skipped_corpus} in corpus · {total} new items_") return fm + "\n".join(lines) + "\n" diff --git a/tests/conftest.py b/tests/conftest.py index de1de2e..99eac37 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -36,7 +36,8 @@ def _no_live_state_or_credentials(monkeypatch, tmp_path): into the LIVE journal (found 2026-10-02: run 20261002T160824Z). Both are autouse now — no test can inherit the machine, period.""" monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state")) - for var in ("ARJEV_JEV_KEY", "ARJEV_JEV_KEY_FILE", "ARJEV_JEV_DISABLED", "ARJEV_SLACK_TOKEN"): + for var in ("ARJEV_JEV_KEY", "ARJEV_JEV_KEY_FILE", "ARJEV_JEV_DISABLED", "ARJEV_SLACK_TOKEN", + "ARJEV_SLACK_TOKEN_FILE"): monkeypatch.delenv(var, raising=False) diff --git a/tests/test_daily.py b/tests/test_daily.py new file mode 100644 index 0000000..2f64e4a --- /dev/null +++ b/tests/test_daily.py @@ -0,0 +1,114 @@ +"""`arjev daily`: the orchestration and its operational rules, tested offline +through an injected runner, clock, and sleep.""" + +from datetime import UTC, datetime + +from arjev.config import Config +from arjev.daily import run_daily + +WEDNESDAY = datetime(2026, 10, 21, 13, 0, tzinfo=UTC) +SATURDAY = datetime(2026, 10, 24, 13, 0, tzinfo=UTC) +SUNDAY = datetime(2026, 10, 25, 13, 0, tzinfo=UTC) + + +class Runner: + """Records every verb; `digest_failures` makes the first N digests fail.""" + + def __init__(self, digest_failures=0, fail=()): + self.calls, self.digest_failures, self.fail = [], digest_failures, set(fail) + + def __call__(self, argv): + self.calls.append(argv) + if argv[0] == "digest" and self.digest_failures: + self.digest_failures -= 1 + raise SystemExit("feed failure — every configured feed returned 0 items") + if " ".join(argv[:2]) in self.fail or argv[0] in self.fail: + return 1 + print(f"ran {' '.join(argv)}") + return 0 + + def verbs(self): + return [" ".join(a[:2]) if a[0] == "slack" else a[0] for a in self.calls] + + +def _cfg(channel="C0000000SYN"): + cfg = Config() + cfg.slack_channel = channel + return cfg + + +def _run(runner, now, **kw): + lines, sleeps = [], [] + code = run_daily(runner, _cfg(kw.pop("channel", "C0000000SYN")), config_arg=[], now=now, + sleep=sleeps.append, log=lines.append, has_slack=kw.pop("has_slack", True), **kw) + return code, lines, sleeps + + +def test_weekday_runs_digest_then_ingest_then_sync(): + runner = Runner() + code, _, _ = _run(runner, WEDNESDAY) + assert code == 0 + assert runner.verbs() == ["digest", "ingest", "slack sync"] + assert runner.calls[0] == ["digest", "--post", "slack,vault"] + assert runner.calls[1] == ["ingest", "--slack-channel-id", "C0000000SYN"] + + +def test_weekday_retries_a_flaky_feed_then_succeeds(): + runner = Runner(digest_failures=2) + code, lines, sleeps = _run(runner, WEDNESDAY, retries=5, backoff_s=600) + assert code == 0 + assert runner.verbs().count("digest") == 3 + assert sleeps == [600, 600] + + +def test_weekday_digest_that_never_recovers_fails_the_run_but_not_the_other_steps(): + runner = Runner(digest_failures=99) + code, lines, _ = _run(runner, WEDNESDAY, retries=3, backoff_s=1) + assert code == 1 + assert runner.verbs() == ["digest", "digest", "digest", "ingest", "slack sync"] + assert any("digest FAILED after 3 attempts" in line for line in lines) + + +def test_weekend_empty_feed_is_a_quiet_day_not_an_outage(): + runner = Runner(digest_failures=99) + code, lines, sleeps = _run(runner, SATURDAY, retries=5) + assert code == 0 + assert runner.verbs().count("digest") == 1 and sleeps == [] + assert any("quiet weekend" in line for line in lines) + + +def test_without_slack_the_digest_goes_to_the_vault_and_slack_steps_skip(): + runner = Runner() + code, lines, _ = _run(runner, WEDNESDAY, has_slack=False) + assert code == 0 + assert runner.calls == [["digest", "--post", "vault"]] + assert any("slack steps skipped" in line for line in lines) + + +def test_sunday_adds_calibrate_and_the_review(tmp_path, monkeypatch): + monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path)) + runner = Runner(digest_failures=99) # Sunday's feed is empty too + code, _, _ = _run(runner, SUNDAY) + assert code == 0 + assert runner.verbs() == ["digest", "ingest", "slack sync", "calibrate", "review"] + assert runner.calls[-1] == ["review", "--days", "7", "--post", "slack"] + assert (tmp_path / "arjev" / "calibration-20261025.md").read_text().startswith("ran calibrate") + assert (tmp_path / "arjev" / "review-20261025.md").exists() + + +def test_a_failing_step_is_logged_and_the_rest_still_run(): + runner = Runner(fail={"ingest"}) + code, lines, _ = _run(runner, WEDNESDAY) + assert code == 1 + assert runner.verbs() == ["digest", "ingest", "slack sync"] + assert lines[-1] == "done with failures: ingest" + + +def test_a_crashing_step_never_takes_the_run_down(): + def runner(argv): + if argv[0] == "ingest": + raise RuntimeError("slack conversations.history failed: ratelimited") + return 0 + + code, lines, _ = _run(runner, WEDNESDAY) + assert code == 1 and any("RuntimeError" in line for line in lines) diff --git a/tests/test_docs_commands.py b/tests/test_docs_commands.py index 5b10d73..85abbb6 100644 --- a/tests/test_docs_commands.py +++ b/tests/test_docs_commands.py @@ -16,7 +16,7 @@ from arjev.cli import main REPO = Path(__file__).resolve().parents[1] -DOCS = [REPO / "README.md", REPO / "CONTEXT.md", *sorted((REPO / "docs").rglob("*.md"))] +DOCS = [REPO / "README.md", REPO / "CONTEXT.md", REPO / "AGENTS.md", *sorted((REPO / "docs").rglob("*.md"))] # a documented command: a fence line leading with arjev (shell prompts allowed), # or an inline code span opening with it — `arjev.toml`/`arjev-digest.timer` diff --git a/tests/test_packaging.py b/tests/test_packaging.py new file mode 100644 index 0000000..e8443fc --- /dev/null +++ b/tests/test_packaging.py @@ -0,0 +1,17 @@ +"""Packaging invariants: the version has exactly one source, pyproject.toml.""" + +import tomllib +from pathlib import Path + +import arjev + +REPO = Path(__file__).resolve().parents[1] + + +def test_version_is_single_sourced_from_pyproject(): + # two hand-synced copies (pyproject + __init__) drifted twice in v0.5.x; + # __init__ now reads the installed metadata, so a release bumps one line + pyproject = tomllib.loads((REPO / "pyproject.toml").read_text()) + assert arjev.__version__ == pyproject["project"]["version"] + init = (REPO / "src" / "arjev" / "__init__.py").read_text() + assert pyproject["project"]["version"] not in init # no literal copy to drift diff --git a/tests/test_slack.py b/tests/test_slack.py index f43a98c..6a6926d 100644 --- a/tests/test_slack.py +++ b/tests/test_slack.py @@ -94,6 +94,31 @@ def handler(request): assert request.headers["authorization"] == "Bearer xoxb-test" +def test_slack_token_reads_a_file_like_the_jev_key(monkeypatch, tmp_path): + # ARJEV_SLACK_TOKEN_FILE mirrors ARJEV_JEV_KEY_FILE: the token stays in a + # mode-600 file, never in a process environment or a wrapper script + from arjev.digest import slack_token + + f = tmp_path / "slack-token" + f.write_text("xoxb-from-file\n") + monkeypatch.delenv("ARJEV_SLACK_TOKEN", raising=False) + monkeypatch.setenv("ARJEV_SLACK_TOKEN_FILE", str(f)) + assert slack_token() == "xoxb-from-file" + monkeypatch.setenv("ARJEV_SLACK_TOKEN", "xoxb-from-env") + assert slack_token() == "xoxb-from-env" # the env var wins when both are set + + +def test_missing_slack_token_names_both_options(monkeypatch): + import pytest + + from arjev.digest import slack_token + + monkeypatch.delenv("ARJEV_SLACK_TOKEN", raising=False) + monkeypatch.delenv("ARJEV_SLACK_TOKEN_FILE", raising=False) + with pytest.raises(SystemExit, match="ARJEV_SLACK_TOKEN_FILE"): + slack_token() + + # ── keepers enforcement ─────────────────────────────────────────────────────────── def test_unauthorized_reactions_label_nothing(): diff --git a/uv.lock b/uv.lock index fad15be..7f4bc42 100644 --- a/uv.lock +++ b/uv.lock @@ -17,7 +17,7 @@ wheels = [ [[package]] name = "arjev" -version = "0.5.1" +version = "0.6.0" source = { editable = "." } dependencies = [ { name = "httpx" },