Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/test-mcp-regression.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ jobs:
env:
SURREAL_URL: 'memory://'
REPO_PATH: ${{ github.workspace }}
BICAMERAL_SKIP_CONSENT_NOTICE: '1'
steps:
- uses: actions/checkout@v4

Expand Down
66 changes: 66 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,72 @@
All notable changes to bicameral-mcp are tracked here. Format loosely follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## v0.14.0 — Local-only telemetry counters + usage summary + first-boot consent — built via [QorLogic SDLC](https://github.com/MythologIQ-Labs-LLC/qor-logic)

Privacy-first observability foundation. Adds a local-only counter sink
that runs alongside (not replacing) the existing network relay, a new
`bicameral.usage_summary` MCP tool that aggregates ledger and counter
state into actionable percentages, and a non-blocking first-boot notice
so users upgrading to this binary see the telemetry policy before any
data flows.

### Added

- **`local_counters.py`** (#39) — append-only JSONL sink at
`~/.bicameral/counters.jsonl`. Records only `{tool_name, delta=1, ts}`
per call. Mode `0o600` on POSIX; thread-safe; no network egress.
Always-on regardless of network telemetry consent — counters are
local introspection, distinct from the relay. Kill-switch:
`BICAMERAL_LOCAL_COUNTERS=0`. API: `increment(tool_name)` and
`read_counters() -> dict[str, int]`.
- **`consent.py`** (#39) — owns `~/.bicameral/consent.json`,
`telemetry_allowed()` predicate, and `notify_if_first_run()`. Marker
shape: `{telemetry, policy_version, acknowledged_at, acknowledged_via}`
with `acknowledged_via` distinguishing `"wizard"` (explicit choice)
from `"first_boot_notice"` (passive ack). `POLICY_VERSION` constant
re-fires the notice for everyone once when telemetry policy changes.
- **`bicameral.usage_summary`** MCP tool (#42) — aggregate readout over
the last N days (default 7). Returns ingest/bind call counts (from
the local counters file), decision counts by status (from ledger),
reflected/drift percentages, cosmetic-drift percentage (from
compliance_check verdicts), and error rate. Privacy-preserving:
aggregate counts and floats only.
- **First-boot consent notice** — non-blocking, fires once per
`policy_version` via stderr (always) and MCP `notifications/message`
(when an active session is available). Server keeps running; if
marker write fails, notice is logged at debug and the server
continues. Test escape hatch: `BICAMERAL_SKIP_CONSENT_NOTICE=1`.

### Changed

- **`telemetry.send_event` now uses `consent.telemetry_allowed()`** as
the single gating predicate. Behavior preserved for users without a
marker (default-on); newly opted-out users (marker says `disabled`
via the wizard) suppress the relay even when env var is unset.
- **`telemetry.send_event` always increments the local counter** before
the relay path — never raises, wrapped in try/except. Counter
failure cannot affect the caller; relay path runs independently.
- **`setup_wizard._select_telemetry`** now calls
`consent.write_consent(via="wizard")` after the user's choice. Hard
fails (raises `OSError`) if the marker cannot be written — guarantees
a "no" answer never silently leaves telemetry on.
- **`server.serve_stdio`** calls `consent.notify_if_first_run()` once
during startup. Wrapped in try/except — startup is never blocked by
notice machinery.

### CI

- `BICAMERAL_SKIP_CONSENT_NOTICE: "1"` added to the test job env in
`.github/workflows/test-mcp-regression.yml` so test runs do not emit
notices into job logs.
- `tests/conftest.py` adds a session-scoped autouse fixture that
reroutes `~/.bicameral/` to a per-session tmp dir and sets the skip
env var. Stdlib only — no third-party fixture plugin.

### Closes

#39, #42.

## v0.13.0 — CodeGenome Phase 4 (#61) — semantic drift evaluation in `resolve_compliance` (M3) — built via [QorLogic SDLC](https://github.com/MythologIQ-Labs-LLC/qor-logic)

Final PR in the three-phase CodeGenome rollout (issues #59 / #60 /
Expand Down
138 changes: 138 additions & 0 deletions consent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""User consent for outbound telemetry (issue #39).

Three responsibilities, kept independent of ``telemetry.py``:

1. **Consent marker** — persisted at ``~/.bicameral/consent.json`` with
``{telemetry: "enabled"|"disabled", policy_version, acknowledged_at,
acknowledged_via}``. File mode 0o600 on POSIX.

2. **First-boot notice** — non-blocking. On the first boot of an
upgraded binary that hasn't acknowledged the current policy version,
emits the notice via MCP ``notifications/message`` (when an active
session is available) and stderr (always). Server keeps running.

3. **``telemetry_allowed()``** — single source of truth for the
network relay. Returns True when env var ``BICAMERAL_TELEMETRY != "0"``
AND (marker missing OR marker.telemetry == "enabled"). Missing
marker preserves current default-on behavior so users don't lose
telemetry between upgrade and first-boot acknowledgment.

Test escape hatch: ``BICAMERAL_SKIP_CONSENT_NOTICE=1`` short-circuits
``notify_if_first_run`` (used by tests/conftest.py and CI).
"""

from __future__ import annotations

import json
import logging
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable

logger = logging.getLogger(__name__)

POLICY_VERSION = 1
"""Bump when telemetry policy changes (new fields, new endpoints).
Re-fires the first-boot notice once for everyone on the next boot."""

_CONSENT_FILE = Path.home() / ".bicameral" / "consent.json"
_OFF_VALUES = frozenset({"0", "false", "no", "off"})


_NOTICE_TEXT = (
"Bicameral collects anonymous usage statistics (skill name, duration, "
"version, error flag — no code, no decision text, no file paths). "
"To opt out: run `bicameral-mcp setup`, or set BICAMERAL_TELEMETRY=0 "
"in your `.mcp.json` env block. This notice will not appear again "
"unless the telemetry policy changes."
)


def read_consent() -> dict | None:
"""Return the marker contents, or None if missing/malformed."""
if not _CONSENT_FILE.exists():
return None
try:
return json.loads(_CONSENT_FILE.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as exc:
logger.debug("[consent] read failed: %s", exc)
return None


def write_consent(telemetry: bool, *, via: str) -> None:
"""Atomic write of the consent marker. Mode 0o600 on POSIX.

Raises OSError on disk failure — wizard treats this as fatal;
notify_if_first_run swallows it.
"""
record: dict[str, Any] = {
"telemetry": "enabled" if telemetry else "disabled",
"policy_version": POLICY_VERSION,
"acknowledged_at": datetime.now(timezone.utc).isoformat(),
"acknowledged_via": via,
}
_CONSENT_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = _CONSENT_FILE.with_suffix(".json.tmp")
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
fd = os.open(str(tmp), flags, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(record, f, separators=(",", ":"))
os.replace(tmp, _CONSENT_FILE)


def telemetry_allowed() -> bool:
"""Single source of truth for whether the relay path may run.

True when:
- env var BICAMERAL_TELEMETRY != "0" (allows runtime opt-out), AND
- marker is missing (default-on for upgraders) OR
marker.telemetry == "enabled"
"""
env_val = os.getenv("BICAMERAL_TELEMETRY", "1").strip().lower()
if env_val in _OFF_VALUES:
return False
marker = read_consent()
if marker is None:
return True # default-on for users who haven't seen the notice yet
return marker.get("telemetry") == "enabled"


def _should_notify() -> bool:
"""True iff the notice has not been emitted for the current policy version."""
if os.getenv("BICAMERAL_SKIP_CONSENT_NOTICE", "").strip() == "1":
return False
marker = read_consent()
if marker is None:
return True
return int(marker.get("policy_version", 0)) < POLICY_VERSION


def notify_if_first_run(send_mcp_notification: Callable[[str, str], Any] | None = None) -> None:
"""Emit the first-boot notice once and stamp the marker. Never raises.

``send_mcp_notification`` is a callable taking (severity, message).
When provided and a session is active, the notice surfaces in the
user's MCP client (Claude Code, etc.). stderr mirror covers headless
contexts and provides a record either way.
"""
try:
if not _should_notify():
return
# Surface to MCP client if available.
if send_mcp_notification is not None:
try:
send_mcp_notification("info", _NOTICE_TEXT)
except Exception as exc:
logger.debug("[consent] MCP notification failed: %s", exc)
# Stderr mirror — always.
print(_NOTICE_TEXT, file=sys.stderr, flush=True)
# Stamp marker so we don't repeat. Default = enabled (matches
# current opt-out posture); user changes via wizard or env var.
try:
write_consent(telemetry=True, via="first_boot_notice")
except OSError as exc:
logger.debug("[consent] marker write failed: %s", exc)
except Exception as exc:
logger.debug("[consent] notify_if_first_run failed: %s", exc)
104 changes: 104 additions & 0 deletions handlers/usage_summary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""Handler for /bicameral_usage_summary MCP tool (issue #42).

Aggregate operational readout — converts raw ledger state into actionable
percentages over a configurable window. Privacy-preserving: returns only
counts and floats. No event rows, no session IDs, no user content.

Pairs with local_counters.py (#39) for tool-call counts; pulls
decision-state metrics directly from the SurrealDB ledger.
"""

from __future__ import annotations

import logging
from datetime import datetime, timedelta, timezone

from local_counters import read_counters

logger = logging.getLogger(__name__)


async def handle_usage_summary(ctx, days: int = 7) -> dict:
"""Aggregate usage stats over the last `days` days.

Returns the schema specified in #42:
period_days, ingest_calls, bind_calls_total, decisions_ingested,
decisions_ungrounded, decisions_pending, decisions_reflected,
decisions_drifted, reflected_pct, drift_pct, cosmetic_drift_pct,
error_rate.
"""
period_days = max(0, int(days))
base = {
"period_days": period_days,
"ingest_calls": 0,
"bind_calls_total": 0,
"decisions_ingested": 0,
"decisions_ungrounded": 0,
"decisions_pending": 0,
"decisions_reflected": 0,
"decisions_drifted": 0,
"reflected_pct": 0.0,
"drift_pct": 0.0,
"cosmetic_drift_pct": 0.0,
"error_rate": 0.0,
}

# ── Tool-call counts (local-only, from #39's counters.jsonl) ──
counters = read_counters()
base["ingest_calls"] = int(counters.get("bicameral-ingest", 0))
base["bind_calls_total"] = int(counters.get("bicameral-bind", 0))

# ── Decision state counts (from ledger) ──
if period_days == 0:
return base

try:
ledger = ctx.ledger
cutoff = (datetime.now(timezone.utc) - timedelta(days=period_days)).isoformat()
client = getattr(getattr(ledger, "_inner", ledger), "_client", None)
if client is None:
return base

rows = await client.query(
"SELECT status, count() AS n FROM decision "
f"WHERE created_at > <datetime>'{cutoff}' GROUP BY status"
)
status_counts: dict[str, int] = {}
for r in rows or []:
s = r.get("status")
n = int(r.get("n", 0))
if isinstance(s, str):
status_counts[s] = n

base["decisions_ungrounded"] = status_counts.get("ungrounded", 0)
base["decisions_pending"] = status_counts.get("pending", 0)
base["decisions_reflected"] = status_counts.get("reflected", 0)
base["decisions_drifted"] = status_counts.get("drifted", 0)
base["decisions_ingested"] = sum(status_counts.values())

grounded = base["decisions_reflected"] + base["decisions_drifted"]
if grounded > 0:
base["reflected_pct"] = round(base["decisions_reflected"] / grounded, 4)
base["drift_pct"] = round(base["decisions_drifted"] / grounded, 4)

# Cosmetic drift: count compliance_check verdicts of cosmetic_autopass
# over total drift verdicts in the window.
try:
cc_rows = await client.query(
"SELECT verdict, count() AS n FROM compliance_check "
f"WHERE checked_at > <datetime>'{cutoff}' "
"AND verdict IN ['drifted', 'cosmetic_autopass'] GROUP BY verdict"
)
cc_counts = {
r.get("verdict"): int(r.get("n", 0)) for r in (cc_rows or [])
}
cosmetic = cc_counts.get("cosmetic_autopass", 0)
drift_total = cosmetic + cc_counts.get("drifted", 0)
if drift_total > 0:
base["cosmetic_drift_pct"] = round(cosmetic / drift_total, 4)
except Exception as exc:
logger.debug("[usage_summary] cosmetic_drift query failed: %s", exc)
except Exception as exc:
logger.debug("[usage_summary] aggregate query failed: %s", exc)

return base
Loading
Loading