damien 0.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. damien-0.4.1/.env.example +37 -0
  2. damien-0.4.1/.github/workflows/release.yml +42 -0
  3. damien-0.4.1/.github/workflows/test.yml +48 -0
  4. damien-0.4.1/.gitignore +19 -0
  5. damien-0.4.1/LICENSE +21 -0
  6. damien-0.4.1/PKG-INFO +342 -0
  7. damien-0.4.1/README.md +305 -0
  8. damien-0.4.1/docs/AUDIT-BRIEF.md +92 -0
  9. damien-0.4.1/docs/COMMANDS.md +202 -0
  10. damien-0.4.1/docs/FIRST-RUN.md +103 -0
  11. damien-0.4.1/docs/HOW-IT-WORKS.md +119 -0
  12. damien-0.4.1/docs/POLICY.md +115 -0
  13. damien-0.4.1/docs/PRIVACY.md +104 -0
  14. damien-0.4.1/docs/RUNBOOK.md +157 -0
  15. damien-0.4.1/docs/SETUP.md +181 -0
  16. damien-0.4.1/docs/SKILL-PROMPT.md +21 -0
  17. damien-0.4.1/pyproject.toml +71 -0
  18. damien-0.4.1/src/damien/__init__.py +42 -0
  19. damien-0.4.1/src/damien/calibrate.py +138 -0
  20. damien-0.4.1/src/damien/cli/__init__.py +131 -0
  21. damien-0.4.1/src/damien/cli/core.py +234 -0
  22. damien-0.4.1/src/damien/cli/plans.py +271 -0
  23. damien-0.4.1/src/damien/cli/rules_cmds.py +242 -0
  24. damien-0.4.1/src/damien/cli/runs.py +118 -0
  25. damien-0.4.1/src/damien/config.py +219 -0
  26. damien-0.4.1/src/damien/decide.py +273 -0
  27. damien-0.4.1/src/damien/doctor.py +139 -0
  28. damien-0.4.1/src/damien/embed.py +140 -0
  29. damien-0.4.1/src/damien/gmail.py +439 -0
  30. damien-0.4.1/src/damien/jev.py +305 -0
  31. damien-0.4.1/src/damien/mime.py +141 -0
  32. damien-0.4.1/src/damien/models.py +159 -0
  33. damien-0.4.1/src/damien/passes.py +173 -0
  34. damien-0.4.1/src/damien/plan.py +582 -0
  35. damien-0.4.1/src/damien/profile.py +77 -0
  36. damien-0.4.1/src/damien/rules.py +413 -0
  37. damien-0.4.1/src/damien/skill/SKILL.md +55 -0
  38. damien-0.4.1/src/damien/stakes.py +141 -0
  39. damien-0.4.1/src/damien/store.py +798 -0
  40. damien-0.4.1/tests/conftest.py +201 -0
  41. damien-0.4.1/tests/test_apply_journal.py +179 -0
  42. damien-0.4.1/tests/test_calibrate.py +66 -0
  43. damien-0.4.1/tests/test_cli.py +135 -0
  44. damien-0.4.1/tests/test_doctor.py +86 -0
  45. damien-0.4.1/tests/test_escalation.py +141 -0
  46. damien-0.4.1/tests/test_gmail.py +244 -0
  47. damien-0.4.1/tests/test_judge.py +416 -0
  48. damien-0.4.1/tests/test_mime.py +87 -0
  49. damien-0.4.1/tests/test_plan.py +580 -0
  50. damien-0.4.1/tests/test_profile.py +71 -0
  51. damien-0.4.1/tests/test_rules.py +307 -0
  52. damien-0.4.1/tests/test_rules_pass.py +98 -0
  53. damien-0.4.1/tests/test_setup.py +119 -0
  54. damien-0.4.1/tests/test_stakes.py +243 -0
  55. damien-0.4.1/tests/test_store.py +178 -0
