caucus 0.2.0__tar.gz → 0.2.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.
- {caucus-0.2.0 → caucus-0.2.1}/PKG-INFO +1 -1
- {caucus-0.2.0 → caucus-0.2.1}/config.example.yaml +11 -2
- {caucus-0.2.0 → caucus-0.2.1}/examples/trading-robinhood/config.yaml +4 -2
- {caucus-0.2.0 → caucus-0.2.1}/pyproject.toml +1 -1
- {caucus-0.2.0 → caucus-0.2.1}/src/caucus/__init__.py +1 -1
- {caucus-0.2.0 → caucus-0.2.1}/src/caucus/cli.py +11 -11
- {caucus-0.2.0 → caucus-0.2.1}/src/caucus/config.py +35 -2
- caucus-0.2.1/src/caucus/notify.py +66 -0
- {caucus-0.2.0 → caucus-0.2.1}/tests/test_briefing.py +3 -1
- caucus-0.2.1/tests/test_notify.py +111 -0
- {caucus-0.2.0 → caucus-0.2.1}/uv.lock +1 -1
- {caucus-0.2.0 → caucus-0.2.1}/.github/codex/prompts/review.md +0 -0
- {caucus-0.2.0 → caucus-0.2.1}/.github/codex/review-config.json +0 -0
- {caucus-0.2.0 → caucus-0.2.1}/.github/codex/review-rules.md +0 -0
- {caucus-0.2.0 → caucus-0.2.1}/.github/codex/scripts/build-review-prompt.mjs +0 -0
- {caucus-0.2.0 → caucus-0.2.1}/.github/codex/scripts/normalize-review-output.mjs +0 -0
- {caucus-0.2.0 → caucus-0.2.1}/.github/codex/scripts/post-review.cjs +0 -0
- {caucus-0.2.0 → caucus-0.2.1}/.github/pull_request_template.md +0 -0
- {caucus-0.2.0 → caucus-0.2.1}/.github/workflows/ci.yml +0 -0
- {caucus-0.2.0 → caucus-0.2.1}/.github/workflows/codex-review.yml +0 -0
- {caucus-0.2.0 → caucus-0.2.1}/.github/workflows/keep-codex-auth-fresh.yml +0 -0
- {caucus-0.2.0 → caucus-0.2.1}/.github/workflows/release.yml +0 -0
- {caucus-0.2.0 → caucus-0.2.1}/.gitignore +0 -0
- {caucus-0.2.0 → caucus-0.2.1}/AGENT_SETUP.md +0 -0
- {caucus-0.2.0 → caucus-0.2.1}/DISCLAIMER.md +0 -0
- {caucus-0.2.0 → caucus-0.2.1}/LICENSE +0 -0
- {caucus-0.2.0 → caucus-0.2.1}/README.md +0 -0
- {caucus-0.2.0 → caucus-0.2.1}/SPEC.md +0 -0
- {caucus-0.2.0 → caucus-0.2.1}/examples/trading-robinhood/README.md +0 -0
- {caucus-0.2.0 → caucus-0.2.1}/examples/trading-robinhood/evidence.sample.json +0 -0
- {caucus-0.2.0 → caucus-0.2.1}/examples/trading-robinhood/import_campaigns.py +0 -0
- {caucus-0.2.0 → caucus-0.2.1}/examples/trading-robinhood/mcp.example.json +0 -0
- {caucus-0.2.0 → caucus-0.2.1}/src/caucus/backends.py +0 -0
- {caucus-0.2.0 → caucus-0.2.1}/src/caucus/briefing.py +0 -0
- {caucus-0.2.0 → caucus-0.2.1}/src/caucus/engine.py +0 -0
- {caucus-0.2.0 → caucus-0.2.1}/src/caucus/evidence.py +0 -0
- {caucus-0.2.0 → caucus-0.2.1}/src/caucus/intents.py +0 -0
- {caucus-0.2.0 → caucus-0.2.1}/src/caucus/record.py +0 -0
- {caucus-0.2.0 → caucus-0.2.1}/tests/test_cli.py +0 -0
- {caucus-0.2.0 → caucus-0.2.1}/tests/test_config.py +0 -0
- {caucus-0.2.0 → caucus-0.2.1}/tests/test_engine.py +0 -0
- {caucus-0.2.0 → caucus-0.2.1}/tests/test_evidence.py +0 -0
- {caucus-0.2.0 → caucus-0.2.1}/tests/test_intents.py +0 -0
- {caucus-0.2.0 → caucus-0.2.1}/tests/test_record.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: caucus
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Your AI agents, deliberating on the record. MCP-grounded multi-agent consensus with recorded dissent and an auditable decision log.
|
|
5
5
|
Project-URL: Repository, https://github.com/srinath-jukanti/caucus
|
|
6
6
|
Author: Srinath Jukanti
|
|
@@ -46,5 +46,14 @@ panel:
|
|
|
46
46
|
# - "Which open intent, if any, is due for action today?"
|
|
47
47
|
# - "Any risks that warrant deviating from the standing plans?"
|
|
48
48
|
|
|
49
|
-
# Delivery
|
|
50
|
-
#
|
|
49
|
+
# Delivery after the briefing renders. Email credentials come from the NAMED
|
|
50
|
+
# environment variables — never put secrets in this file.
|
|
51
|
+
# notify:
|
|
52
|
+
# type: email # Gmail-friendly SMTP defaults
|
|
53
|
+
# to: you@example.com
|
|
54
|
+
# address_env: GMAIL_ADDRESS # env var holding the sending address
|
|
55
|
+
# password_env: GMAIL_APP_PASSWORD
|
|
56
|
+
# ...or run anything executable with the briefing path as its argument:
|
|
57
|
+
# notify:
|
|
58
|
+
# type: command
|
|
59
|
+
# command: bash send_briefing.sh
|
|
@@ -35,5 +35,7 @@ agenda:
|
|
|
35
35
|
- "Given the open intents, live positions, and current prices: which intent, if any, is due for its next action today, and what exactly should be done?"
|
|
36
36
|
- "Does current market regime or any position's behavior warrant a defensive action (hedge, trim, or pause of an intent) beyond the standing plans?"
|
|
37
37
|
|
|
38
|
-
# Delivery
|
|
39
|
-
#
|
|
38
|
+
# Delivery — Gmail via an app password; the env vars hold the credentials.
|
|
39
|
+
# notify:
|
|
40
|
+
# type: email
|
|
41
|
+
# to: you@example.com
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "caucus"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.1"
|
|
4
4
|
description = "Your AI agents, deliberating on the record. MCP-grounded multi-agent consensus with recorded dissent and an auditable decision log."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -207,16 +207,14 @@ def briefing(
|
|
|
207
207
|
) -> None:
|
|
208
208
|
"""Deliberate every agenda subject from the config and render one briefing.
|
|
209
209
|
|
|
210
|
-
Writes OUT (markdown) and its .json sibling, then
|
|
211
|
-
|
|
212
|
-
|
|
210
|
+
Writes OUT (markdown) and its .json sibling, then delivers it via the
|
|
211
|
+
configured notifier — 'email' (SMTP, Gmail-friendly, credentials from
|
|
212
|
+
env vars) or 'command' (any executable, briefing path as argument).
|
|
213
213
|
"""
|
|
214
|
-
import shlex
|
|
215
|
-
import subprocess
|
|
216
|
-
|
|
217
214
|
from caucus.briefing import run_agenda
|
|
218
215
|
from caucus.config import Config, ConfigError
|
|
219
216
|
from caucus.engine import Deliberation
|
|
217
|
+
from caucus.notify import NotifyError
|
|
220
218
|
|
|
221
219
|
config_path = (
|
|
222
220
|
config if config else Path("config.yaml") if Path("config.yaml").exists() else None
|
|
@@ -247,11 +245,13 @@ def briefing(
|
|
|
247
245
|
for record in result.records:
|
|
248
246
|
typer.echo(f"- {record.subject[:70]} → {record.decision[:90]} ({record.confidence:.0%})")
|
|
249
247
|
|
|
250
|
-
if loaded.
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
248
|
+
if loaded.notify is not None:
|
|
249
|
+
subject = f"[Caucus] briefing {result.generated_at[:10]} — {len(result.records)} decisions"
|
|
250
|
+
try:
|
|
251
|
+
loaded.notify.send(subject, result.to_markdown(), attachment_path=out)
|
|
252
|
+
except NotifyError as err:
|
|
253
|
+
typer.echo(str(err), err=True)
|
|
254
|
+
raise typer.Exit(1) from err
|
|
255
255
|
typer.echo("notified")
|
|
256
256
|
|
|
257
257
|
|
|
@@ -14,6 +14,7 @@ import yaml
|
|
|
14
14
|
from caucus.backends import Backend, ClaudeCodeBackend, OpenAICompatibleBackend
|
|
15
15
|
from caucus.engine import DEFAULT_PANEL, Analyst
|
|
16
16
|
from caucus.evidence import EvidenceSource
|
|
17
|
+
from caucus.notify import CommandNotifier, EmailNotifier
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
class ConfigError(ValueError):
|
|
@@ -28,7 +29,7 @@ class Config:
|
|
|
28
29
|
intents: Path | None = None
|
|
29
30
|
evidence_sources: list[EvidenceSource] = field(default_factory=list)
|
|
30
31
|
agenda: list[str] = field(default_factory=list)
|
|
31
|
-
|
|
32
|
+
notify: EmailNotifier | CommandNotifier | None = None
|
|
32
33
|
|
|
33
34
|
@classmethod
|
|
34
35
|
def load(cls, path: Path) -> Config:
|
|
@@ -64,10 +65,14 @@ class Config:
|
|
|
64
65
|
):
|
|
65
66
|
raise ConfigError("'agenda' must be a non-empty list of subject strings")
|
|
66
67
|
config.agenda = agenda
|
|
68
|
+
if "notify" in raw and "notify_command" in raw:
|
|
69
|
+
raise ConfigError("use either 'notify' or 'notify_command', not both")
|
|
70
|
+
if "notify" in raw:
|
|
71
|
+
config.notify = _build_notifier(raw["notify"])
|
|
67
72
|
if "notify_command" in raw:
|
|
68
73
|
if not isinstance(raw["notify_command"], str) or not raw["notify_command"].strip():
|
|
69
74
|
raise ConfigError("'notify_command' must be a non-empty string")
|
|
70
|
-
config.
|
|
75
|
+
config.notify = CommandNotifier(command=raw["notify_command"])
|
|
71
76
|
return config
|
|
72
77
|
|
|
73
78
|
|
|
@@ -100,6 +105,34 @@ def _build_backend(raw: object) -> Backend:
|
|
|
100
105
|
raise ConfigError(f"unknown backend type {kind!r} (expected 'claude' or 'openai')")
|
|
101
106
|
|
|
102
107
|
|
|
108
|
+
def _build_notifier(raw: object) -> EmailNotifier | CommandNotifier:
|
|
109
|
+
if not isinstance(raw, dict):
|
|
110
|
+
raise ConfigError("'notify' must be a mapping with a 'type'")
|
|
111
|
+
kind = raw.get("type")
|
|
112
|
+
if kind == "email":
|
|
113
|
+
to = raw.get("to")
|
|
114
|
+
if not isinstance(to, str) or not to.strip():
|
|
115
|
+
raise ConfigError("email notify requires a string 'to' address")
|
|
116
|
+
options = {}
|
|
117
|
+
for key in ("smtp_host", "address_env", "password_env"):
|
|
118
|
+
if key in raw:
|
|
119
|
+
if not isinstance(raw[key], str) or not raw[key].strip():
|
|
120
|
+
raise ConfigError(f"'{key}' must be a non-empty string")
|
|
121
|
+
options[key] = raw[key]
|
|
122
|
+
if "smtp_port" in raw:
|
|
123
|
+
port = raw["smtp_port"]
|
|
124
|
+
if isinstance(port, bool) or not isinstance(port, int) or not 0 < port < 65536:
|
|
125
|
+
raise ConfigError("'smtp_port' must be a valid port number")
|
|
126
|
+
options["smtp_port"] = port
|
|
127
|
+
return EmailNotifier(to=to, **options)
|
|
128
|
+
if kind == "command":
|
|
129
|
+
command = raw.get("command")
|
|
130
|
+
if not isinstance(command, str) or not command.strip():
|
|
131
|
+
raise ConfigError("command notify requires a string 'command'")
|
|
132
|
+
return CommandNotifier(command=command)
|
|
133
|
+
raise ConfigError(f"unknown notify type {kind!r} (expected 'email' or 'command')")
|
|
134
|
+
|
|
135
|
+
|
|
103
136
|
def _build_evidence_sources(raw: object) -> list[EvidenceSource]:
|
|
104
137
|
if not isinstance(raw, list):
|
|
105
138
|
raise ConfigError("'evidence_sources' must be a list")
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Notification: how a finished briefing reaches a human.
|
|
2
|
+
|
|
3
|
+
Config-driven, two kinds:
|
|
4
|
+
- email: SMTP (Gmail-friendly defaults) using stdlib smtplib. Credentials are
|
|
5
|
+
read from environment variables named in the config — never from the config
|
|
6
|
+
itself.
|
|
7
|
+
- command: run any executable with the briefing path as its argument.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import shlex
|
|
14
|
+
import smtplib
|
|
15
|
+
import subprocess
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from email.mime.text import MIMEText
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NotifyError(RuntimeError):
|
|
22
|
+
"""Raised when a notification cannot be delivered."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class EmailNotifier:
|
|
27
|
+
"""SMTP delivery; defaults suit a Gmail account with an app password."""
|
|
28
|
+
|
|
29
|
+
to: str
|
|
30
|
+
smtp_host: str = "smtp.gmail.com"
|
|
31
|
+
smtp_port: int = 465
|
|
32
|
+
address_env: str = "GMAIL_ADDRESS"
|
|
33
|
+
password_env: str = "GMAIL_APP_PASSWORD"
|
|
34
|
+
smtp_factory: object | None = None # injectable for tests
|
|
35
|
+
|
|
36
|
+
def send(self, subject: str, body: str, attachment_path: Path | None = None) -> None:
|
|
37
|
+
address = os.environ.get(self.address_env)
|
|
38
|
+
password = os.environ.get(self.password_env)
|
|
39
|
+
if not address or not password:
|
|
40
|
+
raise NotifyError(
|
|
41
|
+
f"email notifier needs ${self.address_env} and ${self.password_env} set"
|
|
42
|
+
)
|
|
43
|
+
message = MIMEText(body, "plain", "utf-8")
|
|
44
|
+
message["Subject"] = subject
|
|
45
|
+
message["From"] = address
|
|
46
|
+
message["To"] = self.to
|
|
47
|
+
factory = self.smtp_factory or smtplib.SMTP_SSL
|
|
48
|
+
try:
|
|
49
|
+
with factory(self.smtp_host, self.smtp_port) as server:
|
|
50
|
+
server.login(address, password)
|
|
51
|
+
server.sendmail(address, [self.to], message.as_string())
|
|
52
|
+
except OSError as err:
|
|
53
|
+
raise NotifyError(f"email delivery failed: {err}") from err
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class CommandNotifier:
|
|
58
|
+
"""Runs a user-configured command with the briefing path appended."""
|
|
59
|
+
|
|
60
|
+
command: str
|
|
61
|
+
|
|
62
|
+
def send(self, subject: str, body: str, attachment_path: Path | None = None) -> None:
|
|
63
|
+
target = shlex.quote(str(attachment_path)) if attachment_path else ""
|
|
64
|
+
completed = subprocess.run(f"{self.command} {target}".strip(), shell=True)
|
|
65
|
+
if completed.returncode != 0:
|
|
66
|
+
raise NotifyError(f"notify command exited {completed.returncode}")
|
|
@@ -62,7 +62,9 @@ notify_command: bash send_briefing.sh
|
|
|
62
62
|
)
|
|
63
63
|
config = Config.load(path)
|
|
64
64
|
assert config.agenda == ["Question one?", "Question two?"]
|
|
65
|
-
|
|
65
|
+
from caucus.notify import CommandNotifier
|
|
66
|
+
|
|
67
|
+
assert config.notify == CommandNotifier(command="bash send_briefing.sh")
|
|
66
68
|
|
|
67
69
|
|
|
68
70
|
@pytest.mark.parametrize(
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from types import SimpleNamespace
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from caucus.config import Config, ConfigError
|
|
6
|
+
from caucus.notify import CommandNotifier, EmailNotifier, NotifyError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FakeSMTP:
|
|
10
|
+
"""Records logins and sends; usable as a context manager factory."""
|
|
11
|
+
|
|
12
|
+
instances = []
|
|
13
|
+
|
|
14
|
+
def __init__(self, host, port):
|
|
15
|
+
self.host, self.port = host, port
|
|
16
|
+
self.logins, self.sent = [], []
|
|
17
|
+
FakeSMTP.instances.append(self)
|
|
18
|
+
|
|
19
|
+
def __enter__(self):
|
|
20
|
+
return self
|
|
21
|
+
|
|
22
|
+
def __exit__(self, *args):
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
def login(self, address, password):
|
|
26
|
+
self.logins.append((address, password))
|
|
27
|
+
|
|
28
|
+
def sendmail(self, sender, recipients, message):
|
|
29
|
+
self.sent.append(SimpleNamespace(sender=sender, recipients=recipients, message=message))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.fixture(autouse=True)
|
|
33
|
+
def reset_fake():
|
|
34
|
+
FakeSMTP.instances = []
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_email_notifier_sends_via_smtp(monkeypatch):
|
|
38
|
+
monkeypatch.setenv("GMAIL_ADDRESS", "me@example.com")
|
|
39
|
+
monkeypatch.setenv("GMAIL_APP_PASSWORD", "app-pass")
|
|
40
|
+
notifier = EmailNotifier(to="you@example.com", smtp_factory=FakeSMTP)
|
|
41
|
+
notifier.send("subject line", "body text")
|
|
42
|
+
server = FakeSMTP.instances[0]
|
|
43
|
+
assert server.host == "smtp.gmail.com"
|
|
44
|
+
assert server.logins == [("me@example.com", "app-pass")]
|
|
45
|
+
sent = server.sent[0]
|
|
46
|
+
assert sent.recipients == ["you@example.com"]
|
|
47
|
+
assert "subject line" in sent.message
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_email_notifier_requires_env(monkeypatch):
|
|
51
|
+
monkeypatch.delenv("GMAIL_ADDRESS", raising=False)
|
|
52
|
+
monkeypatch.delenv("GMAIL_APP_PASSWORD", raising=False)
|
|
53
|
+
notifier = EmailNotifier(to="you@example.com", smtp_factory=FakeSMTP)
|
|
54
|
+
with pytest.raises(NotifyError, match="GMAIL_ADDRESS"):
|
|
55
|
+
notifier.send("s", "b")
|
|
56
|
+
assert FakeSMTP.instances == []
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_command_notifier_runs_and_fails_loudly(tmp_path):
|
|
60
|
+
marker = tmp_path / "ran.txt"
|
|
61
|
+
CommandNotifier(command=f"touch {marker}").send("s", "b")
|
|
62
|
+
assert marker.exists()
|
|
63
|
+
with pytest.raises(NotifyError, match="exited 3"):
|
|
64
|
+
CommandNotifier(command="exit 3").send("s", "b")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_config_parses_email_notifier(tmp_path):
|
|
68
|
+
path = tmp_path / "config.yaml"
|
|
69
|
+
path.write_text(
|
|
70
|
+
"""
|
|
71
|
+
notify:
|
|
72
|
+
type: email
|
|
73
|
+
to: you@example.com
|
|
74
|
+
address_env: MY_FROM
|
|
75
|
+
password_env: MY_PASS
|
|
76
|
+
smtp_port: 587
|
|
77
|
+
"""
|
|
78
|
+
)
|
|
79
|
+
notify = Config.load(path).notify
|
|
80
|
+
assert isinstance(notify, EmailNotifier)
|
|
81
|
+
assert notify.to == "you@example.com"
|
|
82
|
+
assert notify.address_env == "MY_FROM"
|
|
83
|
+
assert notify.smtp_port == 587
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_config_legacy_notify_command_still_works(tmp_path):
|
|
87
|
+
path = tmp_path / "config.yaml"
|
|
88
|
+
path.write_text("notify_command: bash send.sh\n")
|
|
89
|
+
notify = Config.load(path).notify
|
|
90
|
+
assert isinstance(notify, CommandNotifier)
|
|
91
|
+
assert notify.command == "bash send.sh"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@pytest.mark.parametrize(
|
|
95
|
+
"text",
|
|
96
|
+
[
|
|
97
|
+
"notify: email\n",
|
|
98
|
+
"notify:\n type: email\n",
|
|
99
|
+
"notify:\n type: email\n to: ''\n",
|
|
100
|
+
"notify:\n type: email\n to: a@b.c\n smtp_port: -5\n",
|
|
101
|
+
"notify:\n type: email\n to: a@b.c\n smtp_port: true\n",
|
|
102
|
+
"notify:\n type: command\n",
|
|
103
|
+
"notify:\n type: slack\n",
|
|
104
|
+
"notify:\n type: command\n command: x\nnotify_command: y\n",
|
|
105
|
+
],
|
|
106
|
+
)
|
|
107
|
+
def test_config_rejects_invalid_notify(tmp_path, text):
|
|
108
|
+
path = tmp_path / "config.yaml"
|
|
109
|
+
path.write_text(text)
|
|
110
|
+
with pytest.raises(ConfigError):
|
|
111
|
+
Config.load(path)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|