diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7ad94d2..91255be 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [2.38.0] - 2026-06-14
+
+Oracle activity reporting — the Oracle can now produce a cross-project "what happened
+today" digest, exposed as a discovery tool (MCP + REST + OpenAPI), a dedicated endpoint,
+and a web-UI tab.
+
+### Added
+
+- **`ActivityReporter`** (`oracle/services/activity_reporter.py`) — aggregates per-project
+ sessions, tool calls, edits, git mutations, and token/cost for a day (or `since`/`until`
+ window) across all registered projects, or one via `project_path`. Reads `.c3` JSONL
+ artifacts directly (no C3Runtime build); skips non-C3 projects without side effects.
+- **`activity_report` discovery tool** (read tier) in `TOOL_SPECS` — auto-exposed on MCP,
+ OpenAPI, `POST /api/discovery/call`, and the internal Oracle chat. Optional `narrate=true`
+ adds a best-effort LLM prose summary (never fails the structured result).
+- **`GET /api/activity/digest`** Oracle endpoint (`date` / `since` / `until` / `project` /
+ `narrate` query params) and an **Activity** tab in `oracle.html` with a date picker,
+ totals cards, optional narrative, and a per-project breakdown table.
+
+### Changed
+
+- `ChatEngine` accepts an optional `activity_reporter` and dispatches `activity_report`.
+
## [2.37.0] - 2026-06-14
Non-destructive config generation — regenerating instruction docs and applying permission
diff --git a/README.md b/README.md
index 078f497..77ddf13 100644
--- a/README.md
+++ b/README.md
@@ -272,6 +272,10 @@ Only **read** and **safe-action** tools are exposed (no code editing); requests
default. Generate, rotate, and copy the token from the dashboard's **Settings →
Discovery API** tab. See the [Oracle Discovery API guide](oracle-guide/discovery-api.md).
+As of v2.38.0 the Oracle also reports a **cross-project activity digest** — sessions,
+tool calls, edits, git mutations, and token/cost for a day — via the `activity_report`
+discovery tool, the `GET /api/activity/digest` endpoint, and the dashboard's **Activity** tab.
+
---
## Tiered local AI (optional)
diff --git a/cli/c3.py b/cli/c3.py
index 4988e8a..7cc7f8b 100644
--- a/cli/c3.py
+++ b/cli/c3.py
@@ -85,7 +85,7 @@
# Config
CONFIG_DIR = ".c3"
CONFIG_FILE = ".c3/config.json"
-__version__ = "2.37.0"
+__version__ = "2.38.0"
def _command_deps() -> CommandDeps:
diff --git a/cli/guide/oracle.html b/cli/guide/oracle.html
index 1892b1e..fb8b20b 100644
--- a/cli/guide/oracle.html
+++ b/cli/guide/oracle.html
@@ -269,6 +269,7 @@
No insights yet. Click "Generate" with 2+ projects.
+
+
@@ -1279,6 +1301,9 @@
+
${value}
+ ${label}
+ `;
+}
+
+function renderActivity(data) {
+ const summary = document.getElementById('actSummary');
+ const projects = document.getElementById('actProjects');
+ const empty = document.getElementById('actEmpty');
+ const narrEl = document.getElementById('actNarrative');
+ if (!data || data.error) {
+ summary.innerHTML = ''; projects.innerHTML = ''; narrEl.style.display = 'none';
+ empty.style.display = ''; empty.textContent = (data && data.error) || 'Failed to load activity.';
+ return;
+ }
+ const t = data.totals || {};
+ summary.innerHTML = [
+ _actCard('Projects', t.projects_active || 0),
+ _actCard('Sessions', t.sessions || 0),
+ _actCard('Tool Calls', t.tool_calls || 0),
+ _actCard('Edits', t.edits || 0),
+ _actCard('Git', t.git_mutations || 0),
+ _actCard('Cost $', (t.cost_usd || 0).toFixed(2)),
+ ].join('');
+
+ if (data.narrative) {
+ narrEl.style.display = '';
+ narrEl.innerHTML = `
${esc(data.window && data.window.label || '')}
+
${esc(data.narrative)}
`;
+ } else {
+ narrEl.style.display = 'none';
+ }
+
+ const rows = data.projects || [];
+ if (!rows.length) {
+ projects.innerHTML = ''; empty.style.display = '';
+ empty.textContent = 'No activity recorded for ' + (data.window && data.window.label || 'this day') + '.';
+ return;
+ }
+ empty.style.display = 'none';
+ projects.innerHTML = `
+
+
+ | Project | Sessions |
+ Tools | Edits |
+ Git | Tokens |
+ Cost $ | Last activity |
+
+ ${rows.map(p => `
+ | ${esc(p.name)} |
+ ${(p.sessions || []).length} |
+ ${p.tool_calls || 0} |
+ ${p.edits || 0} |
+ ${p.git_mutations || 0} |
+ ${(((p.tokens || {}).input || 0) + ((p.tokens || {}).output || 0)).toLocaleString()} |
+ ${(p.cost_usd || 0).toFixed(2)} |
+ ${esc((p.last_activity || '').replace('T', ' ').slice(0, 19))} |
+
`).join('')}
+
`;
+}
+
// ═══════════════════════════════════════════════════════════
// ── Suggestions ──
// ═══════════════════════════════════════════════════════════
diff --git a/oracle/oracle_server.py b/oracle/oracle_server.py
index 09ee11a..145f43a 100644
--- a/oracle/oracle_server.py
+++ b/oracle/oracle_server.py
@@ -21,6 +21,7 @@
from oracle.config import ORACLE_DIR, load_config, save_config # noqa: E402
from oracle.mcp_oracle import mcp_url, start_mcp_thread # noqa: E402
from oracle.services import api_auth # noqa: E402
+from oracle.services.activity_reporter import ActivityReporter # noqa: E402
from oracle.services.api_auth import extract_bearer # noqa: E402
from oracle.services.api_auth import verify as verify_api_key # noqa: E402
from oracle.services.c3_bridge import C3Bridge # noqa: E402
@@ -57,10 +58,11 @@
_c3_bridge: C3Bridge | None = None
_federated: FederatedGraph | None = None
_tool_registry: ToolRegistry | None = None
+_activity_reporter: ActivityReporter | None = None
def _init_services():
- global _cfg, _bridge, _scanner, _reader, _checker, _writer, _cross_memory, _engine, _agent, _model_verified, _chat_store, _chat_engine, _c3_bridge, _federated, _tool_registry
+ global _cfg, _bridge, _scanner, _reader, _checker, _writer, _cross_memory, _engine, _agent, _model_verified, _chat_store, _chat_engine, _c3_bridge, _federated, _tool_registry, _activity_reporter
_cfg = load_config()
_bridge = OllamaBridge(
base_url=_cfg.get("ollama_base_url", "https://ollama.com"),
@@ -97,6 +99,7 @@ def _verify():
interval=int(_cfg.get("review_interval_seconds", 1800)),
federated_graph=_federated,
)
+ _activity_reporter = ActivityReporter(scanner=_scanner, ollama_bridge=_bridge)
_chat_engine = ChatEngine(
bridge=_bridge,
reader=_reader,
@@ -107,6 +110,7 @@ def _verify():
scanner=_scanner,
store=_chat_store,
c3_bridge=_c3_bridge,
+ activity_reporter=_activity_reporter,
)
_tool_registry = ToolRegistry(
ToolExecutor(_chat_engine),
@@ -608,6 +612,26 @@ def api_chat_conversation_state(conv_id):
return jsonify({"state": _chat_store.get_state(conv_id)})
+# ── Activity digest (Oracle UI) ───────────────────────────
+@app.route("/api/activity/digest", methods=["GET"])
+def api_activity_digest():
+ """Cross-project activity digest for the Oracle UI.
+
+ Query params: date=YYYY-MM-DD, since, until, project (single-project path),
+ narrate=true|false. Defaults to today (UTC) across all registered projects.
+ """
+ if not _activity_reporter:
+ return jsonify({"error": "not initialized"}), 500
+ narrate = str(request.args.get("narrate", "")).lower() in ("1", "true", "yes", "on")
+ return jsonify(_activity_reporter.report(
+ date=request.args.get("date", ""),
+ since=request.args.get("since", ""),
+ until=request.args.get("until", ""),
+ project_path=request.args.get("project", ""),
+ narrate=narrate,
+ ))
+
+
# ── Discovery API (external LLM tool surface) ─────────────
@app.route("/api/discovery/tools", methods=["GET"])
def api_discovery_tools():
diff --git a/oracle/services/activity_reporter.py b/oracle/services/activity_reporter.py
new file mode 100644
index 0000000..8faddf7
--- /dev/null
+++ b/oracle/services/activity_reporter.py
@@ -0,0 +1,256 @@
+"""Cross-project activity reporting for the Oracle.
+
+Aggregates per-project C3 activity (sessions, tool calls, edits, git mutations,
+token/cost) into a single daily digest. Reads the same ``.c3`` JSONL artifacts
+the local UI uses, directly per project — no C3Runtime build required (mirrors
+how ``MemoryReader`` / ``ProjectManager`` read project data straight off disk).
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from datetime import datetime, timezone
+from pathlib import Path
+
+from services.activity_log import ActivityLog
+from services.edit_ledger import EditLedger
+from services.session_manager import SessionManager
+
+log = logging.getLogger("oracle")
+
+# Lexicographic sentinels for an open-ended window (ISO-8601 keeps string order).
+_MIN_TS = "0000-01-01T00:00:00"
+_MAX_TS = "9999-12-31T23:59:59"
+
+_NARRATE_SYSTEM = (
+ "You are C3's activity reporter. Given a JSON digest of a developer's work "
+ "across their projects for a time window, write a concise, friendly summary "
+ "(3-6 sentences). Lead with the headline (busiest project and the totals), "
+ "then call out notable specifics. Use ONLY the numbers provided — never "
+ "invent data. If everything is zero, say it was a quiet period."
+)
+
+
+class ActivityReporter:
+ """Builds cross-project (or single-project) activity digests.
+
+ Construct with an Oracle ``ProjectScanner`` and, optionally, an
+ ``OllamaBridge`` for prose narration (``narrate=True``).
+ """
+
+ def __init__(self, scanner, ollama_bridge=None):
+ self.scanner = scanner
+ self.ollama_bridge = ollama_bridge
+
+ # ── Public API ───────────────────────────────────────────────
+
+ def report(self, date: str = "", since: str = "", until: str = "",
+ project_path: str = "", narrate: bool = False) -> dict:
+ """Aggregate activity into a digest dict.
+
+ date: UTC day ``YYYY-MM-DD`` (default today). since/until: ISO bounds
+ that override ``date``. project_path: limit to one project (else all
+ registered projects with a ``.c3`` dir). narrate: add an LLM prose
+ summary (best-effort; never fails the structured result).
+ """
+ lo, hi, window = self._resolve_window(date, since, until)
+
+ proj_reports = []
+ for proj in self._target_projects(project_path):
+ pr = self._report_project(proj, lo, hi)
+ if (pr["tool_calls"] or pr["edits"] or pr["git_mutations"]
+ or pr["sessions"] or pr["decisions"]):
+ proj_reports.append(pr)
+
+ digest = {
+ "window": window,
+ "totals": self._aggregate_totals(proj_reports),
+ "projects": proj_reports,
+ "narrative": None,
+ }
+ if narrate:
+ digest["narrative"] = self._narrate(digest)
+ return digest
+
+ # ── Window resolution ────────────────────────────────────────
+
+ @staticmethod
+ def _resolve_window(date: str, since: str, until: str):
+ """Return (lo, hi, window_dict).
+
+ Bounds are naive (no tz suffix) on purpose: the date/time prefix
+ dominates lexicographically, so the same bounds correctly window both
+ naive ledger timestamps and tz-aware ``+00:00`` activity timestamps.
+ """
+ if since or until:
+ lo = since or _MIN_TS
+ hi = until or _MAX_TS
+ window = {
+ "since": since or None,
+ "until": until or None,
+ "label": f"{since or 'beginning'} → {until or 'now'}",
+ "tz": "as-given",
+ }
+ return lo, hi, window
+
+ day = date.strip() if date else datetime.now(timezone.utc).strftime("%Y-%m-%d")
+ lo = f"{day}T00:00:00"
+ hi = f"{day}T23:59:59.999999"
+ today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
+ window = {
+ "since": lo,
+ "until": hi,
+ "label": f"{day}{' (today)' if day == today else ''}, UTC",
+ "tz": "UTC",
+ }
+ return lo, hi, window
+
+ # ── Project selection ────────────────────────────────────────
+
+ def _target_projects(self, project_path: str) -> list[dict]:
+ if project_path:
+ p = Path(project_path)
+ return [{"path": str(p), "name": p.name, "has_c3": (p / ".c3").is_dir()}]
+ return [p for p in self.scanner.discover() if p.get("has_c3")]
+
+ # ── Per-project aggregation ──────────────────────────────────
+
+ def _report_project(self, proj: dict, lo: str, hi: str) -> dict:
+ path = proj.get("path", "")
+ base = {
+ "name": proj.get("name") or Path(path).name,
+ "path": path,
+ "sessions": [],
+ "tool_calls": 0,
+ "edits": 0,
+ "git_mutations": 0,
+ "decisions": 0,
+ "events": {},
+ "tokens": {"input": 0, "output": 0},
+ "cost_usd": 0.0,
+ "first_activity": None,
+ "last_activity": None,
+ }
+ # Guard: only read projects that already have a .c3 dir — never create
+ # one as a side effect (ActivityLog/SessionManager mkdir on init).
+ if not path or not (Path(path) / ".c3").is_dir():
+ return base
+
+ timestamps: list[str] = []
+
+ # Activity log → per-type event counts.
+ try:
+ counts: dict[str, int] = {}
+ for e in ActivityLog(path).get_recent(limit=10000, since=lo, until=hi):
+ etype = e.get("type", "unknown")
+ counts[etype] = counts.get(etype, 0) + 1
+ if e.get("timestamp"):
+ timestamps.append(e["timestamp"])
+ base["events"] = counts
+ base["tool_calls"] = counts.get("tool_call", 0)
+ base["decisions"] = counts.get("decision", 0)
+ except Exception as exc: # pragma: no cover - defensive
+ log.debug("activity_log read failed for %s: %s", path, exc)
+
+ try:
+ sm = SessionManager(path)
+ # Sessions started within the window.
+ for s in sm.list_sessions(100):
+ started = s.get("started", "")
+ if started and lo <= started <= hi:
+ base["sessions"].append({
+ "id": s.get("id"),
+ "started": started,
+ "ended": s.get("ended", ""),
+ "description": s.get("description", ""),
+ "tool_calls": s.get("tool_calls", 0),
+ "duration": s.get("duration", ""),
+ })
+ timestamps.append(started)
+ # Token / cost from hook-captured session stats.
+ for st in sm.get_session_stats(500):
+ ts = st.get("ts", "")
+ if ts and lo <= ts <= hi:
+ base["tokens"]["input"] += int(st.get("input_tokens", 0) or 0)
+ base["tokens"]["output"] += int(st.get("output_tokens", 0) or 0)
+ base["cost_usd"] += float(st.get("cost_usd", 0) or 0)
+ except Exception as exc: # pragma: no cover - defensive
+ log.debug("session read failed for %s: %s", path, exc)
+
+ # Edit ledger → edits vs git mutations (get_history filters >= lo only).
+ try:
+ for en in EditLedger(path).get_history(since=lo, limit=10000):
+ ts = en.get("timestamp", "")
+ if ts and ts > hi:
+ continue
+ if en.get("change_type") == "shell_git":
+ base["git_mutations"] += 1
+ else:
+ base["edits"] += 1
+ if ts:
+ timestamps.append(ts)
+ except Exception as exc: # pragma: no cover - defensive
+ log.debug("edit ledger read failed for %s: %s", path, exc)
+
+ base["cost_usd"] = round(base["cost_usd"], 4)
+ if timestamps:
+ timestamps.sort()
+ base["first_activity"] = timestamps[0]
+ base["last_activity"] = timestamps[-1]
+ return base
+
+ @staticmethod
+ def _aggregate_totals(reports: list[dict]) -> dict:
+ totals = {
+ "projects_active": len(reports),
+ "sessions": 0,
+ "tool_calls": 0,
+ "edits": 0,
+ "git_mutations": 0,
+ "decisions": 0,
+ "input_tokens": 0,
+ "output_tokens": 0,
+ "cost_usd": 0.0,
+ }
+ for r in reports:
+ totals["sessions"] += len(r["sessions"])
+ totals["tool_calls"] += r["tool_calls"]
+ totals["edits"] += r["edits"]
+ totals["git_mutations"] += r["git_mutations"]
+ totals["decisions"] += r["decisions"]
+ totals["input_tokens"] += r["tokens"]["input"]
+ totals["output_tokens"] += r["tokens"]["output"]
+ totals["cost_usd"] += r["cost_usd"]
+ totals["cost_usd"] = round(totals["cost_usd"], 4)
+ return totals
+
+ # ── Narration (best-effort) ──────────────────────────────────
+
+ def _narrate(self, digest: dict) -> str | None:
+ if self.ollama_bridge is None:
+ digest["narrative_error"] = "No Ollama bridge configured."
+ return None
+ payload = {
+ "window": digest["window"]["label"],
+ "totals": digest["totals"],
+ "projects": [
+ {
+ "name": p["name"],
+ "tool_calls": p["tool_calls"],
+ "edits": p["edits"],
+ "git_mutations": p["git_mutations"],
+ "sessions": len(p["sessions"]),
+ "cost_usd": p["cost_usd"],
+ }
+ for p in digest["projects"]
+ ],
+ }
+ prompt = "Summarize this developer activity digest:\n\n" + json.dumps(payload, indent=2)
+ try:
+ text = self.ollama_bridge.generate(prompt, system=_NARRATE_SYSTEM)
+ return (text or "").strip() or None
+ except Exception as exc:
+ log.warning("activity narration failed: %s", exc)
+ digest["narrative_error"] = str(exc)
+ return None
diff --git a/oracle/services/chat_engine.py b/oracle/services/chat_engine.py
index eae1121..6cd379c 100644
--- a/oracle/services/chat_engine.py
+++ b/oracle/services/chat_engine.py
@@ -100,6 +100,11 @@
Delegate a specific sub-task to a specialized agent.
- agent_id: The ID of the active agent to use.
- task: A detailed prompt explaining what the agent needs to do.
+
+19. activity_report(date?, since?, until?, project_path?, narrate=false)
+ Cross-project daily activity digest: sessions, tool calls, edits, git
+ mutations, and token/cost across ALL projects (or one, via project_path).
+ Defaults to today (UTC). narrate=true adds a prose summary.
"""
_SYSTEM_BASE = """You are Oracle, an AI assistant specializing in cross-project code intelligence and memory analysis.
@@ -259,6 +264,7 @@ def __init__(
scanner: ProjectScanner,
store: ChatStore,
c3_bridge=None,
+ activity_reporter=None,
):
self.bridge = bridge
self.reader = reader
@@ -269,6 +275,7 @@ def __init__(
self.scanner = scanner
self.store = store
self.c3_bridge = c3_bridge
+ self.activity_reporter = activity_reporter
# ── Main chat generator ───────────────────────────────
@@ -854,6 +861,8 @@ def _execute_tool(self, name: str, args: dict) -> dict:
return self._tool_suggest_action(**args)
case "read_graph":
return self._tool_read_graph(**args)
+ case "activity_report":
+ return self._tool_activity_report(**args)
case "delegate_task":
return self._tool_delegate_task(**args)
# ── C3 code intelligence tools ──
@@ -1002,6 +1011,15 @@ def _tool_suggest_action(
def _tool_read_graph(self, project_path: str) -> dict:
return self.reader.get_graph_stats(project_path)
+ def _tool_activity_report(self, date: str = "", since: str = "", until: str = "",
+ project_path: str = "", narrate: bool = False) -> dict:
+ if self.activity_reporter is None:
+ return {"error": "Activity reporter not configured."}
+ return self.activity_reporter.report(
+ date=date, since=since, until=until,
+ project_path=project_path, narrate=narrate,
+ )
+
def _tool_delegate_task(self, agent_id: str, task: str) -> dict:
"""Execute a sub-agent loop for the delegated task.
diff --git a/oracle/services/tool_registry.py b/oracle/services/tool_registry.py
index 5619756..1079399 100644
--- a/oracle/services/tool_registry.py
+++ b/oracle/services/tool_registry.py
@@ -111,6 +111,28 @@ def _obj(properties: dict, required: list[str] | None = None) -> dict:
"description": "Get memory-graph statistics for a project (node/edge/type counts).",
"parameters": _obj({"project_path": _PROJECT_PATH}, ["project_path"]),
},
+ {
+ "name": "activity_report",
+ "tier": TIER_READ,
+ "description": "Cross-project daily activity digest: sessions, tool calls, edits, git "
+ "mutations, and token/cost across ALL projects (or one, via project_path). "
+ "Defaults to today (UTC); pass date (YYYY-MM-DD) or since/until. Set "
+ "narrate=true to add an LLM prose summary.",
+ "parameters": _obj(
+ {
+ "date": {"type": "string", "default": "",
+ "description": "UTC day YYYY-MM-DD (default today)."},
+ "since": {"type": "string", "default": "",
+ "description": "ISO start timestamp (overrides date)."},
+ "until": {"type": "string", "default": "",
+ "description": "ISO end timestamp (overrides date)."},
+ "project_path": {"type": "string", "default": "",
+ "description": "Optional: limit the digest to one project."},
+ "narrate": {"type": "boolean", "default": False,
+ "description": "Add an LLM-narrated prose summary."},
+ }
+ ),
+ },
# ── read tier: C3 code intelligence ──
{
"name": "c3_search",
diff --git a/pyproject.toml b/pyproject.toml
index e93d084..256358c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "code-context-control"
-version = "2.37.0"
+version = "2.38.0"
description = "Local code-intelligence layer for AI coding tools (Claude Code, Codex, Gemini, Copilot). Retrieve less, read less, edit safer."
readme = "README.md"
requires-python = ">=3.10"
diff --git a/tests/test_activity_reporter.py b/tests/test_activity_reporter.py
new file mode 100644
index 0000000..fcfd5e3
--- /dev/null
+++ b/tests/test_activity_reporter.py
@@ -0,0 +1,130 @@
+"""Tests for oracle/services/activity_reporter.py — cross-project digest.
+
+Builds temp projects with synthetic .c3 JSONL artifacts and a stub scanner;
+no Ollama/keyring needed.
+"""
+from __future__ import annotations
+
+import json
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(REPO_ROOT))
+
+from oracle.services.activity_reporter import ActivityReporter
+
+
+class _StubScanner:
+ def __init__(self, projects):
+ self._projects = projects
+
+ def discover(self):
+ return self._projects
+
+
+def _write_jsonl(path: Path, rows: list[dict]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with open(path, "w", encoding="utf-8") as f:
+ for r in rows:
+ f.write(json.dumps(r) + "\n")
+
+
+class TestActivityReporter(unittest.TestCase):
+ DAY = "2026-06-14"
+ PREV = "2026-06-13"
+
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ self.proj = Path(self.tmp.name) / "projA"
+ c3 = self.proj / ".c3"
+ c3.mkdir(parents=True)
+
+ _write_jsonl(c3 / "activity_log.jsonl", [
+ {"timestamp": f"{self.DAY}T09:00:00+00:00", "type": "tool_call"},
+ {"timestamp": f"{self.DAY}T10:00:00+00:00", "type": "tool_call"},
+ {"timestamp": f"{self.DAY}T11:00:00+00:00", "type": "tool_call"},
+ {"timestamp": f"{self.DAY}T11:30:00+00:00", "type": "decision"},
+ {"timestamp": f"{self.PREV}T23:00:00+00:00", "type": "tool_call"},
+ ])
+ _write_jsonl(c3 / "session_stats.jsonl", [
+ {"ts": f"{self.DAY}T09:30:00+00:00", "session_id": "s1",
+ "input_tokens": 100, "output_tokens": 50, "cost_usd": 0.12},
+ {"ts": f"{self.PREV}T09:30:00+00:00", "session_id": "s0",
+ "input_tokens": 999, "output_tokens": 999, "cost_usd": 9.99},
+ ])
+ sess_dir = c3 / "sessions"
+ sess_dir.mkdir(parents=True)
+ with open(sess_dir / "session_001.json", "w", encoding="utf-8") as f:
+ json.dump({"id": "s1", "started": f"{self.DAY}T09:00:00+00:00",
+ "ended": f"{self.DAY}T12:00:00+00:00", "description": "work",
+ "tool_calls": [1, 2, 3]}, f)
+ _write_jsonl(c3 / "edit_ledger.jsonl", [
+ {"id": "e1", "timestamp": f"{self.DAY}T09:10:00+00:00", "change_type": "modified", "file": "a.py"},
+ {"id": "e2", "timestamp": f"{self.DAY}T09:20:00+00:00", "change_type": "modified", "file": "b.py"},
+ {"id": "e3", "timestamp": f"{self.DAY}T09:25:00+00:00", "change_type": "shell_git", "file": "a.py"},
+ {"id": "e0", "timestamp": f"{self.PREV}T09:00:00+00:00", "change_type": "modified", "file": "a.py"},
+ ])
+ self.scanner = _StubScanner([
+ {"path": str(self.proj), "name": "projA", "has_c3": True},
+ ])
+ self.reporter = ActivityReporter(scanner=self.scanner)
+
+ def test_digest_windows_to_the_day(self):
+ d = self.reporter.report(date=self.DAY)
+ t = d["totals"]
+ self.assertEqual(t["projects_active"], 1)
+ self.assertEqual(t["tool_calls"], 3)
+ self.assertEqual(t["decisions"], 1)
+ self.assertEqual(t["edits"], 2) # modified only, not shell_git
+ self.assertEqual(t["git_mutations"], 1)
+ self.assertEqual(t["sessions"], 1)
+ self.assertEqual(t["input_tokens"], 100)
+ self.assertEqual(t["output_tokens"], 50)
+ self.assertAlmostEqual(t["cost_usd"], 0.12, places=4)
+
+ def test_prev_day_does_not_leak(self):
+ t = self.reporter.report(date=self.PREV)["totals"]
+ self.assertEqual(t["tool_calls"], 1)
+ self.assertEqual(t["edits"], 1)
+ self.assertEqual(t["git_mutations"], 0)
+ self.assertAlmostEqual(t["cost_usd"], 9.99, places=4)
+
+ def test_empty_day_has_no_projects(self):
+ d = self.reporter.report(date="2025-01-01")
+ self.assertEqual(d["totals"]["projects_active"], 0)
+ self.assertEqual(d["projects"], [])
+
+ def test_single_project_filter(self):
+ d = self.reporter.report(date=self.DAY, project_path=str(self.proj))
+ self.assertEqual(d["totals"]["projects_active"], 1)
+ self.assertEqual(d["projects"][0]["name"], "projA")
+
+ def test_non_c3_project_skipped_without_side_effect(self):
+ ghost = Path(self.tmp.name) / "ghost"
+ ghost.mkdir()
+ scanner = _StubScanner([
+ {"path": str(self.proj), "name": "projA", "has_c3": True},
+ {"path": str(ghost), "name": "ghost", "has_c3": False},
+ ])
+ d = ActivityReporter(scanner=scanner).report(date=self.DAY)
+ self.assertFalse((ghost / ".c3").exists()) # no mkdir side effect
+ self.assertNotIn("ghost", {p["name"] for p in d["projects"]})
+
+ def test_narrate_without_bridge_records_error(self):
+ d = self.reporter.report(date=self.DAY, narrate=True)
+ self.assertIsNone(d["narrative"])
+ self.assertIn("narrative_error", d)
+
+ def test_window_label_marks_today(self):
+ from datetime import datetime, timezone
+ today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
+ d = self.reporter.report(date=today)
+ self.assertIn("today", d["window"]["label"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_oracle_discovery_api.py b/tests/test_oracle_discovery_api.py
index 165c8a1..c1c43ba 100644
--- a/tests/test_oracle_discovery_api.py
+++ b/tests/test_oracle_discovery_api.py
@@ -65,6 +65,17 @@ def test_no_edit_tool_exposed(self):
self.assertNotIn("c3_edit", names)
self.assertNotIn("c3_shell", names)
+ def test_activity_report_listed(self):
+ r = self.client.get("/api/discovery/tools", headers=self.auth)
+ names = {t["name"] for t in r.get_json()["tools"]}
+ self.assertIn("activity_report", names)
+
+ def test_activity_report_dispatches(self):
+ r = self.client.post("/api/discovery/call",
+ json={"tool": "activity_report", "args": {}}, headers=self.auth)
+ self.assertEqual(r.status_code, 200)
+ self.assertEqual(r.get_json()["dispatched"], "activity_report")
+
def test_call_dispatches(self):
r = self.client.post("/api/discovery/call",
json={"tool": "list_projects", "args": {}}, headers=self.auth)
diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py
index a78020b..d91735d 100644
--- a/tests/test_tool_registry.py
+++ b/tests/test_tool_registry.py
@@ -72,6 +72,15 @@ def test_call_tool_unknown(self):
reg = ToolRegistry(self.exec, max_tier=TIER_ACTION)
self.assertIn("error", reg.call_tool("does_not_exist", {}))
+ def test_activity_report_is_read_tier_no_required_args(self):
+ read_only = ToolRegistry(self.exec, max_tier=TIER_READ)
+ self.assertIn("activity_report", read_only.tool_names())
+ # No required args → callable with an empty args object.
+ out = read_only.call_tool("activity_report", {})
+ self.assertTrue(out["ok"])
+ name, _args = self.exec.calls[-1]
+ self.assertEqual(name, "activity_report")
+
def test_openapi_has_path_per_tool(self):
reg = ToolRegistry(self.exec, max_tier=TIER_ACTION)
spec = reg.openapi_spec("http://127.0.0.1:3331/")