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 @@

Read tier — discovery

c3_status Project health / budget / sessions overview c3_memory_query Read-only memory query (recall/list/score/graph/trends) c3_edits / c3_edits_crossQuery the edit ledger (one / all projects) + activity_reportCross-project daily digest: sessions, tool calls, edits, git mutations, token/cost (optional LLM narration) diff --git a/oracle-guide/api-reference.md b/oracle-guide/api-reference.md index 311ec19..3952bbc 100644 --- a/oracle-guide/api-reference.md +++ b/oracle-guide/api-reference.md @@ -229,6 +229,40 @@ Dismiss an insight (marks `dismissed: true`, excluded from future listings). --- +## Activity + +### `GET /api/activity/digest` + +Cross-project activity digest for a day (or custom window). Aggregates sessions, tool +calls, edits, git mutations, and token/cost across all registered projects (or one, via +`project`). + +**Query params:** `date` (UTC `YYYY-MM-DD`, default today); `since` / `until` (ISO bounds, +override `date`); `project` (single-project path); `narrate` (`true` adds an LLM prose +summary — best-effort, omitted on failure). + +```json +{ + "window": { "since": "2026-06-14T00:00:00", "until": "2026-06-14T23:59:59.999999", + "label": "2026-06-14 (today), UTC", "tz": "UTC" }, + "totals": { "projects_active": 2, "sessions": 3, "tool_calls": 184, "edits": 57, + "git_mutations": 12, "decisions": 4, + "input_tokens": 120000, "output_tokens": 38000, "cost_usd": 1.42 }, + "projects": [ + { "name": "claude-companion", "path": "/path/to/proj", "sessions": [ ... ], + "tool_calls": 150, "edits": 57, "git_mutations": 12, + "tokens": { "input": 100000, "output": 30000 }, "cost_usd": 1.10, + "first_activity": "2026-06-14T09:00:00+00:00", "last_activity": "2026-06-14T18:30:00+00:00" } + ], + "narrative": null +} +``` + +> Also available as the discovery tool **`activity_report`** (identical payload) for +> external LLMs over MCP, OpenAPI, and `POST /api/discovery/call`. + +--- + ## Suggestions Suggestions are write-back recommendations that Oracle generates. They modify project `.c3/facts/facts.json` only when explicitly approved by the user. diff --git a/oracle-guide/changelog.md b/oracle-guide/changelog.md index 99101f3..328bb93 100644 --- a/oracle-guide/changelog.md +++ b/oracle-guide/changelog.md @@ -1,5 +1,19 @@ # Oracle Changelog +## v1.2.0 (2026-06-14) + +### Activity Reporting (C3 v2.38.0) +- **`ActivityReporter`** (`oracle/services/activity_reporter.py`): cross-project daily + digest aggregating sessions, tool calls, edits, git mutations, and token/cost. Reads + `.c3` JSONL artifacts directly per project (no C3Runtime build); skips non-C3 projects. +- **`activity_report` discovery tool** (read tier): auto-exposed on MCP, OpenAPI, + `POST /api/discovery/call`, and the internal Oracle chat. `narrate=true` adds a + best-effort LLM prose summary. +- **`GET /api/activity/digest`** endpoint (`date` / `since` / `until` / `project` / + `narrate` query params) + an **Activity** tab in the Oracle dashboard. + +--- + ## v1.1.0 (2026-04-10) ### Ollama Cloud Migration diff --git a/oracle-guide/discovery-api.md b/oracle-guide/discovery-api.md index 16f4c5e..eadc0dc 100644 --- a/oracle-guide/discovery-api.md +++ b/oracle-guide/discovery-api.md @@ -101,7 +101,7 @@ Set in `~/.c3/oracle/config.json`: **Discovery (read):** `list_projects`, `search_facts`, `query_memory`, `project_health`, `analyze_project`, `cross_insights`, `read_graph`, `c3_search`, `c3_search_cross`, `c3_read`, `c3_compress`, `c3_validate`, `c3_status`, -`c3_memory_query`, `c3_edits`, `c3_edits_cross`. +`c3_memory_query`, `c3_edits`, `c3_edits_cross`, `activity_report`. **Safe actions:** `suggest_action` (creates a *pending* memory suggestion a human approves), `delegate_task` (runs a configured Oracle agent). diff --git a/oracle/oracle.html b/oracle/oracle.html index 751596a..40cdf5d 100644 --- a/oracle/oracle.html +++ b/oracle/oracle.html @@ -841,6 +841,7 @@
Chat
Projects
Insights
+
Activity
Cross-Graph
Suggestions
Team / Agents
@@ -975,6 +976,27 @@

Cross-Project Insights

+ +
+
+
+

Activity Digest

+

Cross-project sessions, tool calls, edits, git mutations and token/cost for one day (UTC).

+
+
+ + + +
+
+
+ +
+ +
+
@@ -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 = ` + + + + + + + + ${rows.map(p => ` + + + + + + + + + `).join('')} +
ProjectSessionsToolsEditsGitTokensCost $Last activity
${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))}
`; +} + // ═══════════════════════════════════════════════════════════ // ── 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/")