@@ -0,0 +1,37 @@
1
+ # Damien — your install's settings. Never commit real values.
2
+
3
+ # The account this install works on (used to label stored rows).
4
+ DAMIEN_ACCOUNT=
5
+
6
+ # rules = deterministic only, no model calls, no key, nothing sent anywhere
7
+ # openrouter = a decision model through OpenRouter's decisions endpoint (needs OPENROUTER_API_KEY)
8
+ # typesafe = Jev direct from TypeSafe (needs TYPESAFE_API_KEY)
9
+ # local = a decision model you host yourself (needs DAMIEN_JUDGE_URL; DAMIEN_JUDGE_KEY if it asks for one)
10
+ # fake = deterministic stand-in, for a demo with no key
11
+ DAMIEN_JUDGE=rules
12
+ # Any System-One decision model: state + typed questions in, choice/confidence/probabilities out.
13
+ # On openrouter, e.g. typesafe/jev-1.14 or perplexity/pplx-decider-flash-v1.2. A model is only
14
+ # trusted to act unattended once `damien judge eval` has calibrated it (jev-1.13 keeps the
15
+ # 0.9 bar it was tuned at).
16
+ DAMIEN_MODEL=typesafe/jev-1.13
17
+ OPENROUTER_API_KEY=
18
+ # TYPESAFE_API_KEY=
19
+ # DAMIEN_JUDGE_URL=http://127.0.0.1:8080/v1/systemone (overrides the backend's default URL)
20
+ # DAMIEN_JUDGE_KEY=
21
+ # Parallel judgment requests (the public endpoints cap near eight).
22
+ # DAMIEN_JUDGE_CONCURRENCY=8
23
+
24
+ # Unset = one SQLite file at ~/.damien/damien.db. Set = Postgres.
25
+ # DAMIEN_DB_URL=postgres://user:password@127.0.0.1:5432/damien
26
+
27
+ # off | on_escalation. on_escalation reads the newest text of the messages the headers and
28
+ # snippet could not settle, and asks again - the only mode where message text reaches the
29
+ # judge (see docs/PRIVACY.md). What it reads is kept locally; attachments are never opened.
30
+ DAMIEN_READ_BODIES=off
31
+
32
+ # Labels the model may never act against: anything carrying one is kept.
33
+ DAMIEN_PROTECT_LABELS=ACTION REQUIRED,WAITING FOR,Bills
34
+
35
+ # 1 = filing a message (archiving it, labelling it out of the inbox) also marks it read, so
36
+ # "unread" keeps meaning "you have not looked at this yet". Kept mail is never touched.
37
+ # DAMIEN_MARK_READ_WHEN_FILED=0
@@ -0,0 +1,42 @@
1
+ name: release
2
+
3
+ # A tag `vX.Y.Z` publishes that version to PyPI. Publishing uses PyPI trusted publishing
4
+ # (OIDC): no token is stored in this repository.
5
+ on:
6
+ push:
7
+ tags: ["v*"]
8
+
9
+ jobs:
10
+ build:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: astral-sh/setup-uv@v5
15
+ - name: the tag names the version pyproject declares
16
+ run: |
17
+ declared="$(python3 -c 'import tomllib;print(tomllib.load(open("pyproject.toml","rb"))["project"]["version"])')"
18
+ test "v$declared" = "$GITHUB_REF_NAME" || { echo "tag $GITHUB_REF_NAME but pyproject says $declared"; exit 1; }
19
+ - run: uv build
20
+ - name: the wheel carries the agent skill
21
+ run: python3 -m zipfile -l dist/*.whl | grep -q 'damien/skill/SKILL.md'
22
+ - name: the wheel installs and runs
23
+ run: |
24
+ uv venv /tmp/smoke && VIRTUAL_ENV=/tmp/smoke uv pip install dist/*.whl
25
+ test "$(/tmp/smoke/bin/damien version | head -1)" = "damien ${GITHUB_REF_NAME#v}"
26
+ - uses: actions/upload-artifact@v4
27
+ with:
28
+ name: dist
29
+ path: dist/
30
+
31
+ publish:
32
+ needs: build
33
+ runs-on: ubuntu-latest
34
+ environment: pypi
35
+ permissions:
36
+ id-token: write
37
+ steps:
38
+ - uses: actions/download-artifact@v4
39
+ with:
40
+ name: dist
41
+ path: dist/
42
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,48 @@
1
+ name: tests
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ${{ matrix.os }}
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ os: [ubuntu-latest, macos-latest]
15
+ python: ["3.11", "3.13"]
16
+ # Service containers run on Linux runners only: ubuntu covers both store dialects,
17
+ # macOS covers SQLite (the Postgres half skips itself when DAMIEN_TEST_DB_URL is unset).
18
+ services:
19
+ postgres:
20
+ image: ${{ matrix.os == 'ubuntu-latest' && 'postgres:17' || '' }}
21
+ env:
22
+ POSTGRES_USER: damien
23
+ POSTGRES_PASSWORD: damien
24
+ POSTGRES_DB: damien_test
25
+ ports:
26
+ - 5432:5432
27
+ options: >-
28
+ --health-cmd "pg_isready -U damien"
29
+ --health-interval 5s
30
+ --health-timeout 5s
31
+ --health-retries 10
32
+ env:
33
+ DAMIEN_TEST_DB_URL: ${{ matrix.os == 'ubuntu-latest' && 'postgresql://damien:damien@localhost:5432/damien_test' || '' }}
34
+ steps:
35
+ - uses: actions/checkout@v4
36
+ - uses: actions/setup-python@v5
37
+ with:
38
+ python-version: ${{ matrix.python }}
39
+ - name: install
40
+ run: |
41
+ python -m pip install --upgrade pip
42
+ pip install -e '.[dev]'
43
+ - name: lint
44
+ run: |
45
+ pip install ruff
46
+ ruff check . # the standard lives in pyproject.toml
47
+ - name: test
48
+ run: pytest -q
@@ -0,0 +1,19 @@
1
+ # secrets and local state — never commit
2
+ .env
3
+ data/
4
+ *.db
5
+ *.db-wal
6
+ *.db-shm
7
+ credentials.json
8
+ token.json
9
+ *.token
10
+
11
+ # python
12
+ __pycache__/
13
+ *.py[cod]
14
+ .venv/
15
+ venv/
16
+ *.egg-info/
17
+ dist/
18
+ build/
19
+ .pytest_cache/
damien-0.4.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ivan Rivera
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
damien-0.4.1/PKG-INFO ADDED
@@ -0,0 +1,342 @@
1
+ Metadata-Version: 2.5
2
+ Name: damien
3
+ Version: 0.4.1
4
+ Summary: A Gmail triage CLI that asks before it acts: rules first, a model for the residue, and journalled, reversible writes.
5
+ Project-URL: Homepage, https://github.com/ivan-rivera-projects/damien
6
+ Project-URL: Repository, https://github.com/ivan-rivera-projects/damien
7
+ Project-URL: Issues, https://github.com/ivan-rivera-projects/damien/issues
8
+ Author: Ivan Rivera
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: automation,cli,email,gmail,inbox,llm,rules,triage
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Operating System :: POSIX :: Linux
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Communications :: Email
22
+ Classifier: Topic :: Office/Business
23
+ Requires-Python: >=3.11
24
+ Requires-Dist: google-auth-oauthlib>=1.2
25
+ Requires-Dist: google-auth>=2.30
26
+ Requires-Dist: httpx>=0.27
27
+ Requires-Dist: pydantic>=2.7
28
+ Requires-Dist: typer>=0.12
29
+ Provides-Extra: dev
30
+ Requires-Dist: psycopg[binary]>=3.1; extra == 'dev'
31
+ Requires-Dist: pytest>=8; extra == 'dev'
32
+ Provides-Extra: pg
33
+ Requires-Dist: psycopg[binary]>=3.1; extra == 'pg'
34
+ Provides-Extra: vectors
35
+ Requires-Dist: fastembed>=0.4; extra == 'vectors'
36
+ Description-Content-Type: text/markdown
37
+
38
+ # Damien
39
+
40
+ **A Gmail triage CLI that asks before it acts.** Rules decide what they can, a model judges
41
+ the residue, and code performs every write — in bulk, journalled, and reversible.
42
+
43
+ [![tests](https://github.com/ivan-rivera-projects/damien/actions/workflows/test.yml/badge.svg)](https://github.com/ivan-rivera-projects/damien/actions/workflows/test.yml)
44
+ ![license](https://img.shields.io/badge/license-MIT-blue)
45
+ ![python](https://img.shields.io/badge/python-3.11%2B-blue)
46
+
47
+ It was built to answer one question honestly: *can a mailbox of a hundred thousand
48
+ messages be put in order by software you would actually leave alone with your mail?*
49
+
50
+ ## Why it is different
51
+
52
+ - **It never acts on mail you have not read.** A plan is a document. Applying one takes an
53
+ approval token — printed once by `plan approve`, stored only as a hash — and
54
+ `--only <action>` applies exactly the slice you looked at.
55
+ - **Every write is reversible.** Each batch is journalled as `pending` *before* it reaches
56
+ Gmail and marked `done` after, so `run undo` puts back the state each message had when the
57
+ plan was built (inbox, unread, label). `trash` is a label and
58
+ Gmail holds it 30 days; there is no permanent-delete path in the code or the scope.
59
+ - **Rules run first, and they are free.** A rule that maps onto Gmail search syntax is
60
+ executed *by Gmail* — no listings, no headers, no model. On a settled mailbox a pass
61
+ costs **$0**.
62
+ - **The model only ever sees what your rules could not settle** — headers and a snippet —
63
+ and every answer is cached, so a message is never paid for twice. It reads the *text* only
64
+ if you ask it to (`DAMIEN_READ_BODIES=on_escalation`), only for the messages the snippet
65
+ could not settle, and never an attachment.
66
+ - **Your keys, your credential, nothing in the middle.** No server, no account here, no
67
+ telemetry. Run it with no model at all if you like (`DAMIEN_JUDGE=rules`).
68
+ - **It does not send.** There is no send *path*: no code in Damien sends, drafts-and-sends,
69
+ or forwards (the `gmail.modify` scope technically permits sending; Damien never calls it).
70
+
71
+ ## What it did on a real mailbox
72
+
73
+ One Gmail account, used daily, 107,710 messages in the inbox when this started. Every figure
74
+ below is measured by the tool itself, not estimated:
75
+
76
+ | | |
77
+ |---|---|
78
+ | **107,710 → under 7,000** messages in the inbox | 94,202 of them were 2024 or older |
79
+ | **101,048** messages acted on, across **29** applied runs | each one bulk, journalled, reversible |
80
+ | **44,985** judgements by the model · **27,072** decisions by rules | rules are mined from your own corrections — and they are free |
81
+ | **200 corrections → 68 rules**, 67 of them evaluated by Gmail itself | so 36% of all decisions now cost nothing and never drift |
82
+ | **$0.0000449 per judgement** | ≈**$0.045 per 1,000 messages**; a 7,054-message inbox took **$0.17** and about three minutes |
83
+ | **$0.000000** once the rules cover it | a settled pass re-decided 233 messages in **3 seconds**, 0 model calls |
84
+ | **44,985 judgements ≈ $2.02**, best case | and many were served from cache at no cost at all |
85
+ | a 200-message plan cold: **~10 s, $0.0075** · warm: **~1.0 s, $0.0000** | the cache key is the request, so unchanged mail is never bought twice |
86
+
87
+ That table is the whole argument for the architecture: **the expensive part shrinks as you
88
+ use it**, because every class of mail you decide once becomes a rule that costs nothing
89
+ forever. The model is the on-ramp, not the engine.
90
+
91
+ ## What it looks like
92
+
93
+ From a real run over the author's mailbox (7,054 messages still in the inbox; 68 rules;
94
+ about three minutes):
95
+
96
+ ```
97
+ $ damien plan build -q 'in:inbox' --show 12
98
+ plan 8f6ed799980e (7054 item(s))
99
+ archive 49
100
+ keep 6825
101
+ label 66
102
+ trash 114
103
+ judgment: 3709 call(s), 575 from cache, $0.1713
104
+ archive jev 121d 'Clean up your report, Ivan.' <notifications@mail.creditkarma.com>
105
+ keep rule 117d 'AI is making your next Apple device cost more' <theneuron@newsletter.theneurondaily.com>
106
+ label Receipts jev 13d 'You accepted an AWS Marketplace offer' <no-reply@marketplace.aws>
107
+ trash jev 13d '💀 You're Not Ready for This Friday…' <support@theduabrand.com>
108
+
109
+ $ damien plan approve 8f6ed799980e
110
+ token: 1f0c…
111
+
112
+ $ damien plan apply 8f6ed799980e --token 1f0c… --yes --only trash
113
+ run 2ba1162fdb34 ok: 114 message(s) in 1 batch(es) (trash slice)
114
+ the rest of the plan is still approved
115
+ to take it back: damien run undo 2ba1162fdb34 --yes
116
+ ```
117
+
118
+ Nothing in Gmail changed until that last command, and `run undo 2ba1162fdb34` reverses it.
119
+
120
+ ## Install
121
+
122
+ ```bash
123
+ uvx --from git+https://github.com/ivan-rivera-projects/damien damien --help # try it without installing
124
+ pipx install git+https://github.com/ivan-rivera-projects/damien # or install it
125
+ ```
126
+
127
+ From a checkout, if you want to hack on it:
128
+
129
+ ```bash
130
+ git clone https://github.com/ivan-rivera-projects/damien && cd damien
131
+ python -m venv .venv && .venv/bin/pip install -e '.[dev]'
132
+ ```
133
+
134
+ State lives in `~/.damien` (one SQLite file by default; set `DAMIEN_DB_URL` for Postgres).
135
+
136
+ ## Set it up
137
+
138
+ Two things are yours, and neither is shared with anyone else:
139
+
140
+ 1. **A Gmail credential of your own** — a Google OAuth *desktop* client from your own
141
+ Cloud project, dropped at `~/.damien/credentials.json`, then `damien login`. One scope
142
+ (`gmail.modify`), and you can revoke it whenever you like.
143
+ 2. **A judge, or none** — any decision model with the Jev contract: `openrouter`
144
+ (`DAMIEN_MODEL` + `OPENROUTER_API_KEY`), `typesafe` (`TYPESAFE_API_KEY`), `local`
145
+ (`DAMIEN_JUDGE_URL`; nothing leaves the machine), or `DAMIEN_JUDGE=rules` to use no model
146
+ at all and pay nothing. `damien judge eval` then calibrates the threshold for your model.
147
+
148
+ Then `damien init` writes your profile and a rules file, and `damien doctor` tells you
149
+ whether anything is missing (it changes nothing).
150
+
151
+ → **[`docs/SETUP.md`](docs/SETUP.md)** has the click-by-click, including the OAuth
152
+ testing-mode trap that expires tokens weekly (it caught this project's own first
153
+ credential). → **[`docs/FIRST-RUN.md`](docs/FIRST-RUN.md)** is the first hour, in order.
154
+
155
+ ## The pipeline
156
+
157
+ ```
158
+ scan ──► rules ──► (residue) ──► the judge ──► guardrail ──► confidence ──► plan ──► apply ──► journal
159
+ free one request a person's threshold review bulk undoable
160
+ server- per message signals win groups
161
+ side
162
+ ```
163
+
164
+ 1. **Rules first, and for free.** A rule that maps onto Gmail search syntax is executed *by
165
+ Gmail*. `to_query()` returns `None` when it cannot be certain, and the rule then runs in
166
+ Python over listed mail — a wrong query acts on the wrong mail, so `None` is the safe
167
+ direction.
168
+ 2. **Gmail's own work is trusted before ours.** Spam is never fetched. `CATEGORY_PERSONAL`
169
+ is kept by a rule with no model call. Gmail's category reaches the model as context.
170
+ 3. **The person's work is trusted above both.** Labels you name in
171
+ `DAMIEN_PROTECT_LABELS` are a floor the model cannot cross, your labels reach the model
172
+ as `your_labels`, and your own corrections outrank a rule and the model on every later
173
+ plan.
174
+ 4. **The judge sees only the residue**, as a small state (headers + snippet, never the
175
+ body, unless escalation is on) and *atomic* questions: which bucket, was it sent by a machine, does it want a
176
+ reply, how important. One request per message. The answers are kept as data, so
177
+ thresholds can be re-tuned without paying again.
178
+ 5. **A guardrail may only raise an action to `keep`** — never overrule you or a rule. It
179
+ fires on money and deadline subjects, replies from real people, mail you already read,
180
+ and Gmail's own `IMPORTANT`.
181
+ 6. **A confidence gate.** Below `DAMIEN_CONFIDENCE_ASK` a judgement keeps the message with
182
+ the reason attached: recorded, but not acted on.
183
+ 7. **Plans are read, then applied** — by group or by action, with every batch journalled
184
+ `pending` before it lands and `done` after, so a run is resumable and undoable.
185
+
186
+ ## Use
187
+
188
+ ```bash
189
+ damien doctor # is everything wired up?
190
+ damien scan -q 'in:inbox older_than:2y' # how much is there? reads only
191
+ damien plan build -q 'in:inbox' -n 200 # decide; writes nothing to Gmail
192
+ damien plan digest <plan-id> --min-size 5 # collapse it into review groups (N:fp)
193
+ damien plan approve <plan-id> # prints a token, once; only its hash is kept
194
+ damien plan apply <plan-id> --token <t> --only archive --yes
195
+ damien run undo <run-id> --yes # take a run back
196
+ damien correct <message-id> --action keep --note 'a brand we follow'
197
+ damien rules from-senders <plan-id> --yes # make your decisions permanent
198
+ damien rules plan --scope 'in:inbox older_than:2y' # a rule pass over history, bounded
199
+ damien judge eval # calibrate the model's threshold
200
+ damien pass weekly # the scheduled pass: build, approve, apply the certain
201
+ damien skill install --target openclaw # give an agent host the Damien skill
202
+ ```
203
+
204
+ `--json` on `status`, `doctor`, `scan`, `plan build|digest|show|approve|apply|apply-group`,
205
+ `run list|undo`, `rules list`, `correct`, `restore`, `judge eval`, `pass`.
206
+ Safe to run twice: `plan build` never writes to Gmail, an applied plan refuses to re-apply,
207
+ a partial run resumes only when its filter matches and only the batches its journal does not
208
+ hold, and an undone run cannot be undone twice.
209
+
210
+ ## Documentation
211
+
212
+ | document | read it for |
213
+ |---|---|
214
+ | [`docs/SETUP.md`](docs/SETUP.md) | your own Gmail credential and judge key, step by step |
215
+ | [`docs/FIRST-RUN.md`](docs/FIRST-RUN.md) | the first hour: look, apply, learn rules, schedule |
216
+ | [`docs/COMMANDS.md`](docs/COMMANDS.md) | every command — when to use it, what it writes, what it costs |
217
+ | [`docs/HOW-IT-WORKS.md`](docs/HOW-IT-WORKS.md) | the pipeline, the store, and what it refuses to do |
218
+ | [`docs/POLICY.md`](docs/POLICY.md) | the layers of authority, the seven buckets, the rule vocabulary |
219
+ | [`docs/RUNBOOK.md`](docs/RUNBOOK.md) | the weekly and monthly passes, failure modes, how to undo |
220
+ | [`docs/PRIVACY.md`](docs/PRIVACY.md) | exactly what leaves your machine, and when |
221
+
222
+ Two more are for machines or reviewers rather than users: `docs/SKILL-PROMPT.md` is a prompt
223
+ that has an AI agent (Hermes, OpenClaw, anything that runs commands) create its own skill for
224
+ driving this, including the scheduled passes below, and `docs/AUDIT-BRIEF.md` is a brief for an
225
+ independent reviewer — the invariants this tool promises, where each is enforced, and the
226
+ surfaces where a defect would hide.
227
+
228
+ ## Cleaning a mailbox of a hundred thousand messages
229
+
230
+ Brute force does not work, and the numbers say why: a Gmail header costs quota, and a
231
+ first attempt to fetch a whole inbox throttled after 64 seconds. A paced pass over 107,710
232
+ messages would take ~90 minutes of wall time before any judgement happened.
233
+
234
+ ```
235
+ 107,710 in:inbox
236
+ 94,202 2024 and older <- 87% of it, historical records
237
+ 2,256 Jan – Oct 2025
238
+ 1,717 Oct 21 – Dec 2025
239
+ 9,536 2026
240
+ ```
241
+
242
+ So the order is: **rule passes first, judgement second, and never both over the same
243
+ messages.**
244
+
245
+ | step | how | messages | cost |
246
+ |---|---|---|---|
247
+ | archive what a rule can describe | `rules plan --scope …` — Gmail evaluates it | tens of thousands | ~1 min, $0, no headers |
248
+ | judge what is left and recent | `plan build -q '<a date range>'` | a few thousand | ~$0.000046 each |
249
+ | keep it clean | `rules from-senders` on the recurring senders | — | free |
250
+
251
+ The rule query is always wrapped in `in:inbox` unless `--scope` says otherwise: a bare
252
+ `older_than:365d` matched 152,881 messages in this mailbox because it had escaped the inbox
253
+ and was reaching Sent and archived mail too.
254
+
255
+ ## Where state lives
256
+
257
+ | | SQLite (default) | Postgres (`DAMIEN_DB_URL`) |
258
+ |---|---|---|
259
+ | setup | none — one file | one env line |
260
+ | vectors | text column, clustered in Python | `message_embeddings` + `pgvector` |
261
+ | tested | ✅ | ✅ in CI: Linux runners start a `postgres:17` service; macOS runs SQLite only |
262
+
263
+ Tables: `messages`, `decisions`, `verdicts` (the judgement cache), `message_embeddings`,
264
+ `plans`, `plan_items`, `runs`, `run_batches`, `corrections`. A column added after release is
265
+ applied by `ADDED_COLUMNS` in `store.py` — never by dropping the table.
266
+
267
+ ## The judgement cache
268
+
269
+ The cache key *is* the request: the model, the questions as written, and the state the model
270
+ was shown. Reword a question, change the model, or add a label yourself — invalidated.
271
+ Nothing else. That is why a settled mailbox costs nothing to re-check, and why editing the
272
+ buckets in `decide.py` is the one change that re-buys every answer.
273
+
274
+ ## Review groups (the digest)
275
+
276
+ Senders do not collapse an inbox: 200 messages came from 140 senders, 102 with a single
277
+ message. Similarity does. Local embeddings (`fastembed`, no torch), greedy
278
+ nearest-centroid, and a threshold chosen from the data — 0.65 on this mailbox, where 0.70
279
+ left 75 groups and 0.60 dissolved the useful "4 finance offers" group into the marketing
280
+ blob. Result: 200 messages → 36 review groups.
281
+
282
+ ## What this deliberately does not do
283
+
284
+ - **No bodies unless you ask.** Judgement uses headers and a snippet. With
285
+ `DAMIEN_READ_BODIES=on_escalation` it also reads the *newest text* of the messages the
286
+ snippet could not settle — quoted replies stripped, capped, attachments never opened — and
287
+ keeps what it read on your machine so a re-plan costs nothing. Off by default.
288
+ - **No scheduler.** Have the host's own scheduler run `damien pass weekly` every Monday and
289
+ `damien pass monthly` on the 1st (see the RUNBOOK); the tool exits either way.
290
+ - **No permanent delete.** `trash` is recoverable for 30 days, and the code cannot do more.
291
+ - **No sending.** No send path: nothing sends, drafts-and-sends or forwards (the scope
292
+ technically permits sending; Damien never calls it).
293
+ - **No telemetry.** Nothing is reported anywhere.
294
+ - **No MCP door.** A small tool wrapper over these functions is the obvious next step for a
295
+ host that cannot shell out; the CLI costs zero tool schemas in a context.
296
+
297
+ ## Model facts this is built on (all from the vendor docs)
298
+
299
+ - One request per message with *every* question about it: questions in a call are
300
+ independent and parallel ("adding questions barely changes the response time"), while
301
+ many emails in one `state` costs accuracy ("Jev suffers from context rot").
302
+ - `confidence` (0–1) describes how peaked the answer is, **not** whether it is right —
303
+ hence conservative bands and a person as the last resort.
304
+ - $0.042/Mtok input, output free; the vendor's published measurement is ≈$0.0256 per 1,000
305
+ items, and this tool measured ≈$0.000046 per message on a real mailbox.
306
+ - A Choice always picks an option even when none fit, so a companion Noul and an `unclear`
307
+ bucket guard it.
308
+ - Never ask it to count, order or compute — that is code's job.
309
+
310
+ ### How the model actually behaved, measured
311
+
312
+ Across **44,985 judgements** on one real mailbox:
313
+
314
+ | | | |
315
+ |---|---|---|
316
+ | **10,735** | 24% | cleared the 0.8 bar and were **acted on unattended** |
317
+ | **11,611** | 26% | between the bars — reported, not acted on |
318
+ | **22,639** | 50% | **kept**, because it was not sure enough to touch anything |
319
+ | **36,775** (82%) | | of what it chose was `keep`; `trash` was only 5.5% of its answers |
320
+
321
+ Two things follow, and they are the reason this design is safe to leave running:
322
+
323
+ - **Half the model's answers never moved a message.** The confidence gate is not decoration:
324
+ a judgement below `DAMIEN_CONFIDENCE_ASK` is recorded and the message stays where it is.
325
+ - **The model is conservative, and the rules do the deleting.** Its instinct is `keep`
326
+ (82%); the 27,072 decisions made by rules — mined from your own corrections, and evaluated
327
+ by Gmail — are where the archive-and-trash volume actually lives. You decide the classes;
328
+ the model only handles what is left, and only when it is sure.
329
+
330
+ **200 corrections** were enough to produce **68 rules**, which is **0.44% of judgements**
331
+ touched by hand. That is the whole loop: correct a class once, and it never costs a model
332
+ call again.
333
+
334
+ ## Status
335
+
336
+ Beta, and in daily use on a 100,000-message mailbox. The test suite runs on Linux and macOS;
337
+ Postgres is exercised on the Linux runners. Interfaces may still move; the safety properties above will
338
+ not.
339
+
340
+ MIT licensed. The predecessor project — a four-service platform that this replaced — was
341
+ torn down in October 2026; its repositories remain on GitHub for history and nothing of it
342
+ runs.