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 +42 -0
- damien/calibrate.py +138 -0
- damien/cli/__init__.py +131 -0
- damien/cli/core.py +234 -0
- damien/cli/plans.py +271 -0
- damien/cli/rules_cmds.py +242 -0
- damien/cli/runs.py +118 -0
- damien/config.py +219 -0
- damien/decide.py +273 -0
- damien/doctor.py +139 -0
- damien/embed.py +140 -0
- damien/gmail.py +439 -0
- damien/jev.py +305 -0
- damien/mime.py +141 -0
- damien/models.py +159 -0
- damien/passes.py +173 -0
- damien/plan.py +582 -0
- damien/profile.py +77 -0
- damien/rules.py +413 -0
- damien/skill/SKILL.md +55 -0
- damien/stakes.py +141 -0
- damien/store.py +798 -0
- damien-0.4.1.dist-info/METADATA +342 -0
- damien-0.4.1.dist-info/RECORD +27 -0
- damien-0.4.1.dist-info/WHEEL +4 -0
- damien-0.4.1.dist-info/entry_points.txt +2 -0
- damien-0.4.1.dist-info/licenses/LICENSE +21 -0
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)
|