damien 0.4.1__py3-none-any.whl

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.
damien/__init__.py ADDED
@@ -0,0 +1,42 @@
1
+ """Damien — a Gmail triage CLI that asks before it acts."""
2
+
3
+ from importlib.metadata import PackageNotFoundError
4
+ from importlib.metadata import version as _installed
5
+
6
+ try:
7
+ # Whatever was actually installed. It is the honest answer for a wheel install, but an
8
+ # *editable* install runs this source tree while carrying the metadata of whenever it was
9
+ # installed — so `source_version()` below exists to notice when the two disagree.
10
+ __version__ = _installed("damien")
11
+ except PackageNotFoundError: # a checkout with no install around it
12
+ __version__ = "0.0.0+source"
13
+
14
+
15
+ def source_version() -> str | None:
16
+ """The version this *source tree* declares, or None when there is no tree (a wheel).
17
+
18
+ `pyproject.toml` is the single source of truth for the version; the installed metadata is a
19
+ copy of it made at install time. A floor check that reads only the copy can stop a machine
20
+ that is in fact running newer code.
21
+ """
22
+ import tomllib
23
+ from pathlib import Path
24
+
25
+ for parent in Path(__file__).resolve().parents:
26
+ candidate = parent / "pyproject.toml"
27
+ if candidate.is_file():
28
+ try:
29
+ return tomllib.loads(candidate.read_text()).get("project", {}).get("version")
30
+ except (OSError, tomllib.TOMLDecodeError):
31
+ return None
32
+ return None
33
+
34
+
35
+ def version_note() -> str | None:
36
+ """A line for `damien version` when the metadata is stale, else None."""
37
+ declared = source_version()
38
+ if declared and declared != __version__:
39
+ return (f" running from a source tree that declares {declared} — the installed "
40
+ f"metadata says {__version__} (refresh it: uv tool install --editable "
41
+ f"'.[pg,vectors]' --force)")
42
+ return None
damien/calibrate.py ADDED
@@ -0,0 +1,138 @@
1
+ """Calibration: the confidence at which *this* model, on *this* host, may act unattended.
2
+
3
+ A decision model's confidence describes its answer, not its correctness, and two models
4
+ (or one model on two hosts) do not mean the same thing by 0.9. So the threshold is measured
5
+ against what this inbox already settled — a person's corrections and the rules' applied
6
+ work — and stored per model@host. A model without a measurement does not act on its own.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from collections.abc import Sequence
12
+ from datetime import UTC, datetime
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from .config import Settings
17
+ from .decide import JudgementSpec, decide_message
18
+ from .models import Action
19
+
20
+ CALIBRATION_FILE = "calibration.json"
21
+
22
+ # The pass ran jev-1.13 at 0.9 before calibration existed; keep that, on any host.
23
+ BASELINE_MODEL = "typesafe/jev-1.13"
24
+ BASELINE_ACT = 0.9
25
+
26
+
27
+ def _path(settings: Settings) -> Path:
28
+ return Path(settings.root) / CALIBRATION_FILE
29
+
30
+
31
+ def _entry_key(settings: Settings) -> str:
32
+ return f"{settings.model}@{settings.judge_host() or ''}"
33
+
34
+
35
+ def _load(settings: Settings) -> dict[str, Any]:
36
+ try:
37
+ return json.loads(_path(settings).read_text())
38
+ except (OSError, ValueError):
39
+ return {}
40
+
41
+
42
+ def act_threshold(settings: Settings) -> float | None:
43
+ """The calibrated threshold for the current model+host, the baseline's, or None (uncalibrated)."""
44
+ entry = _load(settings).get(_entry_key(settings))
45
+ if isinstance(entry, dict) and isinstance(entry.get("act"), (int, float)):
46
+ return float(entry["act"])
47
+ if settings.model == BASELINE_MODEL:
48
+ return BASELINE_ACT
49
+ return None
50
+
51
+
52
+ def _truth(store) -> dict[str, tuple[str, str | None]]:
53
+ """message_id -> (action, label) that a person or a rule settled, newest person correction winning."""
54
+ truth: dict[str, tuple[str, str | None]] = {}
55
+ runs = store.conn.execute(
56
+ "SELECT id, plan_id FROM runs WHERE status IN ('ok', 'partial') ORDER BY started_at").fetchall()
57
+ for run in runs:
58
+ applied: set[str] = set()
59
+ for batch in store.conn.execute("SELECT * FROM run_batches WHERE run_id=?", (run["id"],)).fetchall():
60
+ if "state" in batch.keys() and batch["state"] not in (None, "done"):
61
+ continue
62
+ applied.update(json.loads(batch["ids"]))
63
+ if not applied:
64
+ continue
65
+ for item in store.conn.execute(
66
+ "SELECT message_id, action, label FROM plan_items WHERE plan_id=? AND source IN ('rule', 'person')",
67
+ (run["plan_id"],)).fetchall():
68
+ if item["message_id"] in applied:
69
+ truth[item["message_id"]] = (item["action"], item["label"])
70
+ for mid, row in store.latest_corrections().items():
71
+ truth[mid] = (row["action"], row.get("label"))
72
+ return truth
73
+
74
+
75
+ MIN_ACTED = 30 # a bar set on a handful of answers is a guess, not a calibration
76
+
77
+
78
+ def evaluate(*, store, settings: Settings, spec: JudgementSpec, judge, limit: int = 500,
79
+ max_wrong_rate: float = 0.02, min_acted: int = MIN_ACTED) -> dict:
80
+ """Re-judge settled messages (headers only, no rules) and find the lowest safe threshold.
81
+
82
+ The full decide_message runs, guardrail included, because that is what would act;
83
+ no confidence floor is applied, so every threshold can be read off one pass.
84
+
85
+ "Safe" means: at least `min_acted` messages would have been acted on at that bar, and
86
+ the wrong rate stays within `max_wrong_rate` there *and* at every higher bar with as
87
+ much evidence — so the chosen bar never sits below a worse one.
88
+ """
89
+ truth = _truth(store)
90
+ ids = list(truth)[-limit:] if limit else list(truth)
91
+ messages = [m.model_copy(update={"body": None}) for m in store.get_messages(ids)]
92
+ judged: list[tuple[float, bool]] = [] # (confidence, wrong) for every non-keep action
93
+ criteria = spec.criteria()
94
+ batch = max(1, settings.judge_batch)
95
+ for start in range(0, len(messages), batch):
96
+ chunk: Sequence = messages[start:start + batch]
97
+ verdicts = judge.judge(chunk, criteria, context=spec.context)
98
+ for message, verdict in zip(chunk, verdicts, strict=False):
99
+ if message.id in getattr(judge, "failed", ()):
100
+ continue
101
+ d = decide_message(message, rules=(), verdict=verdict, spec=spec, model=settings.model,
102
+ protected_labels=settings.protect_labels)
103
+ if d.action is Action.keep:
104
+ continue
105
+ action, label = truth[message.id]
106
+ wrong = d.action.value != action or (d.action is Action.label and d.label != label)
107
+ judged.append((d.confidence or 0.0, wrong))
108
+
109
+ thresholds = []
110
+ for step in range(50, 100):
111
+ t = step / 100
112
+ acted = [w for c, w in judged if c >= t]
113
+ thresholds.append({"t": t, "acted": len(acted), "wrong": sum(acted)})
114
+ act = None
115
+ for row in reversed(thresholds): # from the strictest bar down
116
+ if row["acted"] < min_acted:
117
+ continue
118
+ if row["wrong"] / row["acted"] > max_wrong_rate:
119
+ break
120
+ act = row["t"]
121
+ return {"n": len(messages), "thresholds": thresholds, "act": act}
122
+
123
+
124
+ def save(settings: Settings, result: dict) -> Path:
125
+ """Record the measurement for this model@host; a result with no safe threshold removes it."""
126
+ data = _load(settings)
127
+ key = _entry_key(settings)
128
+ act = result.get("act")
129
+ if act is None:
130
+ data.pop(key, None)
131
+ else:
132
+ row = next((r for r in result.get("thresholds", []) if r["t"] == act), {})
133
+ data[key] = {"act": act, "n": result.get("n", 0), "wrong": row.get("wrong", 0),
134
+ "at": datetime.now(UTC).isoformat(timespec="seconds")}
135
+ path = _path(settings)
136
+ path.parent.mkdir(parents=True, exist_ok=True)
137
+ path.write_text(json.dumps(data, indent=2, sort_keys=True))
138
+ return path
damien/cli/__init__.py ADDED
@@ -0,0 +1,131 @@
1
+ """The CLI. One binary, complete operations, nothing running in the background.
2
+
3
+ damien login sign in to Gmail once
4
+ damien doctor check the install; names the fix
5
+ damien pass weekly|monthly [--dry-run] one scheduled pass: build, approve, apply the certain
6
+ damien scan --query 'in:inbox older_than:1y'
7
+ damien plan build --query ... [--limit N] decide, write nothing
8
+ damien plan show <id> [--sample 20] read the plan before it happens
9
+ damien plan approve <id> mint the token (printed once)
10
+ damien plan apply <id> --token T --yes do it, in bulk, journaled
11
+ damien run list | run undo <run-id> --yes
12
+ damien rules init | rules list
13
+ damien judge eval calibrate the model's act threshold
14
+ damien skill install --target openclaw install the agent skill
15
+ damien correct <message-id> --action keep --note "that was a client"
16
+
17
+ Everything prints plain text unless `--json` is given, and every command is safe
18
+ to run twice. `plan build` never writes to Gmail; only `plan apply` does.
19
+ """
20
+
21
+
22
+ from __future__ import annotations
23
+
24
+ import hashlib
25
+ import json
26
+ from pathlib import Path
27
+
28
+ import typer
29
+
30
+ from ..config import Settings, load_settings
31
+ from ..gmail import Gmail
32
+ from ..jev import FakeJudge, make_judge
33
+ from ..plan import group_by_index
34
+ from ..rules import default_rules, load_rules, save_rules, sender_key
35
+
36
+ app = typer.Typer(add_completion=False, help="Damien — Gmail triage: rules first, a System One model for judgment, code for every write.")
37
+ plan_app = typer.Typer(help="Build, review and apply a plan. Only `apply` writes.")
38
+ run_app = typer.Typer(help="Runs that were applied, and how to take them back.")
39
+ rules_app = typer.Typer(help="The deterministic rules.")
40
+ judge_app = typer.Typer(help="The decision model: how far its confidence can be trusted.")
41
+ skill_app = typer.Typer(help="The agent skill that teaches a host agent to run Damien.")
42
+ app.add_typer(plan_app, name="plan")
43
+ app.add_typer(run_app, name="run")
44
+ app.add_typer(rules_app, name="rules")
45
+ app.add_typer(judge_app, name="judge")
46
+ app.add_typer(skill_app, name="skill")
47
+
48
+ SEEDED = "damien-seed"
49
+ SKILL_NAME = "damien-mail-triage"
50
+
51
+
52
+ def _load() -> Settings:
53
+ return load_settings()
54
+
55
+
56
+ def _rules(settings: Settings, path: Path | None, *, seed: bool = True) -> list:
57
+ """The rules in force. `seed=False` is for read-only callers (doctor): no file is written."""
58
+ target = path or settings.root / "rules.json"
59
+ rules = load_rules(target)
60
+ if not rules and not target.exists():
61
+ rules = default_rules()
62
+ if seed:
63
+ save_rules(target, rules)
64
+ return rules
65
+
66
+
67
+ def _gmail(settings: Settings) -> Gmail:
68
+ return Gmail(settings)
69
+
70
+
71
+ def _judge(settings: Settings, mode: str, spec, cache=None):
72
+ if mode == "none":
73
+ return None
74
+ if mode == "fake":
75
+ return FakeJudge()
76
+ return make_judge(settings, spec, cache=cache)
77
+
78
+
79
+ def _fail(message: str, json_out: bool, code: int = 1):
80
+ """One way to refuse: plain red text, or `{"ok": false, "error": ...}` for an agent."""
81
+ if json_out:
82
+ typer.echo(json.dumps({"ok": False, "error": message}))
83
+ else:
84
+ typer.secho(message, fg="red" if code == 1 else "yellow")
85
+ raise typer.Exit(code)
86
+
87
+
88
+ def _fingerprint(ids: list[str]) -> str:
89
+ """Six hex of the group's sorted ids: a digest number that names the wrong group is refused."""
90
+ return hashlib.sha256("\n".join(sorted(ids)).encode()).hexdigest()[:6]
91
+
92
+
93
+ def _parse_group(raw: str) -> tuple[int, str | None]:
94
+ number, _, fp = raw.partition(":")
95
+ try:
96
+ return int(number), (fp.strip().lower() or None)
97
+ except ValueError:
98
+ raise typer.BadParameter(f"--group takes N or N:fp from `plan digest`, not {raw!r}") from None
99
+
100
+
101
+ def _pick_group(plan, store, raw: str, similarity: float):
102
+ """The group a person read in the digest — refused when its fingerprint no longer matches."""
103
+ index, fp = _parse_group(raw)
104
+ found = group_by_index(plan, store, index, similarity=similarity)
105
+ if fp is not None and _fingerprint(found.message_ids) != fp:
106
+ raise ValueError(f"group {index} is no longer {index}:{fp} (it is {index}:{_fingerprint(found.message_ids)}) — "
107
+ f"run `damien plan digest {plan.id}` again and pick from what it shows now")
108
+ return found
109
+
110
+
111
+ def _corrected_against(store, messages, action: str, ids: set[str] | None = None) -> list[tuple[str, dict]]:
112
+ """Person corrections that a rule for these senders would contradict: (subject, correction)."""
113
+ keys = {sender_key(m.from_addr) for m in messages if m.from_addr}
114
+ latest = store.latest_corrections()
115
+ if not latest:
116
+ return []
117
+ by_id = {m.id: m for m in store.get_messages(list(latest))}
118
+ out = []
119
+ for mid, c in latest.items():
120
+ m = by_id.get(mid)
121
+ hit = (ids is not None and mid in ids) or (m is not None and sender_key(m.from_addr) in keys)
122
+ if hit and c["action"] != action:
123
+ out.append(((m.subject if m else "") or "", c))
124
+ return out
125
+
126
+
127
+ def main() -> None:
128
+ app()
129
+
130
+
131
+ from . import core, plans, rules_cmds, runs # noqa: E402,F401
damien/cli/core.py ADDED
@@ -0,0 +1,234 @@
1
+ """Top-level commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import typer
8
+
9
+ from damien import cli as c
10
+ from damien.cli import app
11
+
12
+ from .. import __version__, passes, version_note
13
+ from ..config import write_env
14
+ from ..doctor import checks as doctor_checks
15
+ from ..gmail import GmailError
16
+ from ..models import Action
17
+ from ..plan import (
18
+ load_messages,
19
+ )
20
+ from ..profile import write_profile
21
+ from ..rules import (
22
+ default_rules,
23
+ save_rules,
24
+ )
25
+ from ..store import open_store
26
+
27
+
28
+ @app.command()
29
+ def version() -> None:
30
+ """Print the version. A stale editable install is called out rather than hidden."""
31
+ typer.echo(f"damien {__version__}")
32
+ note = version_note()
33
+ if note:
34
+ typer.echo(note)
35
+
36
+ @app.command()
37
+ def login() -> None:
38
+ """Sign in to Gmail (installed-app OAuth; writes a token to ~/.damien)."""
39
+ settings = c._load()
40
+ gmail = c._gmail(settings)
41
+ try:
42
+ who = gmail.login()
43
+ except GmailError as exc:
44
+ typer.secho(f"login failed: {exc}", fg="red")
45
+ raise typer.Exit(1) from exc
46
+ typer.secho(f"signed in as {who or 'unknown'}", fg="green")
47
+ typer.echo(f"token stored at {settings.token_path}")
48
+
49
+ @app.command()
50
+ def status(json_out: bool = typer.Option(False, "--json", help="Machine-readable output.")) -> None:
51
+ """What is configured, what has been decided, what has been done."""
52
+ settings = c._load()
53
+ with open_store(settings) as store:
54
+ info = {
55
+ "config": settings.redacted(),
56
+ "store": store.summary(),
57
+ "rules_file": str(settings.root / "rules.json"),
58
+ }
59
+ if json_out:
60
+ typer.echo(json.dumps(info, indent=2, default=str))
61
+ return
62
+ typer.secho("damien", bold=True)
63
+ for key, value in info["config"].items():
64
+ typer.echo(f" {key:<18} {value}")
65
+ typer.echo(" store")
66
+ for key, value in info["store"].items():
67
+ typer.echo(f" {key:<14} {value}")
68
+
69
+ @app.command()
70
+ def init(force: bool = typer.Option(False, "--force", help="Overwrite what is already there.")) -> None:
71
+ """Set up this install: profile, rules and a config file. Safe to re-run.
72
+
73
+ Nothing here is required to run Damien on the defaults — but `profile.json` is where
74
+ you tell it whose inbox this is, which is the difference between a tool that files your
75
+ mail and one that files mail.
76
+ """
77
+ settings = c._load()
78
+ root = settings.root
79
+ root.mkdir(parents=True, exist_ok=True)
80
+
81
+ path, written = write_profile(root, force=force)
82
+ typer.secho(f"{'wrote' if written else 'kept '} {path}", fg="green" if written else "yellow")
83
+
84
+ rules_path = root / "rules.json"
85
+ if not rules_path.exists() or force:
86
+ save_rules(rules_path, default_rules())
87
+ typer.secho(f"wrote {rules_path}", fg="green")
88
+ else:
89
+ typer.secho(f"kept {rules_path}", fg="yellow")
90
+
91
+ env_path, env_written = write_env(root, force=force)
92
+ typer.secho(f"{'wrote' if env_written else 'kept '} {env_path}", fg="green" if env_written else "yellow")
93
+
94
+ typer.echo()
95
+ typer.secho("next:", bold=True)
96
+ typer.echo(f" 1. edit {path} — describe whose inbox this is and what belongs in it")
97
+ typer.echo(f" 2. edit {env_path} — your account, and the judge you want (rules | openrouter | typesafe | local)")
98
+ typer.echo(" 3. damien login # Gmail, read/write in one browser tab")
99
+ typer.echo(" 4. damien plan build -q 'in:inbox' --limit 200 --show 20")
100
+ typer.echo(" 5. read it, then: damien plan approve <plan_id>, and damien plan apply <plan_id> --token <t> --yes")
101
+ typer.echo(" 6. calibrate the model once: damien judge eval — then schedule damien pass weekly")
102
+
103
+ @app.command()
104
+ def doctor(json_out: bool = typer.Option(False, "--json", help="Machine-readable output.")) -> None:
105
+ """Check this install and name the fix for anything missing.
106
+
107
+ Changes nothing: it reads your config, profile, rules, credential and store, makes at
108
+ most one live Gmail call, and prints a fix beside each finding. Exits non-zero only when
109
+ something actually blocks you.
110
+ """
111
+ settings = c._load()
112
+ rows = doctor_checks(settings, rules=c._rules(settings, None, seed=False))
113
+ if json_out:
114
+ typer.echo(json.dumps({"checks": [r.__dict__ for r in rows],
115
+ "ok": not any(r.blocking for r in rows)}, indent=2))
116
+ else:
117
+ colour = {"ok": "green", "warn": "yellow", "fail": "red"}
118
+ mark = {"ok": "ok ", "warn": "warn", "fail": "FAIL"}
119
+ typer.secho("damien doctor", bold=True)
120
+ for r in rows:
121
+ typer.secho(f" {mark[r.status]} {r.name:<17} {r.detail}", fg=colour[r.status])
122
+ blocking = sum(1 for r in rows if r.blocking)
123
+ soft = sum(1 for r in rows if r.status == "warn")
124
+ typer.echo()
125
+ typer.echo(f" {len(rows)} checks: {len(rows) - blocking - soft} ok · "
126
+ f"{soft} to look at · {blocking} blocking")
127
+ if blocking:
128
+ typer.secho(" the fix for each line above is in docs/SETUP.md", dim=True)
129
+ else:
130
+ typer.secho(" ready: damien plan build -q 'in:inbox' --limit 200 --show 20", dim=True)
131
+ if any(r.blocking for r in rows):
132
+ raise typer.Exit(1)
133
+
134
+ @app.command()
135
+ def scan(
136
+ query: str = typer.Option("in:inbox", "--query", "-q", help="Gmail search query."),
137
+ limit: int | None = typer.Option(None, "--limit", "-n", help="Stop after this many messages."),
138
+ json_out: bool = typer.Option(False, "--json"),
139
+ ) -> None:
140
+ """List and cache headers for a query. Reads only; decides nothing."""
141
+ settings = c._load()
142
+ gmail = c._gmail(settings)
143
+ with open_store(settings) as store:
144
+ try:
145
+ ids = gmail.list_ids(query, limit=limit)
146
+ messages, _fetched = load_messages(store=store, gmail=gmail, ids=ids,
147
+ ttl_hours=settings.header_ttl_hours)
148
+ except GmailError as exc:
149
+ typer.secho(str(exc), fg="red")
150
+ raise typer.Exit(1) from exc
151
+ store.upsert_messages(messages)
152
+ cached = store.count_messages()
153
+ if json_out:
154
+ typer.echo(json.dumps({"query": query, "matched": len(ids), "cached": cached}, indent=2))
155
+ return
156
+ typer.secho(f"{len(ids)} message(s) matched {query!r}; {cached} cached in the store", fg="green")
157
+
158
+ @app.command("pass")
159
+ def pass_(
160
+ period: str = typer.Argument(..., help="weekly (the last 7 days) | monthly (the calendar month just gone)"),
161
+ dry_run: bool = typer.Option(False, "--dry-run", help="Build and report; write nothing to Gmail."),
162
+ limit: int | None = typer.Option(None, "--limit", "-n", help="Cap how much this pass looks at."),
163
+ json_out: bool = typer.Option(False, "--json"),
164
+ ) -> None:
165
+ """One scheduled pass: rules first, the model on what they left, applied only where certain.
166
+
167
+ It approves its own plan in-process, so a scheduler needs no human step. What it is
168
+ not sure of stays in the plan and is reported, never applied.
169
+ """
170
+ if period not in ("weekly", "monthly"):
171
+ c._fail(f"usage: damien pass weekly|monthly — not {period!r}", json_out, 2)
172
+ settings = c._load()
173
+ report = passes.run_pass(settings=settings, period=period, dry_run=dry_run, limit=limit,
174
+ gmail=c._gmail(settings))
175
+ if json_out:
176
+ typer.echo(json.dumps(report, indent=2, default=str))
177
+ else:
178
+ for line in passes.render(report):
179
+ typer.echo(line)
180
+ if not report["ok"]:
181
+ raise typer.Exit(1)
182
+
183
+ @app.command()
184
+ def restore(
185
+ message_id: list[str] = typer.Argument(..., help="One or more message ids."),
186
+ note: str | None = typer.Option(None, "--note"),
187
+ json_out: bool = typer.Option(False, "--json"),
188
+ ) -> None:
189
+ """Put a message back in the inbox, and record that you wanted it there.
190
+
191
+ The inverse of trashing one message — because a whole run is a blunt thing to undo
192
+ when the model got exactly one of three hundred wrong. Also records the correction,
193
+ so the next plan agrees with you.
194
+ """
195
+ settings = c._load()
196
+ gmail = c._gmail(settings)
197
+ try:
198
+ n = gmail.untrash(message_id)
199
+ except GmailError as exc:
200
+ c._fail(str(exc), json_out)
201
+ with open_store(settings) as store:
202
+ for mid in message_id:
203
+ store.correct(mid, Action.keep, note=note)
204
+ refreshed = gmail.fetch(message_id)
205
+ store.upsert_messages(refreshed)
206
+ if json_out:
207
+ typer.echo(json.dumps({"ok": True, "restored": n, "message_ids": message_id}))
208
+ return
209
+ typer.secho(f"restored {n} message(s) to the inbox", fg="green")
210
+ typer.secho("recorded as a correction, so the next plan keeps it", dim=True)
211
+
212
+ @app.command()
213
+ def correct(
214
+ message_id: str,
215
+ action: str = typer.Option(..., "--action", help="What it should have been: keep|archive|trash|label|mark_read"),
216
+ label: str | None = typer.Option(None, "--label", help="Required with --action label."),
217
+ note: str | None = typer.Option(None, "--note"),
218
+ json_out: bool = typer.Option(False, "--json"),
219
+ ) -> None:
220
+ """Record what a person decided about one message. This is the training signal."""
221
+ try:
222
+ parsed = Action(action)
223
+ except ValueError:
224
+ c._fail(f"unknown action: {action}", json_out, 2)
225
+ if parsed is Action.label and not (label or "").strip():
226
+ c._fail("--action label needs --label NAME: a label correction without a label cannot be applied", json_out, 2)
227
+ settings = c._load()
228
+ with open_store(settings) as store:
229
+ store.correct(message_id, parsed, label=label, note=note)
230
+ if json_out:
231
+ typer.echo(json.dumps({"ok": True, "message_id": message_id, "action": parsed.value, "label": label}))
232
+ return
233
+ typer.secho(f"recorded correction for {message_id}: {parsed.value}", fg="green")
234
+ typer.secho("it outranks a rule or a model on every later plan", dim=True)