From b7e9bffb7025b222431ddbbcb10fed20b5045971 Mon Sep 17 00:00:00 2001 From: roei michael Date: Tue, 7 Apr 2026 17:42:56 +0300 Subject: [PATCH] v0.3.0: Rename to context-analyzer-tool and TUI visual overhaul Rename project from context-pulse to context-analyzer-tool across all modules, CLI entry points, hooks, tests, and documentation. TUI dashboard redesign: vibrant bright_* color theme, distinct box styles per panel (HEAVY/ROUNDED/DOUBLE), zebra-striped tables, Unicode icons in panel titles, and a 20-frame sleeping cat animation in a double-bordered nap zone with drifting z's and wake-up moments. Default refresh rate increased from 2s to 1s. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/agents/anomaly-expert.md | 2 +- .claude/agents/code-reviewer.md | 2 +- .claude/agents/config-expert.md | 6 +- .claude/agents/fastapi-expert.md | 2 +- .claude/agents/hooks-expert.md | 2 +- .claude/agents/llm-classifier.md | 2 +- .claude/agents/planner.md | 2 +- .claude/agents/researcher.md | 2 +- .claude/agents/sqlite-expert.md | 2 +- .claude/agents/tester.md | 2 +- .claude/agents/tui-expert.md | 4 +- .claude/agents/web-dashboard.md | 2 +- .github/ISSUE_TEMPLATE/bug_report.md | 2 +- CHANGELOG.md | 19 +- CONTRIBUTING.md | 8 +- README.md | 36 +- docs/demo-cli.svg | 6 +- docs/demo-dashboard.svg | 4 +- docs/phase1-architecture.md | 190 ++--- docs/phase2-architecture.md | 136 ++-- hooks/_hook_config.py | 4 +- hooks/post_tool_use.py | 6 +- hooks/statusline.py | 6 +- hooks/stop.py | 2 +- hooks/subagent_stop.py | 2 +- hooks/user_prompt_submit.py | 2 +- pyproject.toml | 8 +- src/context_analyzer_tool/__init__.py | 3 + .../cli.py | 104 +-- .../collector/__init__.py | 0 .../collector/delta_engine.py | 24 +- .../collector/models.py | 0 .../collector/routes.py | 30 +- .../collector/server.py | 20 +- .../config.py | 42 +- .../dashboard/__init__.py | 0 src/context_analyzer_tool/dashboard/tui.py | 756 ++++++++++++++++++ .../db/__init__.py | 0 .../db/anomalies.py | 2 +- .../db/baselines.py | 2 +- .../db/compaction.py | 0 .../db/events.py | 0 .../db/maintenance.py | 2 +- .../db/messages.py | 0 .../db/schema.py | 2 +- .../db/tasks.py | 0 .../engine/__init__.py | 0 .../engine/anomaly.py | 16 +- .../engine/baseline.py | 4 +- .../engine/burn_rate.py | 0 .../engine/classifier.py | 6 +- .../engine/context_breakdown.py | 2 +- .../notify/__init__.py | 0 .../notify/context_warnings.py | 12 +- .../notify/dispatcher.py | 10 +- .../notify/session_alert.py | 6 +- .../notify/statusline.py | 2 +- .../notify/system.py | 6 +- .../notify/webhook.py | 6 +- .../rtk_integration.py | 4 +- src/context_pulse/__init__.py | 3 - src/context_pulse/dashboard/tui.py | 486 ----------- tests/conftest.py | 14 +- tests/test_anomaly.py | 6 +- tests/test_baseline.py | 4 +- tests/test_burn_rate.py | 2 +- tests/test_classifier.py | 6 +- tests/test_config.py | 40 +- tests/test_context_warnings.py | 6 +- tests/test_dashboard.py | 2 +- tests/test_db.py | 6 +- tests/test_delta_engine.py | 6 +- tests/test_notify.py | 40 +- tests/test_rtk_integration.py | 20 +- uv.lock | 2 +- 75 files changed, 1226 insertions(+), 939 deletions(-) create mode 100644 src/context_analyzer_tool/__init__.py rename src/{context_pulse => context_analyzer_tool}/cli.py (91%) rename src/{context_pulse => context_analyzer_tool}/collector/__init__.py (100%) rename src/{context_pulse => context_analyzer_tool}/collector/delta_engine.py (95%) rename src/{context_pulse => context_analyzer_tool}/collector/models.py (100%) rename src/{context_pulse => context_analyzer_tool}/collector/routes.py (96%) rename src/{context_pulse => context_analyzer_tool}/collector/server.py (77%) rename src/{context_pulse => context_analyzer_tool}/config.py (89%) rename src/{context_pulse => context_analyzer_tool}/dashboard/__init__.py (100%) create mode 100644 src/context_analyzer_tool/dashboard/tui.py rename src/{context_pulse => context_analyzer_tool}/db/__init__.py (100%) rename src/{context_pulse => context_analyzer_tool}/db/anomalies.py (98%) rename src/{context_pulse => context_analyzer_tool}/db/baselines.py (96%) rename src/{context_pulse => context_analyzer_tool}/db/compaction.py (100%) rename src/{context_pulse => context_analyzer_tool}/db/events.py (100%) rename src/{context_pulse => context_analyzer_tool}/db/maintenance.py (97%) rename src/{context_pulse => context_analyzer_tool}/db/messages.py (100%) rename src/{context_pulse => context_analyzer_tool}/db/schema.py (99%) rename src/{context_pulse => context_analyzer_tool}/db/tasks.py (100%) rename src/{context_pulse => context_analyzer_tool}/engine/__init__.py (100%) rename src/{context_pulse => context_analyzer_tool}/engine/anomaly.py (95%) rename src/{context_pulse => context_analyzer_tool}/engine/baseline.py (98%) rename src/{context_pulse => context_analyzer_tool}/engine/burn_rate.py (100%) rename src/{context_pulse => context_analyzer_tool}/engine/classifier.py (98%) rename src/{context_pulse => context_analyzer_tool}/engine/context_breakdown.py (98%) rename src/{context_pulse => context_analyzer_tool}/notify/__init__.py (100%) rename src/{context_pulse => context_analyzer_tool}/notify/context_warnings.py (89%) rename src/{context_pulse => context_analyzer_tool}/notify/dispatcher.py (87%) rename src/{context_pulse => context_analyzer_tool}/notify/session_alert.py (87%) rename src/{context_pulse => context_analyzer_tool}/notify/statusline.py (97%) rename src/{context_pulse => context_analyzer_tool}/notify/system.py (98%) rename src/{context_pulse => context_analyzer_tool}/notify/webhook.py (97%) rename src/{context_pulse => context_analyzer_tool}/rtk_integration.py (98%) delete mode 100644 src/context_pulse/__init__.py delete mode 100644 src/context_pulse/dashboard/tui.py diff --git a/.claude/agents/anomaly-expert.md b/.claude/agents/anomaly-expert.md index c98b82b..951755c 100644 --- a/.claude/agents/anomaly-expert.md +++ b/.claude/agents/anomaly-expert.md @@ -6,7 +6,7 @@ tools: Read, Edit, Write, Glob, Grep, Bash, WebSearch, WebFetch effort: high --- -You are an anomaly detection and statistics specialist working on the **context-pulse** project. +You are an anomaly detection and statistics specialist working on the **context-analyzer-tool** project. ## Your expertise diff --git a/.claude/agents/code-reviewer.md b/.claude/agents/code-reviewer.md index 2dd1847..8528e65 100644 --- a/.claude/agents/code-reviewer.md +++ b/.claude/agents/code-reviewer.md @@ -6,7 +6,7 @@ tools: Read, Glob, Grep, Bash effort: high --- -You are a senior code reviewer working on the **context-pulse** project. +You are a senior code reviewer working on the **context-analyzer-tool** project. ## Your review criteria diff --git a/.claude/agents/config-expert.md b/.claude/agents/config-expert.md index c0f8d0d..1dbb875 100644 --- a/.claude/agents/config-expert.md +++ b/.claude/agents/config-expert.md @@ -6,7 +6,7 @@ tools: Read, Edit, Write, Glob, Grep, Bash, WebSearch, WebFetch effort: high --- -You are a configuration and CLI design specialist working on the **context-pulse** project. +You are a configuration and CLI design specialist working on the **context-analyzer-tool** project. ## Your expertise @@ -20,12 +20,12 @@ You are a configuration and CLI design specialist working on the **context-pulse ## Key constraints for this project -- Config lives at `~/.context-pulse/config.toml` +- Config lives at `~/.context-analyzer-tool/config.toml` - Config loaded ONCE at startup, passed as dependency — never re-read mid-request - Must handle missing config gracefully (generate defaults on first run) - Cross-platform path expansion (`~` on all platforms) - Config sections: collector, anomaly, classifier, notifications, dashboard -- CLI entry point: `context-pulse` with subcommands (status, dashboard, anomalies, etc.) +- CLI entry point: `context-analyzer-tool` with subcommands (status, dashboard, anomalies, etc.) - All config values have sensible defaults - Config object should be a Pydantic model for validation diff --git a/.claude/agents/fastapi-expert.md b/.claude/agents/fastapi-expert.md index 9066b8b..5da4d3a 100644 --- a/.claude/agents/fastapi-expert.md +++ b/.claude/agents/fastapi-expert.md @@ -6,7 +6,7 @@ tools: Read, Edit, Write, Glob, Grep, Bash, WebSearch, WebFetch effort: high --- -You are a FastAPI and async Python specialist working on the **context-pulse** project. +You are a FastAPI and async Python specialist working on the **context-analyzer-tool** project. ## Your expertise diff --git a/.claude/agents/hooks-expert.md b/.claude/agents/hooks-expert.md index 209dfd4..1365c70 100644 --- a/.claude/agents/hooks-expert.md +++ b/.claude/agents/hooks-expert.md @@ -6,7 +6,7 @@ tools: Read, Edit, Write, Glob, Grep, Bash, WebSearch, WebFetch effort: high --- -You are a Claude Code hooks integration specialist working on the **context-pulse** project. +You are a Claude Code hooks integration specialist working on the **context-analyzer-tool** project. ## Your expertise diff --git a/.claude/agents/llm-classifier.md b/.claude/agents/llm-classifier.md index 91764f1..f6f71ea 100644 --- a/.claude/agents/llm-classifier.md +++ b/.claude/agents/llm-classifier.md @@ -6,7 +6,7 @@ tools: Read, Edit, Write, Glob, Grep, Bash, WebSearch, WebFetch effort: high --- -You are an LLM integration and prompt engineering specialist working on the **context-pulse** project. +You are an LLM integration and prompt engineering specialist working on the **context-analyzer-tool** project. ## Your expertise diff --git a/.claude/agents/planner.md b/.claude/agents/planner.md index 8e19466..122a86e 100644 --- a/.claude/agents/planner.md +++ b/.claude/agents/planner.md @@ -6,7 +6,7 @@ tools: Read, Glob, Grep, Bash, Agent, WebSearch, WebFetch effort: max --- -You are a senior software architect and technical planner for the **context-pulse** project — a per-tool-call context window analyzer for Claude Code. +You are a senior software architect and technical planner for the **context-analyzer-tool** project — a per-tool-call context window analyzer for Claude Code. ## Your role diff --git a/.claude/agents/researcher.md b/.claude/agents/researcher.md index 671bfb5..1471131 100644 --- a/.claude/agents/researcher.md +++ b/.claude/agents/researcher.md @@ -6,7 +6,7 @@ tools: Read, Glob, Grep, Bash, WebSearch, WebFetch effort: high --- -You are a research specialist supporting the **context-pulse** project. +You are a research specialist supporting the **context-analyzer-tool** project. ## Your role diff --git a/.claude/agents/sqlite-expert.md b/.claude/agents/sqlite-expert.md index e055249..7b1b06e 100644 --- a/.claude/agents/sqlite-expert.md +++ b/.claude/agents/sqlite-expert.md @@ -6,7 +6,7 @@ tools: Read, Glob, Grep, Bash, WebSearch, WebFetch effort: high --- -You are a SQLite database specialist working on the **context-pulse** project. +You are a SQLite database specialist working on the **context-analyzer-tool** project. ## Your expertise diff --git a/.claude/agents/tester.md b/.claude/agents/tester.md index ac29c58..65ce2b0 100644 --- a/.claude/agents/tester.md +++ b/.claude/agents/tester.md @@ -6,7 +6,7 @@ tools: Read, Edit, Write, Glob, Grep, Bash effort: high --- -You are a testing specialist working on the **context-pulse** project. +You are a testing specialist working on the **context-analyzer-tool** project. ## Your expertise diff --git a/.claude/agents/tui-expert.md b/.claude/agents/tui-expert.md index b8df71c..0507580 100644 --- a/.claude/agents/tui-expert.md +++ b/.claude/agents/tui-expert.md @@ -6,7 +6,7 @@ tools: Read, Edit, Write, Glob, Grep, Bash, WebSearch, WebFetch effort: high --- -You are a terminal UI specialist working on the **context-pulse** project. +You are a terminal UI specialist working on the **context-analyzer-tool** project. ## Your expertise @@ -18,7 +18,7 @@ You are a terminal UI specialist working on the **context-pulse** project. ## Key constraints for this project -- TUI is the default dashboard mode, launched with `context-pulse dashboard` +- TUI is the default dashboard mode, launched with `context-analyzer-tool dashboard` - Must auto-refresh every 2s from SQLite data - Panels needed: - Current session: ctx%, burn rate, active tool, session timer diff --git a/.claude/agents/web-dashboard.md b/.claude/agents/web-dashboard.md index 7f85539..f4ea9dd 100644 --- a/.claude/agents/web-dashboard.md +++ b/.claude/agents/web-dashboard.md @@ -6,7 +6,7 @@ tools: Read, Edit, Write, Glob, Grep, Bash, WebSearch, WebFetch effort: high --- -You are a React web frontend specialist working on the **context-pulse** project. +You are a React web frontend specialist working on the **context-analyzer-tool** project. ## Your expertise diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 15c4de2..464d77b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -27,7 +27,7 @@ What actually happened. - OS: - Python version: -- context-pulse version: +- context-analyzer-tool version: - Claude Code version: ## Logs / Error Output diff --git a/CHANGELOG.md b/CHANGELOG.md index e3e09df..3f79032 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 0.3.0 (2026-04-07) + +### Project Rename + +- **Renamed from `context-pulse` to `context-analyzer-tool`** -- package, module, CLI entry point, and all internal references updated + +### TUI Visual Overhaul + +- **New color theme** -- vibrant `bright_*` color palette with centralized theme constants replacing scattered hardcoded styles +- **Distinct panel frames** -- each panel type has its own Rich box style: `HEAVY` header, `ROUNDED` sessions/anomalies, `DOUBLE` tasks +- **Alternating row highlights** -- zebra-striping on all tables for improved readability +- **Unicode panel icons** -- `⭐` header, `☰` sessions, `▒` tasks, `⚠` anomalies, `⚙` RTK +- **Header bar redesign** -- stats separated by `│` dividers with `⏱` `⚡` `▣` icons +- **Sleeping cat animation** -- 20-frame ASCII cat in the bottom-left "nap zone" with drifting z's, subtle ear twitches, and a brief wake-up frame +- **Double-bordered nap zone** -- outer `DOUBLE` frame with inner `ROUNDED` border confining the cat art +- **Faster refresh** -- default refresh rate increased from 2s to 1s for smoother animations + ## 0.2.0 (2026-04-05) ### Smart Warnings & Cache Awareness @@ -13,7 +30,7 @@ - **Compaction tracking** -- PreCompact/PostCompact hooks track compaction events with tokens saved - **Compaction API endpoint** -- `/api/compactions` for querying compaction history - **Burn rate API endpoint** -- `/api/sessions/{id}/burn-rate` for programmatic access -- **Uninstall command** -- `context-pulse uninstall` cleanly removes hooks from Claude Code +- **Uninstall command** -- `context-analyzer-tool uninstall` cleanly removes hooks from Claude Code ### Improvements diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5358cc3..f0f2d86 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,7 +27,7 @@ uv run ruff check src tests ### Project Structure ``` -src/context_pulse/ +src/context_analyzer_tool/ cli.py # Typer CLI entry point config.py # TOML + env var configuration collector/ # FastAPI HTTP server + delta engine @@ -60,13 +60,13 @@ uv run pyright # Type checker (strict mode) ```bash # Start the collector server -uv run context-pulse serve +uv run context-analyzer-tool serve # In another terminal, view the dashboard -uv run context-pulse dashboard +uv run context-analyzer-tool dashboard # Check health -uv run context-pulse health +uv run context-analyzer-tool health ``` ## Submitting Changes diff --git a/README.md b/README.md index 3f73519..5153eb1 100644 --- a/README.md +++ b/README.md @@ -37,13 +37,13 @@ uv sync --extra classifier ```bash # Install hooks into Claude Code (also writes default config) -context-pulse install +context-analyzer-tool install # Start the collector (keep running in a terminal) -context-pulse serve +context-analyzer-tool serve # Open the dashboard -context-pulse dashboard +context-analyzer-tool dashboard ``` That's it. Use Claude Code normally -- CAT tracks everything in the background. @@ -68,22 +68,22 @@ That's it. Use Claude Code normally -- CAT tracks everything in the background. ## CLI Reference ``` -context-pulse install Install hooks into Claude Code -context-pulse uninstall Remove hooks from Claude Code -context-pulse serve Start the collector server -context-pulse dashboard Launch the live TUI dashboard -context-pulse status View active sessions and recent tasks -context-pulse anomalies List recent anomalies with root causes -context-pulse context-cost Show context cost breakdown -context-pulse health Collector health check -context-pulse rtk-status Show RTK integration status and savings -context-pulse prune Clean up old data -context-pulse clear Clear all stored data and start fresh +context-analyzer-tool install Install hooks into Claude Code +context-analyzer-tool uninstall Remove hooks from Claude Code +context-analyzer-tool serve Start the collector server +context-analyzer-tool dashboard Launch the live TUI dashboard +context-analyzer-tool status View active sessions and recent tasks +context-analyzer-tool anomalies List recent anomalies with root causes +context-analyzer-tool context-cost Show context cost breakdown +context-analyzer-tool health Collector health check +context-analyzer-tool rtk-status Show RTK integration status and savings +context-analyzer-tool prune Clean up old data +context-analyzer-tool clear Clear all stored data and start fresh ``` ## Configuration -Config lives at `~/.context-pulse/config.toml` (created automatically on first `install`). Every setting can be overridden with environment variables using the `CONTEXT_PULSE_` prefix. +Config lives at `~/.context-analyzer-tool/config.toml` (created automatically on first `install`). Every setting can be overridden with environment variables using the `CAT_` prefix. ```toml [collector] @@ -108,9 +108,9 @@ webhook_url = "" # Slack/Discord webhook Environment variable overrides: ```bash -CONTEXT_PULSE_COLLECTOR_PORT=8080 -CONTEXT_PULSE_ANOMALY_Z_SCORE_THRESHOLD=3.0 -CONTEXT_PULSE_CLASSIFIER_ENABLED=false +CAT_COLLECTOR_PORT=8080 +CAT_ANOMALY_Z_SCORE_THRESHOLD=3.0 +CAT_CLASSIFIER_ENABLED=false ``` ## How It Works diff --git a/docs/demo-cli.svg b/docs/demo-cli.svg index 0670ee4..592db5a 100644 --- a/docs/demo-cli.svg +++ b/docs/demo-cli.svg @@ -118,7 +118,7 @@ - context-pulse CLI + context-analyzer-tool CLI @@ -129,7 +129,7 @@ -$ context-pulse status +$ context-analyzer-tool status                                      Active Sessions                                       ┌────────────────┬─────────────────────────────────┬─────────────┬──────────────┬────────┐ @@ -140,7 +140,7 @@ c4d0e8b3      rust-compiler                           89      45,120   18% └────────────────┴─────────────────────────────────┴─────────────┴──────────────┴────────┘ -$ context-pulse anomalies --limit 3 +$ context-analyzer-tool anomalies --limit 3                                      Recent Anomalies                                      ┌───────────┬───────┬──────────┬─────────┬───────────────────────────────────────────────┐ diff --git a/docs/demo-dashboard.svg b/docs/demo-dashboard.svg index 3235654..332c014 100644 --- a/docs/demo-dashboard.svg +++ b/docs/demo-dashboard.svg @@ -123,7 +123,7 @@ - context-pulse dashboard + context-analyzer-tool dashboard @@ -134,7 +134,7 @@ ┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -context-pulse dashboard | Uptime: 27h 46m | Events: 1741 | Snapshots: 1258 | ● Connected +context-analyzer-tool dashboard | Uptime: 27h 46m | Events: 1741 | Snapshots: 1258 | ● Connected └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐                                                   Session Overview                                                   diff --git a/docs/phase1-architecture.md b/docs/phase1-architecture.md index a9cdfe6..bc8c239 100644 --- a/docs/phase1-architecture.md +++ b/docs/phase1-architecture.md @@ -1,4 +1,4 @@ -# Phase 1 Architecture Specification -- context-pulse +# Phase 1 Architecture Specification -- context-analyzer-tool > Version: 1.0.0 > Date: 2026-03-28 @@ -26,7 +26,7 @@ All models use `pydantic.BaseModel`. Field names match the exact JSON keys from ### 1.1 Hook Payload Models (inbound from hook scripts) ```python -# src/context_pulse/collector/models.py +# src/context_analyzer_tool/collector/models.py from __future__ import annotations from pydantic import BaseModel, Field, field_validator @@ -90,7 +90,7 @@ class UserPromptSubmitPayload(BaseModel): ### 1.2 Statusline Payload Model (inbound from statusline script) ```python -# Also in src/context_pulse/collector/models.py +# Also in src/context_analyzer_tool/collector/models.py class StatuslineCurrentUsage(BaseModel): """Token counts for the current API turn.""" @@ -317,7 +317,7 @@ class HealthResponse(BaseModel): ### 1.6 Configuration Model ```python -# src/context_pulse/config.py +# src/context_analyzer_tool/config.py from pydantic import BaseModel, Field from pathlib import Path @@ -326,7 +326,7 @@ from pathlib import Path class CollectorConfig(BaseModel): host: str = "127.0.0.1" port: int = 7821 - db_path: str = "~/.context-pulse/context_pulse.db" + db_path: str = "~/.context-analyzer-tool/context_analyzer_tool.db" class AnomalyConfig(BaseModel): @@ -356,7 +356,7 @@ class DashboardConfig(BaseModel): web_port: int = 7822 -class ContextPulseConfig(BaseModel): +class CATConfig(BaseModel): """Root configuration model. Maps 1:1 to config.toml sections.""" collector: CollectorConfig = Field(default_factory=CollectorConfig) anomaly: AnomalyConfig = Field(default_factory=AnomalyConfig) @@ -499,7 +499,7 @@ Where `t_prev` is the snapshot immediately before this tool call (from the previ ### 3.2 In-Memory State Per Session ```python -# src/context_pulse/collector/delta_engine.py +# src/context_analyzer_tool/collector/delta_engine.py from dataclasses import dataclass, field from collections import deque @@ -595,7 +595,7 @@ Hook Script (PostToolUse) Statusline Script ## 4. Component Interfaces -### 4.1 `src/context_pulse/collector/server.py` -- FastAPI App +### 4.1 `src/context_analyzer_tool/collector/server.py` -- FastAPI App ```python """FastAPI application factory and lifespan management.""" @@ -609,7 +609,7 @@ from fastapi import FastAPI async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """ Startup: - - Load config from ~/.context-pulse/config.toml + - Load config from ~/.context-analyzer-tool/config.toml - Open aiosqlite connection (WAL mode, busy_timeout=5000, synchronous=NORMAL) - Store db connection on app.state.db - Store config on app.state.config @@ -634,7 +634,7 @@ def create_app() -> FastAPI: External deps: `fastapi`, `aiosqlite`, `uvicorn` Internal imports: `config.load_config`, `db.schema.run_migrations`, `collector.routes` -### 4.2 `src/context_pulse/collector/routes.py` -- Route Handlers +### 4.2 `src/context_analyzer_tool/collector/routes.py` -- Route Handlers ```python """All HTTP route handlers.""" @@ -656,7 +656,7 @@ async def get_sessions(request: Request) -> dict[str, SessionState]: return request.app.state.sessions -async def get_config(request: Request) -> ContextPulseConfig: +async def get_config(request: Request) -> CATConfig: """Dependency: yields app.state.config.""" return request.app.state.config @@ -703,7 +703,7 @@ async def get_status( ) -> StatusResponse: """ Returns active sessions, last 20 events, last 20 tasks. - Used by `context-pulse status` CLI command. + Used by `context-analyzer-tool status` CLI command. """ ... @@ -755,7 +755,7 @@ async def get_session_snapshots( External deps: `fastapi` Internal imports: `collector.models`, `collector.delta_engine`, `db.events`, `db.tasks`, `config` -### 4.3 `src/context_pulse/collector/delta_engine.py` -- Token Delta Engine +### 4.3 `src/context_analyzer_tool/collector/delta_engine.py` -- Token Delta Engine ```python """In-memory correlation engine pairing tool calls with token snapshots.""" @@ -766,7 +766,7 @@ from typing import Optional import logging import aiosqlite -logger = logging.getLogger("context_pulse.delta_engine") +logger = logging.getLogger("context_analyzer_tool.delta_engine") @dataclass @@ -868,7 +868,7 @@ async def restore_sessions_from_db( External deps: `aiosqlite` Internal imports: `collector.models`, `db.tasks` -### 4.4 `src/context_pulse/db/schema.py` -- Schema and Migrations +### 4.4 `src/context_analyzer_tool/db/schema.py` -- Schema and Migrations ```python """Database schema creation and migration management.""" @@ -876,7 +876,7 @@ Internal imports: `collector.models`, `db.tasks` import aiosqlite import logging -logger = logging.getLogger("context_pulse.db.schema") +logger = logging.getLogger("context_analyzer_tool.db.schema") MIGRATIONS: list[tuple[int, str]] = [ (1, "initial schema"), @@ -914,7 +914,7 @@ async def get_schema_version(db: aiosqlite.Connection) -> int: External deps: `aiosqlite` Internal imports: none -### 4.5 `src/context_pulse/db/events.py` -- Event CRUD +### 4.5 `src/context_analyzer_tool/db/events.py` -- Event CRUD ```python """CRUD operations for the events and token_snapshots tables.""" @@ -1022,7 +1022,7 @@ async def get_snapshot_count(db: aiosqlite.Connection) -> int: External deps: `aiosqlite` Internal imports: none -### 4.6 `src/context_pulse/db/tasks.py` -- Task CRUD + Delta Operations +### 4.6 `src/context_analyzer_tool/db/tasks.py` -- Task CRUD + Delta Operations ```python """CRUD operations for the tasks table.""" @@ -1105,7 +1105,7 @@ async def get_null_delta_tasks( External deps: `aiosqlite` Internal imports: none -### 4.7 `src/context_pulse/config.py` -- Configuration +### 4.7 `src/context_analyzer_tool/config.py` -- Configuration ```python """TOML configuration loader.""" @@ -1115,20 +1115,20 @@ from pathlib import Path from typing import Optional import logging -logger = logging.getLogger("context_pulse.config") +logger = logging.getLogger("context_analyzer_tool.config") -DEFAULT_CONFIG_DIR = Path.home() / ".context-pulse" +DEFAULT_CONFIG_DIR = Path.home() / ".context-analyzer-tool" DEFAULT_CONFIG_PATH = DEFAULT_CONFIG_DIR / "config.toml" -def load_config(config_path: Optional[Path] = None) -> ContextPulseConfig: +def load_config(config_path: Optional[Path] = None) -> CATConfig: """ Load config from TOML file. 1. If config_path is None, use DEFAULT_CONFIG_PATH. - 2. If file does not exist, return ContextPulseConfig() (all defaults). + 2. If file does not exist, return CATConfig() (all defaults). 3. Parse TOML, validate with Pydantic. 4. Expand ~ in db_path. - Returns ContextPulseConfig. + Returns CATConfig. Raises ValueError if TOML is malformed. """ ... @@ -1136,13 +1136,13 @@ def load_config(config_path: Optional[Path] = None) -> ContextPulseConfig: def ensure_config_dir() -> Path: """ - Create ~/.context-pulse/ if it doesn't exist. + Create ~/.context-analyzer-tool/ if it doesn't exist. Returns the directory path. """ ... -def get_db_path(config: ContextPulseConfig) -> str: +def get_db_path(config: CATConfig) -> str: """ Resolve db_path from config, expanding ~. Returns absolute path string. @@ -1159,7 +1159,7 @@ def write_default_config(path: Optional[Path] = None) -> Path: ``` External deps: `tomllib` (stdlib 3.11+), `pydantic` -Internal imports: `collector.models.ContextPulseConfig` (or defined here) +Internal imports: `collector.models.CATConfig` (or defined here) --- @@ -1176,7 +1176,7 @@ All hook scripts are standalone Python files using PEP 723 inline script metadat # /// """ -context-pulse hook: {HookName} +context-analyzer-tool hook: {HookName} Reads hook payload from stdin, POSTs to collector, exits 0. """ @@ -1282,7 +1282,7 @@ This script serves a **dual purpose**: it both POSTs snapshot data to the collec # /// """ -context-pulse statusline script. +context-analyzer-tool statusline script. 1. Reads statusline JSON from stdin (provided by Claude Code). 2. POSTs token snapshot to collector (fire-and-forget, 2s timeout). 3. Prints a formatted statusline string to stdout. @@ -1370,7 +1370,7 @@ def main() -> None: print(format_statusline(data)) except Exception: # On any error, output a safe default - print("context-pulse | --") + print("context-analyzer-tool | --") sys.exit(0) @@ -1392,7 +1392,7 @@ if __name__ == "__main__": ### 5.8 Installation Configuration in settings.json -The `context-pulse install` command writes to `~/.claude/settings.json`: +The `context-analyzer-tool install` command writes to `~/.claude/settings.json`: ```json { @@ -1403,7 +1403,7 @@ The `context-pulse install` command writes to `~/.claude/settings.json`: "hooks": [ { "type": "command", - "command": "uv run ~/.context-pulse/hooks/post_tool_use.py", + "command": "uv run ~/.context-analyzer-tool/hooks/post_tool_use.py", "timeout": 5 } ] @@ -1415,7 +1415,7 @@ The `context-pulse install` command writes to `~/.claude/settings.json`: "hooks": [ { "type": "command", - "command": "uv run ~/.context-pulse/hooks/subagent_stop.py", + "command": "uv run ~/.context-analyzer-tool/hooks/subagent_stop.py", "timeout": 5 } ] @@ -1427,7 +1427,7 @@ The `context-pulse install` command writes to `~/.claude/settings.json`: "hooks": [ { "type": "command", - "command": "uv run ~/.context-pulse/hooks/stop.py", + "command": "uv run ~/.context-analyzer-tool/hooks/stop.py", "timeout": 5 } ] @@ -1439,7 +1439,7 @@ The `context-pulse install` command writes to `~/.claude/settings.json`: "hooks": [ { "type": "command", - "command": "uv run ~/.context-pulse/hooks/user_prompt_submit.py", + "command": "uv run ~/.context-analyzer-tool/hooks/user_prompt_submit.py", "timeout": 5 } ] @@ -1448,12 +1448,12 @@ The `context-pulse install` command writes to `~/.claude/settings.json`: }, "statusLine": { "type": "command", - "command": "uv run ~/.context-pulse/hooks/statusline.py" + "command": "uv run ~/.context-analyzer-tool/hooks/statusline.py" } } ``` -Note: The install command must **merge** with existing settings, not overwrite. It reads the existing file, adds/updates only the context-pulse entries, and writes back with proper JSON formatting. +Note: The install command must **merge** with existing settings, not overwrite. It reads the existing file, adds/updates only the context-analyzer-tool entries, and writes back with proper JSON formatting. --- @@ -1461,17 +1461,17 @@ Note: The install command must **merge** with existing settings, not overwrite. CLI is built with `typer`. Since Typer does not support async, all async operations are wrapped with `asyncio.run()`. -### 6.1 `context-pulse serve` +### 6.1 `context-analyzer-tool serve` ``` -Usage: context-pulse serve [OPTIONS] +Usage: context-analyzer-tool serve [OPTIONS] Start the collector server. Options: --host TEXT Bind host [default: 127.0.0.1] --port INTEGER Bind port [default: 7821] - --config PATH Config file path [default: ~/.context-pulse/config.toml] + --config PATH Config file path [default: ~/.context-analyzer-tool/config.toml] --log-level TEXT Log level [default: info] ``` @@ -1491,9 +1491,9 @@ def serve( config: Optional[Path] = typer.Option(None, help="Config file path"), log_level: str = typer.Option("info", help="Log level"), ) -> None: - """Start the context-pulse collector server.""" + """Start the context-analyzer-tool collector server.""" import uvicorn - from context_pulse.collector.server import create_app + from context_analyzer_tool.collector.server import create_app cfg = load_config(config) actual_host = host or cfg.collector.host @@ -1507,10 +1507,10 @@ def serve( ) ``` -### 6.2 `context-pulse status` +### 6.2 `context-analyzer-tool status` ``` -Usage: context-pulse status [OPTIONS] +Usage: context-analyzer-tool status [OPTIONS] Show recent events and active sessions. @@ -1523,7 +1523,7 @@ Options: **What it does:** 1. GET `{url}/api/status` from the running collector. -2. If collector is unreachable, print error and suggest `context-pulse serve`. +2. If collector is unreachable, print error and suggest `context-analyzer-tool serve`. 3. Render a Rich table with columns: `Time | Session | Type | Tool | Delta | Compaction`. 4. Above the table, show active session summary: session_id (truncated to 8 chars), event count, latest ctx%, model. @@ -1572,7 +1572,7 @@ async def _status_async( except httpx.ConnectError: console.print( "[red]Cannot connect to collector.[/red] " - "Is it running? Start with: [bold]context-pulse serve[/bold]" + "Is it running? Start with: [bold]context-analyzer-tool serve[/bold]" ) raise typer.Exit(1) @@ -1587,41 +1587,41 @@ async def _status_async( ... ``` -### 6.3 `context-pulse install` +### 6.3 `context-analyzer-tool install` ``` -Usage: context-pulse install [OPTIONS] +Usage: context-analyzer-tool install [OPTIONS] Install hooks and statusline into Claude Code settings. Options: --claude-settings PATH Path to settings.json [default: ~/.claude/settings.json] - --hooks-dir PATH Where to copy hook scripts [default: ~/.context-pulse/hooks/] - --uninstall Remove context-pulse hooks and statusline + --hooks-dir PATH Where to copy hook scripts [default: ~/.context-analyzer-tool/hooks/] + --uninstall Remove context-analyzer-tool hooks and statusline --check Verify installation without modifying anything --use-http Use HTTP hooks instead of command hooks (posts directly to collector) ``` **What it does (install):** -1. Create `~/.context-pulse/` and `~/.context-pulse/hooks/` directories. -2. Copy hook scripts from the package to `~/.context-pulse/hooks/`. +1. Create `~/.context-analyzer-tool/` and `~/.context-analyzer-tool/hooks/` directories. +2. Copy hook scripts from the package to `~/.context-analyzer-tool/hooks/`. 3. Write default `config.toml` if it doesn't exist. 4. Read existing `~/.claude/settings.json` (or create if missing). -5. Merge hook entries and statusline entry (preserve existing non-context-pulse hooks). +5. Merge hook entries and statusline entry (preserve existing non-context-analyzer-tool hooks). 6. Write back settings.json. 7. Verify collector is reachable (GET /health). If not, warn user. 8. Print summary of what was installed. **What it does (--check):** 1. Check if hooks directory exists and contains expected scripts. -2. Check if settings.json has context-pulse hooks registered. +2. Check if settings.json has context-analyzer-tool hooks registered. 3. Check if collector is reachable. 4. Print pass/fail for each check. **What it does (--uninstall):** -1. Remove context-pulse hook entries from settings.json. -2. Remove statusline entry from settings.json (only if it points to context-pulse). -3. Optionally remove ~/.context-pulse/ directory (prompt user). +1. Remove context-analyzer-tool hook entries from settings.json. +2. Remove statusline entry from settings.json (only if it points to context-analyzer-tool). +3. Optionally remove ~/.context-analyzer-tool/ directory (prompt user). **What it does (--use-http):** Instead of `"type": "command"` hooks, install `"type": "http"` hooks that POST directly to the collector. This avoids spawning a Python process per hook but requires the collector to be running at hook time. @@ -1742,8 +1742,8 @@ These groups can be built in parallel: ### 8.1 Full TOML with Defaults and Comments ```toml -# ~/.context-pulse/config.toml -# context-pulse configuration +# ~/.context-analyzer-tool/config.toml +# context-analyzer-tool configuration [collector] # Host to bind the collector server @@ -1751,7 +1751,7 @@ host = "127.0.0.1" # Port for the collector HTTP server port = 7821 # Path to SQLite database (~ is expanded) -db_path = "~/.context-pulse/context_pulse.db" +db_path = "~/.context-analyzer-tool/context_analyzer_tool.db" [anomaly] # Z-score threshold for anomaly detection (Phase 2) @@ -1776,7 +1776,7 @@ max_tokens = 150 cache_results = true [notifications] -# Show context-pulse data in Claude Code statusline +# Show context-analyzer-tool data in Claude Code statusline statusline = true # Fire OS-level notifications on anomalies system_notification = true @@ -1799,23 +1799,23 @@ See Section 1.6 above. The model maps exactly: each TOML section is a nested Bas ### 8.3 Config Loading Priority 1. Built-in defaults (Pydantic model defaults). -2. Config file (`~/.context-pulse/config.toml`). -3. Environment variables: `CONTEXT_PULSE_COLLECTOR_PORT=7822` overrides `collector.port`. Pattern: `CONTEXT_PULSE_{SECTION}_{KEY}` (uppercase). +2. Config file (`~/.context-analyzer-tool/config.toml`). +3. Environment variables: `CAT_COLLECTOR_PORT=7822` overrides `collector.port`. Pattern: `CAT_{SECTION}_{KEY}` (uppercase). 4. CLI flags (highest priority, only for `serve` command). -Environment variable support is implemented with a custom `model_validator` on `ContextPulseConfig` that checks `os.environ` for matching keys after TOML loading. +Environment variable support is implemented with a custom `model_validator` on `CATConfig` that checks `os.environ` for matching keys after TOML loading. --- ## 9. File-by-File Breakdown -### 9.1 `src/context_pulse/__init__.py` +### 9.1 `src/context_analyzer_tool/__init__.py` - **Contains:** Package version string `__version__ = "0.1.0"`. - **Internal imports:** None. - **External deps:** None. -### 9.2 `src/context_pulse/cli.py` +### 9.2 `src/context_analyzer_tool/cli.py` - **Contains:** - `app = typer.Typer()` -- the root CLI app. @@ -1823,30 +1823,30 @@ Environment variable support is implemented with a custom `model_validator` on ` - `status()` -- fetches and displays recent events from the collector API. - `_status_async()` -- async implementation of status. - `install()` -- installs hooks into Claude Code settings. - - `_merge_settings()` -- merges context-pulse hooks into existing settings.json. - - `_copy_hook_scripts()` -- copies hook .py files to ~/.context-pulse/hooks/. + - `_merge_settings()` -- merges context-analyzer-tool hooks into existing settings.json. + - `_copy_hook_scripts()` -- copies hook .py files to ~/.context-analyzer-tool/hooks/. - `_verify_installation()` -- checks that hooks are installed and collector is reachable. - **Internal imports:** `config.load_config`, `config.ensure_config_dir`, `config.write_default_config`, `collector.server.create_app`. - **External deps:** `typer`, `rich` (Console, Table, Panel), `httpx`, `uvicorn`, `asyncio`, `json`, `pathlib`, `shutil`. -### 9.3 `src/context_pulse/config.py` +### 9.3 `src/context_analyzer_tool/config.py` - **Contains:** - - All Pydantic config models (see Section 1.6): `CollectorConfig`, `AnomalyConfig`, `ClassifierConfig`, `NotificationsConfig`, `DashboardConfig`, `ContextPulseConfig`. - - `load_config(config_path: Optional[Path] = None) -> ContextPulseConfig` + - All Pydantic config models (see Section 1.6): `CollectorConfig`, `AnomalyConfig`, `ClassifierConfig`, `NotificationsConfig`, `DashboardConfig`, `CATConfig`. + - `load_config(config_path: Optional[Path] = None) -> CATConfig` - `ensure_config_dir() -> Path` - - `get_db_path(config: ContextPulseConfig) -> str` + - `get_db_path(config: CATConfig) -> str` - `write_default_config(path: Optional[Path] = None) -> Path` - **Internal imports:** None. - **External deps:** `pydantic` (BaseModel, Field), `tomllib` (stdlib), `pathlib`, `os`, `logging`. -### 9.4 `src/context_pulse/collector/__init__.py` +### 9.4 `src/context_analyzer_tool/collector/__init__.py` - **Contains:** Empty or re-exports. - **Internal imports:** None. - **External deps:** None. -### 9.5 `src/context_pulse/collector/models.py` +### 9.5 `src/context_analyzer_tool/collector/models.py` - **Contains:** All Pydantic models from Sections 1.1 through 1.5: - `PostToolUsePayload`, `SubagentStopPayload`, `StopPayload`, `UserPromptSubmitPayload` @@ -1857,7 +1857,7 @@ Environment variable support is implemented with a custom `model_validator` on ` - **Internal imports:** None. - **External deps:** `pydantic` (BaseModel, Field, field_validator), `typing` (Any, Optional), `time`. -### 9.6 `src/context_pulse/collector/server.py` +### 9.6 `src/context_analyzer_tool/collector/server.py` - **Contains:** - `lifespan(app: FastAPI) -> AsyncGenerator[None, None]` -- async context manager. @@ -1865,7 +1865,7 @@ Environment variable support is implemented with a custom `model_validator` on ` - **Internal imports:** `config.load_config`, `config.get_db_path`, `db.schema.open_db`, `db.schema.run_migrations`, `collector.routes.hook_router`, `collector.routes.api_router`, `collector.delta_engine.restore_sessions_from_db`. - **External deps:** `fastapi` (FastAPI), `contextlib` (asynccontextmanager), `typing` (AsyncGenerator), `time`, `logging`. -### 9.7 `src/context_pulse/collector/routes.py` +### 9.7 `src/context_analyzer_tool/collector/routes.py` - **Contains:** - `hook_router = APIRouter()`, `api_router = APIRouter()` @@ -1874,7 +1874,7 @@ Environment variable support is implemented with a custom `model_validator` on ` - **Internal imports:** `collector.models` (all request/response models), `collector.delta_engine` (on_tool_use, on_snapshot, on_session_stop), `db.events` (insert_event, insert_snapshot, get_recent_events, get_event_count, get_snapshot_count, get_latest_snapshot, get_active_session_ids), `db.tasks` (insert_task, get_recent_tasks). - **External deps:** `fastapi` (APIRouter, Depends, Request), `aiosqlite` (Connection type), `logging`, `json`, `time`. -### 9.8 `src/context_pulse/collector/delta_engine.py` +### 9.8 `src/context_analyzer_tool/collector/delta_engine.py` - **Contains:** - `PendingToolCall` dataclass. @@ -1883,13 +1883,13 @@ Environment variable support is implemented with a custom `model_validator` on ` - **Internal imports:** `db.tasks` (insert_task, update_task_delta), `db.events` (get_latest_snapshot, get_active_session_ids). - **External deps:** `aiosqlite`, `dataclasses`, `collections` (deque), `typing`, `logging`, `time`. -### 9.9 `src/context_pulse/db/__init__.py` +### 9.9 `src/context_analyzer_tool/db/__init__.py` - **Contains:** Empty or re-exports. - **Internal imports:** None. - **External deps:** None. -### 9.10 `src/context_pulse/db/schema.py` +### 9.10 `src/context_analyzer_tool/db/schema.py` - **Contains:** - `MIGRATIONS` list. @@ -1900,14 +1900,14 @@ Environment variable support is implemented with a custom `model_validator` on ` - **Internal imports:** None. - **External deps:** `aiosqlite`, `logging`. -### 9.11 `src/context_pulse/db/events.py` +### 9.11 `src/context_analyzer_tool/db/events.py` - **Contains:** - `insert_event()`, `insert_snapshot()`, `get_recent_events()`, `get_latest_snapshot()`, `get_recent_snapshots()`, `get_active_session_ids()`, `get_event_count()`, `get_snapshot_count()`. - **Internal imports:** None. - **External deps:** `aiosqlite`, `typing`, `json`. -### 9.12 `src/context_pulse/db/tasks.py` +### 9.12 `src/context_analyzer_tool/db/tasks.py` - **Contains:** - `insert_task()`, `update_task_delta()`, `get_recent_tasks()`, `get_tasks_by_type()`, `get_null_delta_tasks()`. @@ -2010,7 +2010,7 @@ Environment variable support is implemented with a custom `model_validator` on ` ```toml [project] -name = "context-pulse" +name = "context-analyzer-tool" version = "0.1.0" description = "Per-task token attribution and anomaly detection for Claude Code" requires-python = ">=3.11" @@ -2025,7 +2025,7 @@ dependencies = [ ] [project.scripts] -context-pulse = "context_pulse.cli:app" +context-analyzer-tool = "context_analyzer_tool.cli:app" [dependency-groups] dev = [ @@ -2059,7 +2059,7 @@ testpaths = ["tests"] | # | Edge Case | Severity | Handling | |---|---|---|---| -| E1 | **Collector not running when hook fires** | Medium | Hook scripts catch `httpx.ConnectError`, log nothing, exit 0. Events are silently lost. Mitigation: `context-pulse install --check` warns if collector is down. Future: hook scripts can write to a local spool file (`~/.context-pulse/spool/`) and the collector drains it on startup. Phase 1 does NOT implement spooling. | +| E1 | **Collector not running when hook fires** | Medium | Hook scripts catch `httpx.ConnectError`, log nothing, exit 0. Events are silently lost. Mitigation: `context-analyzer-tool install --check` warns if collector is down. Future: hook scripts can write to a local spool file (`~/.context-analyzer-tool/spool/`) and the collector drains it on startup. Phase 1 does NOT implement spooling. | | E2 | **Statusline script fails to POST** | Medium | Same as E1. The statusline still outputs to stdout (Claude Code sees a status). Only the snapshot is lost. Token deltas for tool calls between the lost snapshot and the next one will be aggregated into a single larger delta. | | E3 | **Hook script exceeds timeout (5s)** | Low | Claude Code kills the process. `httpx` timeout is 2s, so this should not happen unless `uv run` is slow on first invocation (dependency resolution). Mitigation: run `uv run hooks/post_tool_use.py < /dev/null` once during install to pre-cache deps. | | E4 | **Multiple Claude Code instances race on settings.json** | Low | The install command uses a file lock (portalocker or fcntl) when writing settings.json. If locking is not available, it reads-modifies-writes with a retry on conflict. | @@ -2084,7 +2084,7 @@ testpaths = ["tests"] | E13 | **uv not installed** | High | The install command checks for `uv` in PATH. If missing, prints instructions: `pip install uv` or `curl -LsSf https://astral.sh/uv/install.sh | sh`. Hooks will not work without `uv`. | | E14 | **Python < 3.11** | Medium | `pyproject.toml` declares `requires-python = ">=3.11"`. `uv` enforces this. Hook scripts declare `requires-python = ">=3.11"` via PEP 723. | | E15 | **Disk full (SQLite write fails)** | Low | All DB writes are wrapped in try/except. On write failure, the route returns 202 anyway (hook must not fail), logs an error. A persistent disk-full state is surfaced via `GET /health` which tries a test write. | -| E16 | **Port 7821 already in use** | Medium | `uvicorn.run()` raises `OSError`. The `serve` command catches this and prints a clear message: "Port 7821 is in use. Is another context-pulse instance running? Use --port to specify a different port." | +| E16 | **Port 7821 already in use** | Medium | `uvicorn.run()` raises `OSError`. The `serve` command catches this and prints a clear message: "Port 7821 is in use. Is another context-analyzer-tool instance running? Use --port to specify a different port." | ### 10.4 Data Integrity Risks @@ -2100,22 +2100,22 @@ testpaths = ["tests"] | # | Edge Case | Severity | Handling | |---|---|---|---| -| E22 | **Database grows unbounded** | Medium | Phase 1 does not implement cleanup. Estimated growth: ~5MB/month of heavy use. A future `context-pulse gc` command will delete events older than a configurable retention period (default 30 days). For Phase 1, document that users can delete the DB file to reset. | +| E22 | **Database grows unbounded** | Medium | Phase 1 does not implement cleanup. Estimated growth: ~5MB/month of heavy use. A future `context-analyzer-tool gc` command will delete events older than a configurable retention period (default 30 days). For Phase 1, document that users can delete the DB file to reset. | | E23 | **Memory leak from sessions dict** | Low | `cleanup_stale_sessions()` runs every 60s and evicts sessions idle for >1 hour. Each SessionState is ~200 bytes. Even 1000 sessions would be 200KB. Not a concern. | -| E24 | **First-run uv dependency resolution slow** | Medium | First `uv run` of a hook script downloads `httpx` and its deps. This can take 2-5s, potentially exceeding Claude Code's 5s hook timeout. Mitigation: `context-pulse install` runs each hook script once with empty stdin to pre-warm the `uv` cache. | -| E25 | **User has existing hooks in settings.json** | Medium | The install command merges, not overwrites. It reads the existing hooks arrays and appends context-pulse entries. If a context-pulse entry already exists (detected by checking if the command contains "context-pulse"), it is updated in place. | +| E24 | **First-run uv dependency resolution slow** | Medium | First `uv run` of a hook script downloads `httpx` and its deps. This can take 2-5s, potentially exceeding Claude Code's 5s hook timeout. Mitigation: `context-analyzer-tool install` runs each hook script once with empty stdin to pre-warm the `uv` cache. | +| E25 | **User has existing hooks in settings.json** | Medium | The install command merges, not overwrites. It reads the existing hooks arrays and appends context-analyzer-tool entries. If a context-analyzer-tool entry already exists (detected by checking if the command contains "context-analyzer-tool"), it is updated in place. | --- ## Appendix A: Project File Structure (Phase 1) ``` -context-pulse/ +context-analyzer-tool/ ├── pyproject.toml ├── CLAUDE.md ├── .gitattributes ├── src/ -│ └── context_pulse/ +│ └── context_analyzer_tool/ │ ├── __init__.py │ ├── cli.py │ ├── config.py @@ -2147,11 +2147,11 @@ context-pulse/ ``` Files NOT included in Phase 1 (deferred): -- `src/context_pulse/db/baselines.py` -- Phase 2 -- `src/context_pulse/db/anomalies.py` -- Phase 2 -- `src/context_pulse/engine/` -- Phase 2 -- `src/context_pulse/notify/` -- Phase 3 -- `src/context_pulse/dashboard/` -- Phase 4/5 +- `src/context_analyzer_tool/db/baselines.py` -- Phase 2 +- `src/context_analyzer_tool/db/anomalies.py` -- Phase 2 +- `src/context_analyzer_tool/engine/` -- Phase 2 +- `src/context_analyzer_tool/notify/` -- Phase 3 +- `src/context_analyzer_tool/dashboard/` -- Phase 4/5 --- diff --git a/docs/phase2-architecture.md b/docs/phase2-architecture.md index bfb25b6..03615cb 100644 --- a/docs/phase2-architecture.md +++ b/docs/phase2-architecture.md @@ -1,4 +1,4 @@ -# Phase 2 Architecture Specification -- context-pulse +# Phase 2 Architecture Specification -- context-analyzer-tool > Version: 1.0.0 > Date: 2026-03-28 @@ -12,24 +12,24 @@ | File | Purpose | |---|---| -| `src/context_pulse/engine/__init__.py` | Package init (empty) | -| `src/context_pulse/engine/baseline.py` | `RollingWelford` class -- Welford's online algorithm with deque-backed rolling window | -| `src/context_pulse/engine/anomaly.py` | Z-score anomaly detector -- computes z-score per task, applies thresholds and cooldown | -| `src/context_pulse/engine/classifier.py` | Haiku classifier call + SQLite-backed response cache | -| `src/context_pulse/db/baselines.py` | CRUD for the `baselines` table (get, upsert) | -| `src/context_pulse/db/anomalies.py` | CRUD for the `anomalies` table (insert, query, cooldown check) | +| `src/context_analyzer_tool/engine/__init__.py` | Package init (empty) | +| `src/context_analyzer_tool/engine/baseline.py` | `RollingWelford` class -- Welford's online algorithm with deque-backed rolling window | +| `src/context_analyzer_tool/engine/anomaly.py` | Z-score anomaly detector -- computes z-score per task, applies thresholds and cooldown | +| `src/context_analyzer_tool/engine/classifier.py` | Haiku classifier call + SQLite-backed response cache | +| `src/context_analyzer_tool/db/baselines.py` | CRUD for the `baselines` table (get, upsert) | +| `src/context_analyzer_tool/db/anomalies.py` | CRUD for the `anomalies` table (insert, query, cooldown check) | | `tests/test_baseline.py` | Unit tests for `RollingWelford` | | `tests/test_anomaly.py` | Unit tests for anomaly detector | | `tests/test_classifier.py` | Unit tests for classifier + cache | No existing files need to be deleted. The following existing files require modifications (detailed in section 7): -- `src/context_pulse/collector/delta_engine.py` -- call anomaly detector after delta assignment -- `src/context_pulse/collector/routes.py` -- add anomaly API endpoints -- `src/context_pulse/collector/models.py` -- add new Pydantic response models -- `src/context_pulse/collector/server.py` -- store config on app.state (already done) -- `src/context_pulse/cli.py` -- add `anomalies` command -- `src/context_pulse/db/schema.py` -- add v2 migration for `classifier_cache` table +- `src/context_analyzer_tool/collector/delta_engine.py` -- call anomaly detector after delta assignment +- `src/context_analyzer_tool/collector/routes.py` -- add anomaly API endpoints +- `src/context_analyzer_tool/collector/models.py` -- add new Pydantic response models +- `src/context_analyzer_tool/collector/server.py` -- store config on app.state (already done) +- `src/context_analyzer_tool/cli.py` -- add `anomalies` command +- `src/context_analyzer_tool/db/schema.py` -- add v2 migration for `classifier_cache` table - `pyproject.toml` -- add `anthropic` dependency --- @@ -63,7 +63,7 @@ ALTER TABLE baselines ADD COLUMN window_json TEXT NOT NULL DEFAULT '[]'; ### 2.3 Migration Registration -In `src/context_pulse/db/schema.py`, add: +In `src/context_analyzer_tool/db/schema.py`, add: ```python MIGRATIONS: list[tuple[int, str]] = [ @@ -88,7 +88,7 @@ async def _apply_v2(db: aiosqlite.Connection) -> None: ## 3. Pydantic Models (New) -Add these to `src/context_pulse/collector/models.py`: +Add these to `src/context_analyzer_tool/collector/models.py`: ```python class BaselineSnapshot(BaseModel): @@ -142,7 +142,7 @@ class AnomaliesListResponse(BaseModel): --- -## 4. Welford Baseline Engine (`src/context_pulse/engine/baseline.py`) +## 4. Welford Baseline Engine (`src/context_analyzer_tool/engine/baseline.py`) ### 4.1 `RollingWelford` Class @@ -158,9 +158,9 @@ from collections import deque import aiosqlite -from context_pulse.db import baselines as db_baselines +from context_analyzer_tool.db import baselines as db_baselines -logger = logging.getLogger("context_pulse.engine.baseline") +logger = logging.getLogger("context_analyzer_tool.engine.baseline") class RollingWelford: @@ -376,7 +376,7 @@ The `BaselineManager.record_delta()` is called from the anomaly detector (sectio --- -## 5. Anomaly Detector (`src/context_pulse/engine/anomaly.py`) +## 5. Anomaly Detector (`src/context_analyzer_tool/engine/anomaly.py`) ### 5.1 Core Detection Function @@ -388,13 +388,13 @@ import time import aiosqlite -from context_pulse.collector.models import AnomalyResult, ClassifierResponse -from context_pulse.config import AnomalyConfig, ClassifierConfig -from context_pulse.db import anomalies as db_anomalies -from context_pulse.engine.baseline import BaselineManager -from context_pulse.engine.classifier import classify_anomaly +from context_analyzer_tool.collector.models import AnomalyResult, ClassifierResponse +from context_analyzer_tool.config import AnomalyConfig, ClassifierConfig +from context_analyzer_tool.db import anomalies as db_anomalies +from context_analyzer_tool.engine.baseline import BaselineManager +from context_analyzer_tool.engine.classifier import classify_anomaly -logger = logging.getLogger("context_pulse.engine.anomaly") +logger = logging.getLogger("context_analyzer_tool.engine.anomaly") MIN_STDDEV: float = 100.0 # floor to prevent div-by-zero on uniform data @@ -595,7 +595,7 @@ async def detect_anomaly( --- -## 6. Haiku Classifier (`src/context_pulse/engine/classifier.py`) +## 6. Haiku Classifier (`src/context_analyzer_tool/engine/classifier.py`) ### 6.1 Full Implementation @@ -608,10 +608,10 @@ import time import aiosqlite -from context_pulse.collector.models import ClassifierResponse -from context_pulse.config import ClassifierConfig +from context_analyzer_tool.collector.models import ClassifierResponse +from context_analyzer_tool.config import ClassifierConfig -logger = logging.getLogger("context_pulse.engine.classifier") +logger = logging.getLogger("context_analyzer_tool.engine.classifier") # The system prompt, per project brief section 8 CLASSIFIER_SYSTEM_PROMPT: str = ( @@ -872,7 +872,7 @@ The classifier must never raise. All errors are caught and logged. The anomaly r ## 7. Database CRUD -### 7.1 `src/context_pulse/db/baselines.py` +### 7.1 `src/context_analyzer_tool/db/baselines.py` ```python from __future__ import annotations @@ -882,7 +882,7 @@ from typing import Any import aiosqlite -logger = logging.getLogger("context_pulse.db.baselines") +logger = logging.getLogger("context_analyzer_tool.db.baselines") async def get_baseline( @@ -949,7 +949,7 @@ async def get_all_baselines( return [dict(row) for row in rows] ``` -### 7.2 `src/context_pulse/db/anomalies.py` +### 7.2 `src/context_analyzer_tool/db/anomalies.py` ```python from __future__ import annotations @@ -959,7 +959,7 @@ from typing import Any import aiosqlite -logger = logging.getLogger("context_pulse.db.anomalies") +logger = logging.getLogger("context_analyzer_tool.db.anomalies") async def insert_anomaly( @@ -1129,7 +1129,7 @@ async def process_anomalies( list[AnomalyResult] Any anomalies detected (may be empty). """ - from context_pulse.engine.anomaly import detect_anomaly + from context_analyzer_tool.engine.anomaly import detect_anomaly anomalies: list[AnomalyResult] = [] for (task_id, delta, is_compaction), ptc in zip(results, pending_list): @@ -1233,7 +1233,7 @@ async def get_baseline_manager(request: Request) -> BaselineManager: Add `BaselineManager` initialization in the lifespan: ```python -from context_pulse.engine.baseline import BaselineManager +from context_analyzer_tool.engine.baseline import BaselineManager # Inside lifespan(), after run_migrations(db): baseline_manager = BaselineManager( @@ -1325,7 +1325,7 @@ def anomalies( except httpx.ConnectError: console.print( "[red]Cannot connect to collector.[/red] " - "Is it running? Start with: [bold]context-pulse serve[/bold]" + "Is it running? Start with: [bold]context-analyzer-tool serve[/bold]" ) raise typer.Exit(1) from None except httpx.HTTPError as exc: @@ -1420,18 +1420,18 @@ schema.py (modified) ─────────v2 migration ### 9.2 Build Order (Parallelism Opportunities) **Layer 1 -- No dependencies, build in parallel:** -- `src/context_pulse/db/baselines.py` -- `src/context_pulse/db/anomalies.py` -- `src/context_pulse/db/schema.py` (v2 migration addition) -- `src/context_pulse/engine/__init__.py` +- `src/context_analyzer_tool/db/baselines.py` +- `src/context_analyzer_tool/db/anomalies.py` +- `src/context_analyzer_tool/db/schema.py` (v2 migration addition) +- `src/context_analyzer_tool/engine/__init__.py` - New Pydantic models in `models.py` **Layer 2 -- Depends on Layer 1:** -- `src/context_pulse/engine/baseline.py` (depends on `db/baselines.py`) -- `src/context_pulse/engine/classifier.py` (depends on models, `db` for cache) +- `src/context_analyzer_tool/engine/baseline.py` (depends on `db/baselines.py`) +- `src/context_analyzer_tool/engine/classifier.py` (depends on models, `db` for cache) **Layer 3 -- Depends on Layer 2:** -- `src/context_pulse/engine/anomaly.py` (depends on `engine/baseline.py`, `engine/classifier.py`, `db/anomalies.py`) +- `src/context_analyzer_tool/engine/anomaly.py` (depends on `engine/baseline.py`, `engine/classifier.py`, `db/anomalies.py`) **Layer 4 -- Depends on Layer 3:** - Modifications to `delta_engine.py` (depends on `engine/anomaly.py`) @@ -1498,7 +1498,7 @@ import aiosqlite import pytest import pytest_asyncio -from context_pulse.engine.baseline import BaselineManager, RollingWelford +from context_analyzer_tool.engine.baseline import BaselineManager, RollingWelford # ------------------------------------------------------------------- @@ -1623,7 +1623,7 @@ class TestBaselineManager: for v in [100, 200, 300]: await mgr.record_delta("Read", float(v)) # After 3 samples (= update_interval), should be in DB - from context_pulse.db import baselines as db_baselines + from context_analyzer_tool.db import baselines as db_baselines row = await db_baselines.get_baseline(db_connection, "Read") assert row is not None assert row["sample_count"] == 3 @@ -1652,7 +1652,7 @@ class TestBaselineManager: await mgr.record_delta("Bash", 1000.0) await mgr.record_delta("Read", 500.0) await mgr.flush_all() - from context_pulse.db import baselines as db_baselines + from context_analyzer_tool.db import baselines as db_baselines bash_row = await db_baselines.get_baseline(db_connection, "Bash") read_row = await db_baselines.get_baseline(db_connection, "Read") assert bash_row is not None @@ -1672,10 +1672,10 @@ from unittest.mock import AsyncMock, patch import aiosqlite -from context_pulse.collector.models import AnomalyResult -from context_pulse.config import AnomalyConfig, ClassifierConfig -from context_pulse.engine.anomaly import MIN_STDDEV, detect_anomaly -from context_pulse.engine.baseline import BaselineManager +from context_analyzer_tool.collector.models import AnomalyResult +from context_analyzer_tool.config import AnomalyConfig, ClassifierConfig +from context_analyzer_tool.engine.anomaly import MIN_STDDEV, detect_anomaly +from context_analyzer_tool.engine.baseline import BaselineManager class TestDetectAnomaly: @@ -1936,9 +1936,9 @@ from unittest.mock import AsyncMock, MagicMock, patch import aiosqlite -from context_pulse.collector.models import ClassifierResponse -from context_pulse.config import ClassifierConfig -from context_pulse.engine.classifier import ( +from context_analyzer_tool.collector.models import ClassifierResponse +from context_analyzer_tool.config import ClassifierConfig +from context_analyzer_tool.engine.classifier import ( _build_user_prompt, _compute_cache_key, _parse_classifier_output, @@ -2066,7 +2066,7 @@ class TestClassifyAnomaly: mock_client.messages.create = AsyncMock(return_value=mock_response) with patch( - "context_pulse.engine.classifier.anthropic" + "context_analyzer_tool.engine.classifier.anthropic" ) as mock_module: mock_module.AsyncAnthropic.return_value = mock_client mock_module.RateLimitError = Exception @@ -2129,7 +2129,7 @@ class TestClassifyAnomaly: cfg = ClassifierConfig(enabled=True, cache_results=False) with patch( - "context_pulse.engine.classifier.anthropic" + "context_analyzer_tool.engine.classifier.anthropic" ) as mock_module: rate_limit_error = type("RateLimitError", (Exception,), {}) mock_module.RateLimitError = rate_limit_error @@ -2162,7 +2162,7 @@ The existing `tests/conftest.py` provides `db_connection` (in-memory SQLite with ### 11.5 Mock Strategy for Anthropic SDK -The `anthropic` module is imported inside `classify_anomaly()` at call time (not at module level). Tests mock it via `unittest.mock.patch("context_pulse.engine.classifier.anthropic")`. The mock must provide: +The `anthropic` module is imported inside `classify_anomaly()` at call time (not at module level). Tests mock it via `unittest.mock.patch("context_analyzer_tool.engine.classifier.anthropic")`. The mock must provide: - `AsyncAnthropic()` returning a mock client - `client.messages.create()` as an `AsyncMock` returning a mock message @@ -2174,21 +2174,21 @@ The `anthropic` module is imported inside `classify_anomaly()` at call time (not ## 12. Summary of All File Changes ### New files: -1. `src/context_pulse/engine/__init__.py` -- empty -2. `src/context_pulse/engine/baseline.py` -- `RollingWelford`, `BaselineManager` -3. `src/context_pulse/engine/anomaly.py` -- `detect_anomaly()`, `MIN_STDDEV` -4. `src/context_pulse/engine/classifier.py` -- `classify_anomaly()`, cache helpers, prompt constants -5. `src/context_pulse/db/baselines.py` -- `get_baseline()`, `upsert_baseline()`, `get_all_baselines()` -6. `src/context_pulse/db/anomalies.py` -- `insert_anomaly()`, `update_anomaly_classification()`, `check_cooldown()`, `get_recent_anomalies()`, `get_anomaly_count()` +1. `src/context_analyzer_tool/engine/__init__.py` -- empty +2. `src/context_analyzer_tool/engine/baseline.py` -- `RollingWelford`, `BaselineManager` +3. `src/context_analyzer_tool/engine/anomaly.py` -- `detect_anomaly()`, `MIN_STDDEV` +4. `src/context_analyzer_tool/engine/classifier.py` -- `classify_anomaly()`, cache helpers, prompt constants +5. `src/context_analyzer_tool/db/baselines.py` -- `get_baseline()`, `upsert_baseline()`, `get_all_baselines()` +6. `src/context_analyzer_tool/db/anomalies.py` -- `insert_anomaly()`, `update_anomaly_classification()`, `check_cooldown()`, `get_recent_anomalies()`, `get_anomaly_count()` 7. `tests/test_baseline.py` 8. `tests/test_anomaly.py` 9. `tests/test_classifier.py` ### Modified files: -1. `src/context_pulse/db/schema.py` -- add v2 migration (classifier_cache table + baselines columns) -2. `src/context_pulse/collector/models.py` -- add `BaselineSnapshot`, `AnomalyResult`, `ClassifierResponse`, `AnomalyResponse`, `AnomaliesListResponse` -3. `src/context_pulse/collector/delta_engine.py` -- add `tool_input_summary` field to `PendingToolCall`, add `process_anomalies()` function -4. `src/context_pulse/collector/routes.py` -- add `get_baseline_manager` dependency, modify `receive_statusline_snapshot` to call `process_anomalies()`, add `GET /api/anomalies` and `GET /api/baselines` endpoints -5. `src/context_pulse/collector/server.py` -- create `BaselineManager` in lifespan, store on `app.state`, flush on shutdown -6. `src/context_pulse/cli.py` -- add `anomalies` command +1. `src/context_analyzer_tool/db/schema.py` -- add v2 migration (classifier_cache table + baselines columns) +2. `src/context_analyzer_tool/collector/models.py` -- add `BaselineSnapshot`, `AnomalyResult`, `ClassifierResponse`, `AnomalyResponse`, `AnomaliesListResponse` +3. `src/context_analyzer_tool/collector/delta_engine.py` -- add `tool_input_summary` field to `PendingToolCall`, add `process_anomalies()` function +4. `src/context_analyzer_tool/collector/routes.py` -- add `get_baseline_manager` dependency, modify `receive_statusline_snapshot` to call `process_anomalies()`, add `GET /api/anomalies` and `GET /api/baselines` endpoints +5. `src/context_analyzer_tool/collector/server.py` -- create `BaselineManager` in lifespan, store on `app.state`, flush on shutdown +6. `src/context_analyzer_tool/cli.py` -- add `anomalies` command 7. `pyproject.toml` -- add `anthropic>=0.43.0` to dependencies diff --git a/hooks/_hook_config.py b/hooks/_hook_config.py index c911363..18cb1f3 100644 --- a/hooks/_hook_config.py +++ b/hooks/_hook_config.py @@ -1,4 +1,4 @@ -"""Shared config reader for context-pulse hook scripts. +"""Shared config reader for context-analyzer-tool hook scripts. Uses only stdlib (tomllib, pathlib, os) — no external dependencies. Hook scripts import from this module to get the collector URL, timeout, @@ -18,7 +18,7 @@ def _load_config() -> dict: if _CONFIG_CACHE is not None: return _CONFIG_CACHE config_dir = os.environ.get( - "CONTEXT_PULSE_CONFIG_DIR", str(Path.home() / ".context-pulse") + "CAT_CONFIG_DIR", str(Path.home() / ".context-analyzer-tool") ) config_path = Path(config_dir) / "config.toml" if config_path.exists(): diff --git a/hooks/post_tool_use.py b/hooks/post_tool_use.py index 2cbbfd5..daf90e0 100644 --- a/hooks/post_tool_use.py +++ b/hooks/post_tool_use.py @@ -4,7 +4,7 @@ # /// """ -context-pulse hook: PostToolUse +context-analyzer-tool hook: PostToolUse Reads hook payload from stdin, POSTs to collector, exits 0. """ @@ -91,7 +91,7 @@ def get_session_alert(session_id: str) -> str: pass parts = [ - f"[context-pulse] \u26a0 Last {task_type} command cost " + f"[CAT] \u26a0 Last {task_type} command cost " f"{delta:,} tokens ({z:.1f}\u03c3 above baseline of ~{mean:,.0f})." ] if cause: @@ -125,7 +125,7 @@ def main() -> None: tool = envelope.get("tool_name", "Tool") tokens_k = response_tokens / 1000 context_parts.append( - f"[context-pulse] That {tool} output consumed ~{tokens_k:.0f}K tokens. " + f"[CAT] That {tool} output consumed ~{tokens_k:.0f}K tokens. " "Consider piping through head/tail or using rtk." ) diff --git a/hooks/statusline.py b/hooks/statusline.py index 077ca11..6e21485 100644 --- a/hooks/statusline.py +++ b/hooks/statusline.py @@ -4,7 +4,7 @@ # /// """ -context-pulse statusline script. +context-analyzer-tool statusline script. 1. Reads statusline JSON from stdin (provided by Claude Code). 2. POSTs token snapshot to collector (fire-and-forget, 2s timeout). 3. Prints a formatted statusline string to stdout. @@ -20,7 +20,7 @@ COLLECTOR_URL = get_collector_url("/hook/statusline") TIMEOUT_SECONDS = get_timeout() -# Must match context_pulse.engine.context_breakdown.FRESH_SESSION_COST +# Must match context_analyzer_tool.engine.context_breakdown.FRESH_SESSION_COST _FRESH_SESSION_COST = 13_700 @@ -140,7 +140,7 @@ def main() -> None: print(format_statusline(data)) except Exception: # On any error, output a safe default - print("context-pulse | --") + print("context-analyzer-tool | --") sys.exit(0) diff --git a/hooks/stop.py b/hooks/stop.py index b887f30..ad96b22 100644 --- a/hooks/stop.py +++ b/hooks/stop.py @@ -4,7 +4,7 @@ # /// """ -context-pulse hook: Stop +context-analyzer-tool hook: Stop Reads hook payload from stdin, POSTs to collector, exits 0. """ diff --git a/hooks/subagent_stop.py b/hooks/subagent_stop.py index 1daf764..e27a3c5 100644 --- a/hooks/subagent_stop.py +++ b/hooks/subagent_stop.py @@ -4,7 +4,7 @@ # /// """ -context-pulse hook: SubagentStop +context-analyzer-tool hook: SubagentStop Reads hook payload from stdin, POSTs to collector, exits 0. """ diff --git a/hooks/user_prompt_submit.py b/hooks/user_prompt_submit.py index b4b65b4..1558846 100644 --- a/hooks/user_prompt_submit.py +++ b/hooks/user_prompt_submit.py @@ -4,7 +4,7 @@ # /// """ -context-pulse hook: UserPromptSubmit +context-analyzer-tool hook: UserPromptSubmit Reads hook payload from stdin, POSTs to collector, exits 0. """ diff --git a/pyproject.toml b/pyproject.toml index 933647c..c0e7439 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] -name = "context-pulse" -version = "0.2.0" +name = "context-analyzer-tool" +version = "0.3.0" description = "Per-tool-call context window analyzer for Claude Code" readme = "README.md" requires-python = ">=3.11" @@ -19,14 +19,14 @@ dependencies = [ classifier = ["anthropic>=0.43.0"] [project.scripts] -context-pulse = "context_pulse.cli:app" +context-analyzer-tool = "context_analyzer_tool.cli:app" [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["src/context_pulse"] +packages = ["src/context_analyzer_tool"] [tool.ruff] src = ["src"] diff --git a/src/context_analyzer_tool/__init__.py b/src/context_analyzer_tool/__init__.py new file mode 100644 index 0000000..dc29221 --- /dev/null +++ b/src/context_analyzer_tool/__init__.py @@ -0,0 +1,3 @@ +"""context-analyzer-tool: Per-tool-call context window analyzer for Claude Code.""" + +__version__ = "0.2.0" diff --git a/src/context_pulse/cli.py b/src/context_analyzer_tool/cli.py similarity index 91% rename from src/context_pulse/cli.py rename to src/context_analyzer_tool/cli.py index c04b455..2aa6d7b 100644 --- a/src/context_pulse/cli.py +++ b/src/context_analyzer_tool/cli.py @@ -1,4 +1,4 @@ -"""CLI entry point for context-pulse, built with Typer.""" +"""CLI entry point for context-analyzer-tool, built with Typer.""" from __future__ import annotations @@ -15,19 +15,19 @@ from rich.panel import Panel from rich.table import Table -from context_pulse.config import ( +from context_analyzer_tool.config import ( get_config_dir, get_config_path, load_config, write_default_config, ) -logger = logging.getLogger("context_pulse.cli") +logger = logging.getLogger("context_analyzer_tool.cli") console = Console() app = typer.Typer( - name="context-pulse", + name="context-analyzer-tool", help="Per-tool-call context window analyzer for Claude Code", ) @@ -70,7 +70,7 @@ def _collector_base_url(port: int | None) -> str: "post_compact.py": "PostCompact", } -_CONTEXT_PULSE_MARKER = "context-pulse" +_CAT_MARKER = "context-analyzer-tool" # --------------------------------------------------------------------------- @@ -84,7 +84,7 @@ def _find_hooks_source_dir() -> Path: We look relative to this file first (development layout), then try importlib.resources for installed packages. """ - # Development layout: src/context_pulse/cli.py -> ../../hooks/ + # Development layout: src/context_analyzer_tool/cli.py -> ../../hooks/ dev_hooks = Path(__file__).resolve().parent.parent.parent / "hooks" if dev_hooks.is_dir(): return dev_hooks @@ -94,7 +94,7 @@ def _find_hooks_source_dir() -> Path: import importlib.resources as ir # Python 3.12+ files() API - ref = ir.files("context_pulse").joinpath("hooks") + ref = ir.files("context_analyzer_tool").joinpath("hooks") if hasattr(ref, "_path"): p = Path(str(ref._path)) # type: ignore[attr-defined] if p.is_dir(): @@ -136,7 +136,7 @@ def _copy_hook_scripts(hooks_dir: Path) -> list[str]: def _build_hooks_config( hooks_dir: Path, use_http: bool = False, ) -> dict[str, list[dict[str, Any]]]: - """Build the hooks portion of settings.json for context-pulse.""" + """Build the hooks portion of settings.json for context-analyzer-tool.""" hooks_config: dict[str, list[dict[str, Any]]] = {} for script_name, event_name in _HOOK_EVENT_MAP.items(): script_path = hooks_dir / script_name @@ -176,11 +176,11 @@ def _build_statusline_config(hooks_dir: Path) -> dict[str, str]: } -def _is_context_pulse_hook(hook_entry: dict[str, Any]) -> bool: - """Check if a hook entry belongs to context-pulse.""" +def _is_context_analyzer_tool_hook(hook_entry: dict[str, Any]) -> bool: + """Check if a hook entry belongs to context-analyzer-tool.""" command: str = hook_entry.get("command", "") url: str = hook_entry.get("url", "") - return _CONTEXT_PULSE_MARKER in command or _CONTEXT_PULSE_MARKER in url + return _CAT_MARKER in command or _CAT_MARKER in url def _merge_settings( @@ -188,9 +188,9 @@ def _merge_settings( hooks_dir: Path, use_http: bool = False, ) -> dict[str, Any]: - """Merge context-pulse hooks into existing settings.json content. + """Merge context-analyzer-tool hooks into existing settings.json content. - Preserves existing non-context-pulse hooks for each event type. + Preserves existing non-context-analyzer-tool hooks for each event type. """ settings: dict[str, Any] = dict(existing) @@ -201,11 +201,11 @@ def _merge_settings( for event_name, new_entries in new_hooks_config.items(): existing_entries: list[dict[str, Any]] = current_hooks.get(event_name, []) - # Remove any existing context-pulse entries for this event + # Remove any existing context-analyzer-tool entries for this event cleaned: list[dict[str, Any]] = [] for entry in existing_entries: inner_hooks: list[dict[str, Any]] = entry.get("hooks", []) - non_cp = [h for h in inner_hooks if not _is_context_pulse_hook(h)] + non_cp = [h for h in inner_hooks if not _is_context_analyzer_tool_hook(h)] if non_cp: cleaned.append({**entry, "hooks": non_cp}) # Append our new entries @@ -218,16 +218,16 @@ def _merge_settings( new_statusline: dict[str, str] = _build_statusline_config(hooks_dir) existing_sl: dict[str, Any] = settings.get("statusLine", {}) - # Only overwrite if it's empty or already belongs to context-pulse. - # Respect existing non-context-pulse statusLines. + # Only overwrite if it's empty or already belongs to context-analyzer-tool. + # Respect existing non-context-analyzer-tool statusLines. sl_cmd: str = existing_sl.get("command", "") - if not existing_sl or _CONTEXT_PULSE_MARKER in sl_cmd: + if not existing_sl or _CAT_MARKER in sl_cmd: settings["statusLine"] = new_statusline else: logger.warning( - "Existing statusLine found that does not belong to context-pulse. " + "Existing statusLine found that does not belong to context-analyzer-tool. " "Keeping existing statusLine. Remove the existing statusLine entry " - "manually to use context-pulse statusline." + "manually to use context-analyzer-tool statusline." ) return settings @@ -268,15 +268,15 @@ def serve( ), log_level: str = typer.Option("info", help="Log level"), ) -> None: - """Start the context-pulse collector server.""" + """Start the context-analyzer-tool collector server.""" try: import uvicorn - from context_pulse.collector.server import create_app + from context_analyzer_tool.collector.server import create_app except ImportError as exc: console.print( f"[red]Missing dependency:[/red] {exc}. " - "Install with: [bold]pip install context-pulse[/bold]" + "Install with: [bold]pip install context-analyzer-tool[/bold]" ) raise typer.Exit(1) from None @@ -305,7 +305,7 @@ def serve( if "address already in use" in exc_lower or "error while attempting to bind" in exc_lower: console.print( f"[red]Port {actual_port} is already in use.[/red] " - "Is another context-pulse instance running? " + "Is another context-analyzer-tool instance running? " f"Use [bold]--port[/bold] to specify a different port." ) else: @@ -331,7 +331,7 @@ def status( except httpx.ConnectError: console.print( "[red]Cannot connect to collector.[/red] " - "Is it running? Start with: [bold]context-pulse serve[/bold]" + "Is it running? Start with: [bold]context-analyzer-tool serve[/bold]" ) raise typer.Exit(1) from None except httpx.HTTPStatusError as exc: @@ -418,7 +418,7 @@ def health( except httpx.ConnectError: console.print( "[red]Cannot connect to collector.[/red] " - "Is it running? Start with: [bold]context-pulse serve[/bold]" + "Is it running? Start with: [bold]context-analyzer-tool serve[/bold]" ) raise typer.Exit(1) from None except httpx.HTTPError as exc: @@ -493,7 +493,7 @@ def install( raise typer.Exit(1) from None -# Alias: `context-pulse init` does the same as `context-pulse install` +# Alias: `context-analyzer-tool init` does the same as `context-analyzer-tool install` app.command(name="init", hidden=True)(install) @@ -508,10 +508,10 @@ def uninstall( remove_hooks: bool = typer.Option( False, "--remove-hooks", - help="Also delete the hook scripts from ~/.context-pulse/hooks/", + help="Also delete the hook scripts from ~/.context-analyzer-tool/hooks/", ), ) -> None: - """Remove context-pulse hooks and statusline from Claude Code settings.""" + """Remove context-analyzer-tool hooks and statusline from Claude Code settings.""" if claude_settings is None: # pyright: ignore[reportUnnecessaryComparison] claude_settings = _default_claude_settings() try: @@ -549,7 +549,7 @@ def _run_uninstall( console.print(f"[red]Could not parse settings.json:[/red] {exc}") raise typer.Exit(1) from None - # 2. Filter out context-pulse hook entries + # 2. Filter out context-analyzer-tool hook entries current_hooks = cast(dict[str, Any], settings.get("hooks", {})) hooks_removed = 0 cleaned_hooks: dict[str, Any] = {} @@ -562,7 +562,7 @@ def _run_uninstall( cleaned_entries: list[dict[str, Any]] = [] for entry in typed_entries: inner_hooks = cast(list[dict[str, Any]], entry.get("hooks", [])) - non_cp = [h for h in inner_hooks if not _is_context_pulse_hook(h)] + non_cp = [h for h in inner_hooks if not _is_context_analyzer_tool_hook(h)] removed_count = len(inner_hooks) - len(non_cp) hooks_removed += removed_count if non_cp: @@ -575,11 +575,11 @@ def _run_uninstall( elif "hooks" in settings: del settings["hooks"] - # 3. Remove statusLine if it belongs to context-pulse + # 3. Remove statusLine if it belongs to context-analyzer-tool statusline_removed = False existing_sl: dict[str, Any] = settings.get("statusLine", {}) sl_cmd: str = existing_sl.get("command", "") - if existing_sl and _CONTEXT_PULSE_MARKER in sl_cmd: + if existing_sl and _CAT_MARKER in sl_cmd: del settings["statusLine"] statusline_removed = True @@ -612,7 +612,7 @@ def _run_uninstall( f"StatusLine removed: {'yes' if statusline_removed else 'no'}\n" f"Hook scripts deleted: {'yes' if hooks_deleted else 'no'}\n" f"Settings: {claude_settings}", - title="context-pulse", + title="context-analyzer-tool", border_style="green", ) ) @@ -628,7 +628,7 @@ def _run_install( if not use_http and not shutil.which("uv"): console.print( "[red]'uv' not found on PATH.[/red] " - "context-pulse hooks require uv to run. " + "context-analyzer-tool hooks require uv to run. " "Install it from [bold]https://docs.astral.sh/uv/[/bold] " "or use [bold]--use-http[/bold] to skip command hooks." ) @@ -691,7 +691,7 @@ def _run_install( except Exception: console.print( " [yellow]Collector is not running.[/yellow] " - "Start it with: [bold]context-pulse serve[/bold]" + "Start it with: [bold]context-analyzer-tool serve[/bold]" ) # 8. Summary @@ -705,16 +705,16 @@ def _run_install( f"Config: {config_path}\n" f"Settings: {claude_settings}\n\n" "Next steps:\n" - " 1. Start the collector: [bold]context-pulse serve[/bold]\n" - " 2. Check health: [bold]context-pulse health[/bold]\n" - " 3. View status: [bold]context-pulse status[/bold]", - title="context-pulse", + " 1. Start the collector: [bold]context-analyzer-tool serve[/bold]\n" + " 2. Check health: [bold]context-analyzer-tool health[/bold]\n" + " 3. View status: [bold]context-analyzer-tool status[/bold]", + title="context-analyzer-tool", border_style="green", ) ) # 9. Check and offer RTK integration - from context_pulse.rtk_integration import ( + from context_analyzer_tool.rtk_integration import ( install_rtk_hooks, is_rtk_hooks_installed, is_rtk_installed, @@ -762,7 +762,7 @@ def anomalies( except httpx.ConnectError: console.print( "[red]Cannot connect to collector.[/red] " - "Is it running? Start with: [bold]context-pulse serve[/bold]" + "Is it running? Start with: [bold]context-analyzer-tool serve[/bold]" ) raise typer.Exit(1) from None except httpx.HTTPError as exc: @@ -832,10 +832,10 @@ def dashboard( refresh: float = typer.Option(2.0, help="Refresh interval in seconds"), ) -> None: """Launch the live TUI dashboard (Ctrl+C to exit).""" - from context_pulse.dashboard.tui import run_dashboard + from context_analyzer_tool.dashboard.tui import run_dashboard console.print( - f"[bold]Starting context-pulse dashboard[/bold] " + f"[bold]Starting context-analyzer-tool dashboard[/bold] " f"(collector: 127.0.0.1:{port}, refresh: {refresh}s)" ) console.print("[dim]Press Ctrl+C to stop.[/dim]\n") @@ -847,7 +847,7 @@ def rtk_status( port: int | None = typer.Option(None, help="Collector port (default: from config)"), ) -> None: """Show RTK (Rust Token Killer) integration status and savings.""" - from context_pulse.rtk_integration import ( + from context_analyzer_tool.rtk_integration import ( get_rtk_savings_summary, get_rtk_version, is_rtk_hooks_installed, @@ -903,7 +903,7 @@ def context_cost( """ import httpx - from context_pulse.engine.context_breakdown import ( + from context_analyzer_tool.engine.context_breakdown import ( compute_breakdown, format_breakdown_table, ) @@ -918,7 +918,7 @@ def context_cost( except httpx.ConnectError: console.print( "[red]Cannot connect to collector.[/red] " - "Start with: [bold]context-pulse serve[/bold]" + "Start with: [bold]context-analyzer-tool serve[/bold]" ) raise typer.Exit(1) from None @@ -1023,7 +1023,7 @@ def clear( except (httpx.ConnectError, httpx.ConnectTimeout): pass # Good — collector is stopped - from context_pulse.config import get_db_path, load_config + from context_analyzer_tool.config import get_db_path, load_config cfg = load_config() db_path = Path(get_db_path(cfg)) @@ -1047,7 +1047,7 @@ def clear( db_path.unlink() console.print( f"[green]Cleared.[/green] Deleted {db_path} ({size_kb} KB)\n" - "Start the collector again: [bold]context-pulse serve[/bold]" + "Start the collector again: [bold]context-analyzer-tool serve[/bold]" ) @@ -1062,9 +1062,9 @@ def prune( """ import asyncio - from context_pulse.config import get_db_path - from context_pulse.db.maintenance import get_table_counts, prune_old_data - from context_pulse.db.schema import open_db + from context_analyzer_tool.config import get_db_path + from context_analyzer_tool.db.maintenance import get_table_counts, prune_old_data + from context_analyzer_tool.db.schema import open_db cfg = load_config() retention = days if days is not None else cfg.retention.retention_days diff --git a/src/context_pulse/collector/__init__.py b/src/context_analyzer_tool/collector/__init__.py similarity index 100% rename from src/context_pulse/collector/__init__.py rename to src/context_analyzer_tool/collector/__init__.py diff --git a/src/context_pulse/collector/delta_engine.py b/src/context_analyzer_tool/collector/delta_engine.py similarity index 95% rename from src/context_pulse/collector/delta_engine.py rename to src/context_analyzer_tool/collector/delta_engine.py index 442f9e7..7fb22d6 100644 --- a/src/context_pulse/collector/delta_engine.py +++ b/src/context_analyzer_tool/collector/delta_engine.py @@ -11,11 +11,11 @@ import aiosqlite -from context_pulse.collector.models import HookEventRequest, StatuslineSnapshotRequest -from context_pulse.db import events as db_events -from context_pulse.db import tasks as db_tasks +from context_analyzer_tool.collector.models import HookEventRequest, StatuslineSnapshotRequest +from context_analyzer_tool.db import events as db_events +from context_analyzer_tool.db import tasks as db_tasks -logger = logging.getLogger("context_pulse.delta_engine") +logger = logging.getLogger("context_analyzer_tool.delta_engine") @dataclass @@ -256,7 +256,7 @@ async def on_snapshot( if cache_miss_detected: try: - from context_pulse.db import messages as db_messages + from context_analyzer_tool.db import messages as db_messages dedup_key = "CACHE_MISS_WARNING" already_sent = await db_messages.has_message_like( @@ -265,7 +265,7 @@ async def on_snapshot( if not already_sent: msg = ( f"\n" - f"[context-pulse] Cache expired -- context was rebuilt " + f"[CAT] Cache expired -- context was rebuilt " f"(~{curr_cache_creation:,} cache_creation tokens). " "This is normal after ~5 minutes of inactivity. " "Frequent cache rebuilds increase token costs." @@ -466,10 +466,10 @@ async def process_anomalies( Returns a list of ``AnomalyResult`` objects (may be empty). """ - from context_pulse.collector.models import AnomalyResult - from context_pulse.config import AnomalyConfig, ClassifierConfig, NotificationsConfig - from context_pulse.engine.anomaly import detect_anomaly - from context_pulse.engine.baseline import BaselineManager + from context_analyzer_tool.collector.models import AnomalyResult + from context_analyzer_tool.config import AnomalyConfig, ClassifierConfig, NotificationsConfig + from context_analyzer_tool.engine.anomaly import detect_anomaly + from context_analyzer_tool.engine.baseline import BaselineManager if not isinstance(baseline_manager, BaselineManager): return [] @@ -512,7 +512,7 @@ async def process_anomalies( # Dispatch notifications for this anomaly if isinstance(notifications_config, NotificationsConfig): try: - from context_pulse.notify.dispatcher import ( + from context_analyzer_tool.notify.dispatcher import ( dispatch_anomaly_notifications, ) @@ -520,7 +520,7 @@ async def process_anomalies( cause: str | None = None severity: str | None = None suggestion: str | None = None - from context_pulse.db import anomalies as db_anomalies_mod + from context_analyzer_tool.db import anomalies as db_anomalies_mod rows = await db_anomalies_mod.get_recent_anomalies( db, limit=1, session_id=session_id, diff --git a/src/context_pulse/collector/models.py b/src/context_analyzer_tool/collector/models.py similarity index 100% rename from src/context_pulse/collector/models.py rename to src/context_analyzer_tool/collector/models.py diff --git a/src/context_pulse/collector/routes.py b/src/context_analyzer_tool/collector/routes.py similarity index 96% rename from src/context_pulse/collector/routes.py rename to src/context_analyzer_tool/collector/routes.py index 2d30241..de2fbe9 100644 --- a/src/context_pulse/collector/routes.py +++ b/src/context_analyzer_tool/collector/routes.py @@ -1,4 +1,4 @@ -"""All HTTP route handlers for the context-pulse collector.""" +"""All HTTP route handlers for the context-analyzer-tool collector.""" from __future__ import annotations @@ -12,8 +12,8 @@ import aiosqlite from fastapi import APIRouter, Depends, Request -from context_pulse.collector import delta_engine -from context_pulse.collector.models import ( +from context_analyzer_tool.collector import delta_engine +from context_analyzer_tool.collector.models import ( AnomaliesListResponse, AnomalyResponse, BaselineSnapshot, @@ -26,10 +26,10 @@ StatusResponse, TaskResponse, ) -from context_pulse.db import anomalies as db_anomalies -from context_pulse.db import baselines as db_baselines -from context_pulse.db import events as db_events -from context_pulse.db import tasks as db_tasks +from context_analyzer_tool.db import anomalies as db_anomalies +from context_analyzer_tool.db import baselines as db_baselines +from context_analyzer_tool.db import events as db_events +from context_analyzer_tool.db import tasks as db_tasks logger = logging.getLogger(__name__) @@ -219,7 +219,7 @@ async def receive_statusline_snapshot( # Check context thresholds and queue warnings try: - from context_pulse.notify.context_warnings import check_context_thresholds + from context_analyzer_tool.notify.context_warnings import check_context_thresholds await check_context_thresholds( db=db, @@ -304,7 +304,7 @@ async def get_status( ) -> StatusResponse: """Return active sessions, last 20 events, and last 20 tasks. - Used by ``context-pulse status`` CLI command. + Used by ``context-analyzer-tool status`` CLI command. """ # Active sessions: those with events in the last 5 minutes. active_window_ms = 5 * 60 * 1000 @@ -411,7 +411,7 @@ async def get_status( # Burn rate projection burn_rate: dict[str, Any] | None = None try: - from context_pulse.engine.burn_rate import compute_burn_rate + from context_analyzer_tool.engine.burn_rate import compute_burn_rate snap_rows = await db_events.get_recent_snapshots( db, session_id=sid, limit=20, @@ -638,7 +638,7 @@ async def get_anomalies( @api_router.get("/rtk-status") async def get_rtk_status() -> dict[str, Any]: """Return RTK integration status and savings.""" - from context_pulse.rtk_integration import ( + from context_analyzer_tool.rtk_integration import ( get_rtk_savings_summary, get_rtk_version, is_rtk_hooks_installed, @@ -736,7 +736,7 @@ async def get_pending_messages( to inject additionalContext. """ try: - from context_pulse.db import messages as db_messages + from context_analyzer_tool.db import messages as db_messages msgs = await db_messages.consume_messages(db, session_id) return {"messages": msgs} @@ -754,7 +754,7 @@ async def get_context_breakdown( Shows fixed costs vs. conversation history overhead. """ - from context_pulse.engine.context_breakdown import compute_breakdown + from context_analyzer_tool.engine.context_breakdown import compute_breakdown snap = await db_events.get_latest_snapshot(db, session_id) if snap is None: @@ -777,7 +777,7 @@ async def get_burn_rate( db: aiosqlite.Connection = Depends(get_db), ) -> dict[str, Any]: """Return burn rate projection for a session.""" - from context_pulse.engine.burn_rate import compute_burn_rate + from context_analyzer_tool.engine.burn_rate import compute_burn_rate snap_rows = await db_events.get_recent_snapshots( db, session_id=session_id, limit=lookback, @@ -800,7 +800,7 @@ async def get_compactions( db: aiosqlite.Connection = Depends(get_db), ) -> dict[str, Any]: """Return recent compaction events.""" - from context_pulse.db import compaction as db_compaction + from context_analyzer_tool.db import compaction as db_compaction rows = await db_compaction.get_recent_compactions( db, session_id=session_id, limit=limit, diff --git a/src/context_pulse/collector/server.py b/src/context_analyzer_tool/collector/server.py similarity index 77% rename from src/context_pulse/collector/server.py rename to src/context_analyzer_tool/collector/server.py index 2420ed8..09fde79 100644 --- a/src/context_pulse/collector/server.py +++ b/src/context_analyzer_tool/collector/server.py @@ -1,4 +1,4 @@ -"""FastAPI application factory for the context-pulse collector.""" +"""FastAPI application factory for the context-analyzer-tool collector.""" from __future__ import annotations @@ -10,12 +10,12 @@ from fastapi import FastAPI -from context_pulse.collector.delta_engine import SessionState, restore_sessions_from_db -from context_pulse.collector.routes import api_router, hook_router -from context_pulse.config import ensure_config_dir, get_db_path, load_config -from context_pulse.db.maintenance import prune_old_data -from context_pulse.db.schema import open_db, run_migrations -from context_pulse.engine.baseline import BaselineManager +from context_analyzer_tool.collector.delta_engine import SessionState, restore_sessions_from_db +from context_analyzer_tool.collector.routes import api_router, hook_router +from context_analyzer_tool.config import ensure_config_dir, get_db_path, load_config +from context_analyzer_tool.db.maintenance import prune_old_data +from context_analyzer_tool.db.schema import open_db, run_migrations +from context_analyzer_tool.engine.baseline import BaselineManager logger = logging.getLogger(__name__) @@ -70,7 +70,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: app.state.start_time = time.time() logger.info( - "context-pulse collector started on %s:%d", + "context-analyzer-tool collector started on %s:%d", cfg.collector.host, cfg.collector.port, ) @@ -78,7 +78,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: yield # -- shutdown -------------------------------------------------------- - logger.info("Shutting down context-pulse collector") + logger.info("Shutting down context-analyzer-tool collector") await baseline_manager.flush_all() logger.info("Baselines flushed to database") await db.close() @@ -92,7 +92,7 @@ def create_app() -> FastAPI: A fully configured :class:`FastAPI` instance with hook and API routers included. """ - app = FastAPI(lifespan=lifespan, title="context-pulse") + app = FastAPI(lifespan=lifespan, title="context-analyzer-tool") app.include_router(hook_router, prefix="/hook") app.include_router(api_router, prefix="/api") return app diff --git a/src/context_pulse/config.py b/src/context_analyzer_tool/config.py similarity index 89% rename from src/context_pulse/config.py rename to src/context_analyzer_tool/config.py index 05da731..868538e 100644 --- a/src/context_pulse/config.py +++ b/src/context_analyzer_tool/config.py @@ -1,4 +1,4 @@ -"""TOML configuration loader for context-pulse.""" +"""TOML configuration loader for context-analyzer-tool.""" from __future__ import annotations @@ -10,15 +10,15 @@ from pydantic import BaseModel, Field, model_validator -logger = logging.getLogger("context_pulse.config") +logger = logging.getLogger("context_analyzer_tool.config") def get_config_dir() -> Path: - """Return the config directory, respecting ``CONTEXT_PULSE_CONFIG_DIR`` env var.""" - env_dir = os.environ.get("CONTEXT_PULSE_CONFIG_DIR") + """Return the config directory, respecting ``CAT_CONFIG_DIR`` env var.""" + env_dir = os.environ.get("CAT_CONFIG_DIR") if env_dir: return Path(env_dir).expanduser() - return Path.home() / ".context-pulse" + return Path.home() / ".context-analyzer-tool" def get_config_path() -> Path: @@ -34,7 +34,7 @@ def get_config_path() -> Path: class CollectorConfig(BaseModel): host: str = "127.0.0.1" port: int = 7821 - db_path: str = "~/.context-pulse/context_pulse.db" + db_path: str = "~/.context-analyzer-tool/context_analyzer_tool.db" class AnomalyConfig(BaseModel): @@ -88,10 +88,10 @@ class DashboardConfig(BaseModel): refresh_rate: float = 2.0 -_ENV_PREFIX = "CONTEXT_PULSE_" +_ENV_PREFIX = "CAT_" -class ContextPulseConfig(BaseModel): +class CATConfig(BaseModel): """Root configuration model. Maps 1:1 to config.toml sections.""" collector: CollectorConfig = Field(default_factory=CollectorConfig) @@ -104,11 +104,11 @@ class ContextPulseConfig(BaseModel): dashboard: DashboardConfig = Field(default_factory=DashboardConfig) @model_validator(mode="after") - def _apply_env_overrides(self) -> ContextPulseConfig: + def _apply_env_overrides(self) -> CATConfig: """Override fields from environment variables. - Pattern: ``CONTEXT_PULSE_{SECTION}_{KEY}`` (uppercase). - For example ``CONTEXT_PULSE_COLLECTOR_PORT=7822`` sets + Pattern: ``CAT_{SECTION}_{KEY}`` (uppercase). + For example ``CAT_COLLECTOR_PORT=7822`` sets ``collector.port`` to ``7822``. """ sections: dict[str, BaseModel] = { @@ -169,17 +169,17 @@ def _apply_env_overrides(self) -> ContextPulseConfig: # --------------------------------------------------------------------------- _DEFAULT_TOML_TEMPLATE = """\ -# context-pulse configuration -# Location: ~/.context-pulse/config.toml (or CONTEXT_PULSE_CONFIG_DIR) +# context-analyzer-tool configuration +# Location: ~/.context-analyzer-tool/config.toml (or CAT_CONFIG_DIR) # All values can be overridden via environment variables: -# CONTEXT_PULSE_{SECTION}_{KEY} (uppercase) +# CAT_{SECTION}_{KEY} (uppercase) [collector] # Host and port for the collector HTTP server host = "127.0.0.1" port = 7821 # Path to SQLite database (~ is expanded) -db_path = "~/.context-pulse/context_pulse.db" +db_path = "~/.context-analyzer-tool/context_analyzer_tool.db" [anomaly] # Z-score threshold for anomaly detection @@ -204,7 +204,7 @@ def _apply_env_overrides(self) -> ContextPulseConfig: cache_results = true [notifications] -# Show context-pulse data in Claude Code statusline +# Show context-analyzer-tool data in Claude Code statusline statusline = true # Fire OS-level notifications on anomalies system_notification = true @@ -245,11 +245,11 @@ def _apply_env_overrides(self) -> ContextPulseConfig: # --------------------------------------------------------------------------- -def load_config(config_path: Path | None = None) -> ContextPulseConfig: +def load_config(config_path: Path | None = None) -> CATConfig: """Load config from a TOML file. 1. If *config_path* is ``None``, use :func:`get_config_path`. - 2. If the file does not exist, return ``ContextPulseConfig()`` (all defaults). + 2. If the file does not exist, return ``CATConfig()`` (all defaults). 3. Parse TOML, validate with Pydantic. 4. Expand ``~`` in ``db_path``. @@ -261,7 +261,7 @@ def load_config(config_path: Path | None = None) -> ContextPulseConfig: if not path.exists(): logger.info("Config file not found at %s — using defaults.", path) - return ContextPulseConfig() + return CATConfig() logger.info("Loading config from %s", path) try: @@ -270,7 +270,7 @@ def load_config(config_path: Path | None = None) -> ContextPulseConfig: except tomllib.TOMLDecodeError as exc: raise ValueError(f"Malformed TOML in {path}: {exc}") from exc - return ContextPulseConfig.model_validate(data) + return CATConfig.model_validate(data) def ensure_config_dir() -> Path: @@ -285,7 +285,7 @@ def ensure_config_dir() -> Path: return config_dir -def get_db_path(config: ContextPulseConfig) -> str: +def get_db_path(config: CATConfig) -> str: """Resolve *db_path* from *config*, expanding ``~``. Returns: diff --git a/src/context_pulse/dashboard/__init__.py b/src/context_analyzer_tool/dashboard/__init__.py similarity index 100% rename from src/context_pulse/dashboard/__init__.py rename to src/context_analyzer_tool/dashboard/__init__.py diff --git a/src/context_analyzer_tool/dashboard/tui.py b/src/context_analyzer_tool/dashboard/tui.py new file mode 100644 index 0000000..1eda17b --- /dev/null +++ b/src/context_analyzer_tool/dashboard/tui.py @@ -0,0 +1,756 @@ +"""Rich Live-based terminal dashboard for context-analyzer-tool. + +Auto-refreshes every 2 seconds by polling the collector HTTP API. +Displays session overview, task cost timeline, and anomaly feed. +""" + +from __future__ import annotations + +import logging +import time +from datetime import UTC, datetime +from typing import Any + +import httpx +from rich.box import DOUBLE, HEAVY, ROUNDED, SIMPLE_HEAVY +from rich.console import Console +from rich.layout import Layout +from rich.live import Live +from rich.panel import Panel +from rich.table import Table +from rich.text import Text + +logger = logging.getLogger("context_analyzer_tool.dashboard.tui") + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_BAR_MAX_WIDTH = 30 +_SESSION_ID_TRUNCATE = 8 +_CAUSE_TRUNCATE = 40 +_DEFAULT_PORT = None # resolved from config at runtime + +# --------------------------------------------------------------------------- +# Theme +# --------------------------------------------------------------------------- + +_ACCENT = "bright_cyan" +_ACCENT_DIM = "cyan" +_TITLE_STYLE = "bold bright_white" +_HEADER_STYLE = "bold bright_cyan" +_BORDER_SESSIONS = "bright_cyan" +_BORDER_TASKS = "bright_magenta" +_BORDER_ANOMALY_QUIET = "bright_green" +_BORDER_ANOMALY_ALERT = "bright_red" +_BORDER_RTK = "bright_cyan" +_DIM = "grey50" + +# --------------------------------------------------------------------------- +# Sleeping cat animation +# --------------------------------------------------------------------------- + +# Each frame is a list of (line_string, style_key) tuples. +# "z" lines float above the cat's head (left side); body stays fixed. +# Frame 4 is a brief wake-up before drifting back to sleep. +_CAT_FRAMES = [ + [ + (" z z", "z"), + (" z", "z"), + (" |\\ _,,,---,,_", "cat"), + (" /,`.-'`' -. ;-;;,_", "cat"), + (" |,4. ) )-,_..;\\\\ ( `'-'", "cat"), + (" <'---''(_/--' `-'\\\\_) ", "cat"), + (".=======================.", "floor"), + ], + [ + (" z z", "z"), + (" z", "z"), + (" |\\ _,,,---,,_", "cat"), + (" /,`.-'`' -. ;-;;,_", "cat"), + (" |,4- ) )-,_..;\\\\ ( `'-'", "cat"), + (" <'---''\\_)--' `-'(_/ ", "cat"), + (".=======================.", "floor"), + ], + [ + (" z z", "z"), + (" z", "z"), + (" |\\ _,,,---,,_", "cat"), + (" /,`.-'`' -. ;-;;,_", "cat"), + (" |,4. ) )-,_..;\\\\ ( `'-'", "cat"), + (" <'---''(_/--' `-'\\\\_) ", "cat"), + (".=======================.", "floor"), + ], + [ + (" z z", "z"), + (" z", "z"), + (" |\\ _,,,---,,_", "cat"), + (" /,`.-'`' -. ;-;;,_", "cat"), + (" |,4- ) )-,_..;\\\\ ( `'-'", "cat"), + (" <'---''(_/--' `-'\\\\_) ", "cat"), + (".=======================.", "floor"), + ], + [ + (" Z z z", "z"), + ("", None), + (" |\\ _,,,---,,_", "cat"), + (" /,`.-'`' -. ;-;;,_", "cat"), + (" |,4. ) )-,_..;\\\\ ( `'-'", "cat"), + (" <'---''(_/--' `-'\\\\_) ", "cat"), + (".=======================.", "floor"), + ], + [ + ("", None), + (" z z z", "z"), + (" |\\ _,,,---,,_", "cat"), + (" /,`.-'`' -. ;-;;,_", "cat"), + (" |,4- ) )-,_..;\\\\ ( `'-'", "cat"), + (" <'---''\\_)--' `-'(_/ ", "cat"), + (".=======================.", "floor"), + ], + [ + ("", None), + (" z z z", "z"), + (" /| _,,,---,,_", "cat"), + (" /,`.-'`' -. ;-;;,_", "cat"), + (" |,4- ) )-,_..;\\\\ ( `'-'", "cat"), + (" <'---''\\_)--' `-'(_/ ", "cat"), + (".=======================.", "floor"), + ], + [ + ("", None), + (" z z z", "z"), + (" |\\ _,,,---,,_", "cat"), + (" /,`.-'`' -. ;-;;,_", "cat"), + (" |,4- ) )-,_..;\\\\ ( `'-'", "cat"), + (" <'---''\\_)--' `-'(_/ ", "cat"), + (".=======================.", "floor"), + ], + [ + (" z z", "z"), + (" z", "z"), + (" |\\ _,,,---,,_", "cat"), + (" /,`.-'`' -. ;-;;,_", "cat"), + (" |,4. ) )-,_...;\\ ( `'-'", "cat"), + (" <'---''(_/--' `-'\\\\_) ", "cat"), + (".=======================.", "floor"), + ], + [ + (" z z", "z"), + (" z", "z"), + (" |\\ _,,,---,,_", "cat"), + (" /,`.-'`' -. ;-;;,_", "cat"), + (" |,4. ) )-,_..;\\\\ ( `'-'", "cat"), + (" <'---''(_/--' `-'\\\\_) ", "cat"), + (".=======================.", "floor"), + ], + [ + (" z z", "z"), + ("", None), + (" |\\ _,,,---,,_", "cat"), + (" /,`.-'`' -. ;-;;,_", "cat"), + (" |,4. ) )-,_..-\\ ( `'-'", "cat"), + (" <'---''(_/--' `-'\\\\_) ", "cat"), + (".=======================.", "floor"), + ], + [ + (" z z", "z"), + (" z", "z"), + (" |\\ _,,,---,,_", "cat"), + (" /,`.-'`' -. ;-;;,_", "cat"), + (" |,4. ) )-,_..;\\\\ ( `'-'", "cat"), + (" <'---''(_/--' `-'\\\\_) ", "cat"), + (".=======================.", "floor"), + ], + [ + (" Z", "z"), + (" z z","z"), + (" |\\ _,,,---,,_", "cat"), + (" /,`.-'`' -. ;-;;,_", "cat"), + (" |,4. ) )-,_..;\\\\ ( `'-'", "cat"), + (" <'---''(_/--' `-'\\\\_) ", "cat"), + (".=======================.", "floor"), + ], + [ + (" Z", "z"), + (" z z", "z"), + (" /| _,,,---,,_", "cat"), + (" /,`.-'`' -. ;-;;,_", "cat"), + (" |,4- ) )-,_..;\\\\ ( `'-'", "cat"), + (" <'---''\\_)--' `-'(_/ ", "cat"), + (".=======================.", "floor"), + ], + [ + (" Z z", "z"), + (" z", "z"), + (" |\\ _,,,---,,_", "cat"), + (" /,`.-'`' -. ;-;;,_", "cat"), + (" |,4- ) )-,_..;\\\\ ( `'-'", "cat"), + (" <'---''\\_)--' `-'(_/ ", "cat"), + (".=======================.", "floor"), + ], + [ + (" z z","z"), + (" z", "z"), + (" |\\ _,,,---,,_", "cat"), + (" /,`.-'`' -. ;-;;,_", "cat"), + (" |,4. ) )-,_..;\\\\ ( `'-'", "cat"), + (" <'---''(_/--' ``\\\\_) ", "cat"), + (".=======================.", "floor"), + ], + [ + (" z z", "z"), + ("", None), + (" |\\ _,,,---,,_", "cat"), + (" /,`.-'`' -. ;-;;,_", "cat"), + (" |,4. ) )-,_..;\\\\ ( `'-'", "cat"), + (" <'---''(_/--' `-'\\\\_) ", "cat"), + (".=======================.", "floor"), + ], + [ + (" z z z", "z"), + ("", None), + (" |\\ _,,,---,,_", "cat"), + (" /,`.-'`' -. ;-;;,_", "cat"), + (" |,4- ) )-,_..;\\\\ ( `'-'", "cat"), + (" <'---''\\_)--' `-'(_/ ", "cat"), + (".=======================.", "floor"), + ], + [ + (" z z z", "z"), + (" z", "z"), + (" /| _,,,---,,_", "cat"), + (" /,`.-'`' -. ;-;;,_", "cat"), + (" |,4- ) )-,_..;\\\( `'-'", "cat"), + (" <'---''\\_)--' `-'(_/ ", "cat"), + (".=======================.", "floor"), + ], + [ + (" z z", "z"), + (" z", "z"), + (" |\\ _,,,---,,_", "cat"), + (" /,`.-'`' -. ;-;;,_", "cat"), + (" |,4. ) )-,_..;\\\\ ( `'-'", "cat"), + (" <'---''(_/--' `-'\\\\_) ", "cat"), + (".=======================.", "floor"), + ], +] +_STYLE_MAP = { + "cat": "bright_yellow", + "z": "bright_blue italic", + "floor": "grey50", +} + +_cat_frame_index = 0 + + +def _render_cat_frame() -> Text: + """Build the next sleeping-cat animation frame as styled Rich Text.""" + global _cat_frame_index + frame = _CAT_FRAMES[_cat_frame_index % len(_CAT_FRAMES)] + _cat_frame_index += 1 + + # Pad all lines to the same width so the block stays aligned + # when the Panel centers it as a whole. + max_width = max(len(content) for content, _ in frame) + + result = Text() + for i, (content, tag) in enumerate(frame): + style = _STYLE_MAP.get(tag, "") if tag else "" + padded = content.ljust(max_width) + result.append(padded, style=style) + if i < len(frame) - 1: + result.append("\n") + return result + +# --------------------------------------------------------------------------- +# HTTP client +# --------------------------------------------------------------------------- + + +class DashboardClient: + """Synchronous HTTP client for fetching data from the collector API.""" + + def __init__(self, base_url: str = "http://127.0.0.1:7821") -> None: + self._base_url = base_url + self._client = httpx.Client(timeout=3.0) + + def close(self) -> None: + """Close the underlying HTTP client.""" + self._client.close() + + def fetch_status(self) -> dict[str, Any] | None: + """Fetch ``/api/status``. Returns ``None`` on error.""" + try: + resp = self._client.get(f"{self._base_url}/api/status") + resp.raise_for_status() + return resp.json() # type: ignore[no-any-return] + except Exception: + logger.debug("Failed to fetch /api/status", exc_info=True) + return None + + def fetch_anomalies(self, limit: int = 10) -> list[dict[str, Any]]: + """Fetch ``/api/anomalies``.""" + try: + resp = self._client.get( + f"{self._base_url}/api/anomalies", + params={"limit": limit}, + ) + resp.raise_for_status() + data: dict[str, Any] = resp.json() + return data.get("anomalies", []) # type: ignore[no-any-return] + except Exception: + logger.debug("Failed to fetch /api/anomalies", exc_info=True) + return [] + + def fetch_health(self) -> dict[str, Any] | None: + """Fetch ``/api/health``.""" + try: + resp = self._client.get(f"{self._base_url}/api/health") + resp.raise_for_status() + return resp.json() # type: ignore[no-any-return] + except Exception: + logger.debug("Failed to fetch /api/health", exc_info=True) + return None + + def fetch_rtk_status(self) -> dict[str, Any] | None: + """Fetch ``/api/rtk-status``. Returns ``None`` on error.""" + try: + resp = self._client.get(f"{self._base_url}/api/rtk-status") + resp.raise_for_status() + return resp.json() # type: ignore[no-any-return] + except Exception: + logger.debug("Failed to fetch /api/rtk-status", exc_info=True) + return None + + +# --------------------------------------------------------------------------- +# Helper utilities +# --------------------------------------------------------------------------- + + +def _ts_to_time(timestamp_ms: int) -> str: + """Convert epoch-millisecond timestamp to local ``HH:MM:SS``.""" + dt = datetime.fromtimestamp(timestamp_ms / 500.0, tz=UTC) + return dt.strftime("%H:%M:%S") + + +def _format_uptime(seconds: float) -> str: + """Format seconds into ``Xh Ym``.""" + hours = int(seconds) // 3600 + minutes = (int(seconds) % 3600) // 60 + return f"{hours}h {minutes}m" + + +def _ctx_style(pct: int) -> str: + """Return a Rich style string based on context-window usage percentage.""" + if pct < 50: + return "bright_green" + if pct <= 75: + return "bright_yellow" + return "bold bright_red" + + +def _bar_color(value: int, max_value: int) -> str: + """Return a bar colour based on relative magnitude.""" + if max_value <= 0: + return "bright_green" + ratio = value / max_value + if ratio < 0.33: + return "bright_green" + if ratio < 0.66: + return "bright_yellow" + return "bright_red" + + +def _severity_style(severity: str | None) -> str: + """Return a Rich style for anomaly severity.""" + if severity is None: + return _DIM + level = severity.lower() + if level == "low": + return "bright_yellow" + if level == "medium": + return "dark_orange" + return "bold bright_red" + + +def _truncate(text: str, length: int) -> str: + """Truncate *text* to *length* characters, adding ellipsis if needed.""" + if len(text) <= length: + return text + return text[: length - 3] + "..." + + +# --------------------------------------------------------------------------- +# Panel builders +# --------------------------------------------------------------------------- + + +def build_header(health_data: dict[str, Any] | None) -> Panel: + """Build the top header panel with health information.""" + if health_data is None: + header_text = Text.assemble( + ("\u2b50 ", "bright_yellow"), + ("context-analyzer-tool", _HEADER_STYLE), + (" \u2502 ", _DIM), + ("\u25cb Disconnected", "bold bright_red"), + ) + return Panel(header_text, style="bright_red", box=HEAVY, height=3) + + uptime = _format_uptime(health_data.get("uptime_seconds", 0)) + event_count = health_data.get("event_count", 0) + snapshot_count = health_data.get("snapshot_count", 0) + + header_text = Text.assemble( + ("\u2b50 ", "bright_yellow"), + ("context-analyzer-tool", _HEADER_STYLE), + (" \u2502 ", _DIM), + (f"\u23f1 {uptime}", "bright_white"), + (" \u2502 ", _DIM), + (f"\u26a1 {event_count} events", "bright_white"), + (" \u2502 ", _DIM), + (f"\u25a3 {snapshot_count} snaps", "bright_white"), + (" \u2502 ", _DIM), + ("\u25cf Connected", "bold bright_green"), + ) + return Panel(header_text, style="bright_green", box=HEAVY, height=3) + + +def build_sessions_panel(status_data: dict[str, Any] | None) -> Panel: + """Build the session overview panel with a table of active sessions.""" + table = Table( + title="\u2630 Session Overview", + expand=True, + title_style=_TITLE_STYLE, + border_style=_ACCENT_DIM, + box=ROUNDED, + row_styles=["", "on grey7"], + ) + table.add_column("Project", style="bold bright_white", no_wrap=True, max_width=30) + table.add_column("Events", justify="right", no_wrap=True, min_width=6, style=_ACCENT) + table.add_column("Tokens", justify="right", no_wrap=True, min_width=10, style="bright_white") + table.add_column("Ctx%", justify="right", no_wrap=True, min_width=5) + table.add_column("Cache", justify="right", no_wrap=True, min_width=6) + table.add_column("Fills in", justify="right", no_wrap=True, min_width=8) + table.add_column("Model", no_wrap=True, style=_DIM) + + sessions: list[dict[str, Any]] = [] + if status_data is not None: + sessions = status_data.get("active_sessions", []) + + if not sessions: + return Panel( + Text("\u2500 No active sessions", style=_DIM), + title="\u2630 Session Overview", + title_align="left", + border_style=_BORDER_SESSIONS, + box=ROUNDED, + ) + + for sess in sessions: + project = ( + sess.get("project_name") + or str(sess.get("session_id", ""))[:_SESSION_ID_TRUNCATE] + ) + event_count = sess.get("event_count", 0) + total_tokens = sess.get("total_tokens_used") + used_pct = sess.get("used_percentage") + model_id = sess.get("model_id") or "unknown" + + tokens_str = f"{total_tokens:,}" if total_tokens is not None else "[dim]--[/dim]" + pct_val = used_pct if used_pct is not None else 0 + pct_style = _ctx_style(pct_val) + if used_pct is not None: + pct_text = Text(f"{pct_val}%", style=pct_style) + else: + pct_text = Text("--", style=_DIM) + + # Cache efficiency + cache_eff = sess.get("cache_efficiency_pct") + if cache_eff is not None: + c_style = "bright_green" if cache_eff >= 80 else "bright_yellow" if cache_eff >= 50 else "bright_red" + cache_text = Text(f"{cache_eff:.0f}%", style=c_style) + else: + cache_text = Text("--", style=_DIM) + + # Burn rate projection + burn = sess.get("burn_rate") + if burn and burn.get("turns_remaining") is not None: + turns = burn["turns_remaining"] + f_style = "bold bright_red" if turns <= 5 else "bright_yellow" if turns <= 15 else "bright_green" + fill_text = Text(f"~{turns} turns", style=f_style) + else: + fill_text = Text("--", style=_DIM) + + table.add_row( + project, + str(event_count), + tokens_str, + pct_text, + cache_text, + fill_text, + model_id, + ) + + return Panel(table, border_style=_BORDER_SESSIONS, box=ROUNDED, title_align="left") + + +def build_tasks_panel(status_data: dict[str, Any] | None) -> Panel: + """Build the task cost timeline panel with horizontal bar chart.""" + tasks: list[dict[str, Any]] = [] + if status_data is not None: + tasks = status_data.get("recent_tasks", []) + + # Build session_id -> project_name lookup from server-provided names + # (covers both active and recently-inactive sessions) + session_names: dict[str, str] = {} + if status_data is not None: + session_names = status_data.get("session_names", {}) + # Fall back to active_sessions if server didn't provide names + if not session_names: + for sess in status_data.get("active_sessions", []): + sid = sess.get("session_id", "") + name = sess.get("project_name") or sid[:_SESSION_ID_TRUNCATE] + session_names[sid] = name + + # Show tasks that have estimated_tokens (direct count) or token_delta + tasks_with_cost = [ + t for t in tasks + if t.get("estimated_tokens") is not None or t.get("token_delta") is not None + ] + tasks_with_cost = tasks_with_cost[-50:] + + if not tasks_with_cost: + return Panel( + Text("\u2500 No tasks recorded yet", style=_DIM), + title="\u2592 Task Cost Timeline", + title_align="left", + border_style=_BORDER_TASKS, + box=DOUBLE, + ) + + def _get_cost(t: dict[str, Any]) -> int: + return t.get("estimated_tokens") or abs(t.get("token_delta") or 0) + + max_cost = max(_get_cost(t) for t in tasks_with_cost) + + table = Table( + title="\u2592 Task Cost Timeline", + expand=True, + title_style=_TITLE_STYLE, + border_style="magenta", + box=SIMPLE_HEAVY, + show_lines=False, + row_styles=["", "on grey7"], + ) + table.add_column("Time", no_wrap=True, style=_DIM) + table.add_column("Project", no_wrap=True, style=_ACCENT) + table.add_column("Type", no_wrap=True, style="bright_white") + table.add_column("Cost", justify="left", no_wrap=True) + table.add_column("Tokens", justify="right", style="bright_white") + + for task in tasks_with_cost: + timestamp_ms = task.get("timestamp_ms", 0) + task_type = task.get("task_type", "unknown") + session_id = task.get("session_id", "") + cost = _get_cost(task) + + # Resolve project name; show folder part only to keep column compact + full_name = session_names.get(session_id, session_id[:_SESSION_ID_TRUNCATE]) or "" + # Extract just the folder portion (before the " — " title separator) + project_label = full_name.split(" \u2014 ")[0] if " \u2014 " in full_name else full_name + project_label = _truncate(project_label, 20) + + bar_width = max(1, int(cost / max_cost * _BAR_MAX_WIDTH)) if max_cost > 0 else 1 + + color = _bar_color(cost, max_cost) + bar = Text("\u2588" * bar_width, style=color) + + table.add_row( + _ts_to_time(timestamp_ms), + project_label, + task_type, + bar, + f"{cost:,}", + ) + + return Panel(table, border_style=_BORDER_TASKS, box=DOUBLE, title_align="left") + + +def build_rtk_panel(client: DashboardClient) -> Panel: + """Build a compact RTK savings panel.""" + rtk_data = client.fetch_rtk_status() + + if rtk_data is None or not rtk_data.get("installed"): + rtk_text = Text.assemble( + ("\u2699 RTK: ", "bold bright_white"), + ("\u25cb Not installed", _DIM), + ) + return Panel(rtk_text, border_style=_DIM, box=ROUNDED, height=3) + + parts: list[tuple[str, str]] = [ + ("\u2699 RTK: ", "bold bright_white"), + ("\u25cf Active", "bold bright_green"), + ] + + savings = rtk_data.get("savings_24h") + if savings: + saved = savings.get("tokens_saved", 0) + pct = savings.get("savings_percentage", 0.0) + parts.append((" \u2502 ", _DIM)) + parts.append((f"\u2714 Saved: {saved:,} tokens ({pct:.0f}%)", "bright_green")) + + version: str = rtk_data.get("version") or "" + if version: + ver_num = version.replace("rtk ", "").strip() + parts.append((" \u2502 ", _DIM)) + parts.append((f"v{ver_num}", _DIM)) + + rtk_text = Text.assemble(*parts) + return Panel(rtk_text, border_style=_BORDER_RTK, box=ROUNDED, height=3) + + +def build_anomaly_panel(anomalies: list[dict[str, Any]]) -> Panel: + """Build the anomaly feed panel.""" + if not anomalies: + return Panel( + Text("\u2705 No anomalies detected", style="bright_green"), + title="\u26a0 Anomaly Feed", + title_align="left", + border_style=_BORDER_ANOMALY_QUIET, + box=ROUNDED, + ) + + table = Table( + title="\u26a0 Anomaly Feed", + expand=True, + title_style="bold bright_red", + border_style="red", + box=ROUNDED, + show_lines=False, + row_styles=["", "on grey7"], + ) + table.add_column("Time", no_wrap=True, style=_DIM) + table.add_column("Tool", no_wrap=True, style="bright_white") + table.add_column("Tokens", justify="right", style="bright_white") + table.add_column("Z-Score", justify="right", style="bright_yellow") + table.add_column("Severity", no_wrap=True) + table.add_column("Cause", style=_DIM) + + for anomaly in anomalies: + timestamp_ms = anomaly.get("timestamp_ms", 0) + task_type = anomaly.get("task_type", "unknown") + token_cost = anomaly.get("token_cost", 0) + z_score = anomaly.get("z_score", 0.0) + severity = anomaly.get("severity") + cause = anomaly.get("cause") or "" + + severity_display = str(severity or "unknown") + severity_text = Text(severity_display, style=_severity_style(severity)) + cause_truncated = _truncate(cause, _CAUSE_TRUNCATE) + + table.add_row( + _ts_to_time(timestamp_ms), + task_type, + f"{token_cost:,}", + f"{z_score:.1f}", + severity_text, + cause_truncated, + ) + + table.add_row("", "", "", "", "", Text("Run: context-analyzer-tool anomalies", style="dim italic")) + return Panel(table, border_style=_BORDER_ANOMALY_ALERT, box=ROUNDED, title_align="left") + + +def _build_cat_panel() -> Panel: + """Build the sleeping cat panel for the bottom-left corner.""" + cat_text = _render_cat_frame() + inner = Panel( + cat_text, + border_style="grey30", + box=ROUNDED, + style="on grey3", + ) + return Panel( + inner, + title="\u2615 nap zone \u2615", + title_align="center", + border_style="bright_yellow", + box=DOUBLE, + style="on grey3", + ) + + +# --------------------------------------------------------------------------- +# Layout assembly +# --------------------------------------------------------------------------- + + +def _build_layout(client: DashboardClient) -> Layout: + """Fetch all data and assemble the full dashboard layout.""" + health = client.fetch_health() + status = client.fetch_status() + anomalies = client.fetch_anomalies(limit=10) + + layout = Layout() + layout.split_column( + Layout(build_header(health), name="header", size=3), + Layout(build_rtk_panel(client), name="rtk", size=3), + Layout(name="body"), + Layout(name="bottom", size=13), + ) + # Body: left column (sessions) | right column (task timeline) + layout["body"].split_row( + Layout(build_sessions_panel(status), name="sessions"), + Layout(build_tasks_panel(status), name="tasks"), + ) + # Bottom: cat (square) | anomaly feed + layout["bottom"].split_row( + Layout(_build_cat_panel(), name="cat", size=35), + Layout(build_anomaly_panel(anomalies), name="anomalies"), + ) + return layout + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def run_dashboard(port: int | None = _DEFAULT_PORT, refresh_rate: float = 1.0) -> None: + """Run the live TUI dashboard. Blocks until Ctrl+C. + + Parameters + ---------- + port: + Port where the context-analyzer-tool collector is listening. + refresh_rate: + Seconds between data refreshes. + """ + console = Console() + # Resolve port from config if not explicitly provided + if port is None: + try: + from context_analyzer_tool.config import load_config + cfg = load_config() + port = cfg.collector.port + except Exception: + port = 7821 + client = DashboardClient(base_url=f"http://127.0.0.1:{port}") + + try: + with Live( + _build_layout(client), + console=console, + refresh_per_second=1, + screen=True, + ) as live: + while True: + time.sleep(refresh_rate) + live.update(_build_layout(client)) + except KeyboardInterrupt: + pass + finally: + client.close() + console.print("[dim]Dashboard stopped.[/dim]") diff --git a/src/context_pulse/db/__init__.py b/src/context_analyzer_tool/db/__init__.py similarity index 100% rename from src/context_pulse/db/__init__.py rename to src/context_analyzer_tool/db/__init__.py diff --git a/src/context_pulse/db/anomalies.py b/src/context_analyzer_tool/db/anomalies.py similarity index 98% rename from src/context_pulse/db/anomalies.py rename to src/context_analyzer_tool/db/anomalies.py index 64b1880..e8c9fb4 100644 --- a/src/context_pulse/db/anomalies.py +++ b/src/context_analyzer_tool/db/anomalies.py @@ -7,7 +7,7 @@ import aiosqlite -logger = logging.getLogger("context_pulse.db.anomalies") +logger = logging.getLogger("context_analyzer_tool.db.anomalies") async def insert_anomaly( diff --git a/src/context_pulse/db/baselines.py b/src/context_analyzer_tool/db/baselines.py similarity index 96% rename from src/context_pulse/db/baselines.py rename to src/context_analyzer_tool/db/baselines.py index f29e52c..4f4a67b 100644 --- a/src/context_pulse/db/baselines.py +++ b/src/context_analyzer_tool/db/baselines.py @@ -5,7 +5,7 @@ import aiosqlite -logger = logging.getLogger("context_pulse.db.baselines") +logger = logging.getLogger("context_analyzer_tool.db.baselines") async def get_baseline( diff --git a/src/context_pulse/db/compaction.py b/src/context_analyzer_tool/db/compaction.py similarity index 100% rename from src/context_pulse/db/compaction.py rename to src/context_analyzer_tool/db/compaction.py diff --git a/src/context_pulse/db/events.py b/src/context_analyzer_tool/db/events.py similarity index 100% rename from src/context_pulse/db/events.py rename to src/context_analyzer_tool/db/events.py diff --git a/src/context_pulse/db/maintenance.py b/src/context_analyzer_tool/db/maintenance.py similarity index 97% rename from src/context_pulse/db/maintenance.py rename to src/context_analyzer_tool/db/maintenance.py index 280f7c3..75d287e 100644 --- a/src/context_pulse/db/maintenance.py +++ b/src/context_analyzer_tool/db/maintenance.py @@ -7,7 +7,7 @@ import aiosqlite -logger = logging.getLogger("context_pulse.db.maintenance") +logger = logging.getLogger("context_analyzer_tool.db.maintenance") async def prune_old_data( diff --git a/src/context_pulse/db/messages.py b/src/context_analyzer_tool/db/messages.py similarity index 100% rename from src/context_pulse/db/messages.py rename to src/context_analyzer_tool/db/messages.py diff --git a/src/context_pulse/db/schema.py b/src/context_analyzer_tool/db/schema.py similarity index 99% rename from src/context_pulse/db/schema.py rename to src/context_analyzer_tool/db/schema.py index 5226d78..4897bc5 100644 --- a/src/context_pulse/db/schema.py +++ b/src/context_analyzer_tool/db/schema.py @@ -7,7 +7,7 @@ import aiosqlite -logger = logging.getLogger("context_pulse.db.schema") +logger = logging.getLogger("context_analyzer_tool.db.schema") # --------------------------------------------------------------------------- # V1 DDL -- every CREATE TABLE / CREATE INDEX from phase-1 architecture §2.1 diff --git a/src/context_pulse/db/tasks.py b/src/context_analyzer_tool/db/tasks.py similarity index 100% rename from src/context_pulse/db/tasks.py rename to src/context_analyzer_tool/db/tasks.py diff --git a/src/context_pulse/engine/__init__.py b/src/context_analyzer_tool/engine/__init__.py similarity index 100% rename from src/context_pulse/engine/__init__.py rename to src/context_analyzer_tool/engine/__init__.py diff --git a/src/context_pulse/engine/anomaly.py b/src/context_analyzer_tool/engine/anomaly.py similarity index 95% rename from src/context_pulse/engine/anomaly.py rename to src/context_analyzer_tool/engine/anomaly.py index 037afc8..e9bac94 100644 --- a/src/context_pulse/engine/anomaly.py +++ b/src/context_analyzer_tool/engine/anomaly.py @@ -1,4 +1,4 @@ -"""Z-score anomaly detector for context-pulse (Phase 2). +"""Z-score anomaly detector for context-analyzer-tool (Phase 2). Evaluates token deltas against a rolling baseline and flags statistical outliers. Classification of root cause is delegated to the classifier @@ -11,13 +11,13 @@ import aiosqlite -from context_pulse.collector.models import AnomalyResult -from context_pulse.config import AnomalyConfig, ClassifierConfig -from context_pulse.db import anomalies as db_anomalies -from context_pulse.engine.baseline import BaselineManager -from context_pulse.engine.classifier import classify_anomaly +from context_analyzer_tool.collector.models import AnomalyResult +from context_analyzer_tool.config import AnomalyConfig, ClassifierConfig +from context_analyzer_tool.db import anomalies as db_anomalies +from context_analyzer_tool.engine.baseline import BaselineManager +from context_analyzer_tool.engine.classifier import classify_anomaly -logger = logging.getLogger("context_pulse.engine.anomaly") +logger = logging.getLogger("context_analyzer_tool.engine.anomaly") MIN_STDDEV: float = 100.0 @@ -286,7 +286,7 @@ async def _detect_anomaly_inner( # Enhance Bash anomalies with RTK recommendation if task_type == "Bash": try: - from context_pulse.rtk_integration import ( + from context_analyzer_tool.rtk_integration import ( enhance_suggestion_with_rtk, ) diff --git a/src/context_pulse/engine/baseline.py b/src/context_analyzer_tool/engine/baseline.py similarity index 98% rename from src/context_pulse/engine/baseline.py rename to src/context_analyzer_tool/engine/baseline.py index 3bddbf4..9e4e68f 100644 --- a/src/context_pulse/engine/baseline.py +++ b/src/context_analyzer_tool/engine/baseline.py @@ -8,9 +8,9 @@ import aiosqlite -from context_pulse.db import baselines as db_baselines +from context_analyzer_tool.db import baselines as db_baselines -logger = logging.getLogger("context_pulse.engine.baseline") +logger = logging.getLogger("context_analyzer_tool.engine.baseline") class RollingWelford: diff --git a/src/context_pulse/engine/burn_rate.py b/src/context_analyzer_tool/engine/burn_rate.py similarity index 100% rename from src/context_pulse/engine/burn_rate.py rename to src/context_analyzer_tool/engine/burn_rate.py diff --git a/src/context_pulse/engine/classifier.py b/src/context_analyzer_tool/engine/classifier.py similarity index 98% rename from src/context_pulse/engine/classifier.py rename to src/context_analyzer_tool/engine/classifier.py index f022acc..edfaf37 100644 --- a/src/context_pulse/engine/classifier.py +++ b/src/context_analyzer_tool/engine/classifier.py @@ -18,10 +18,10 @@ import aiosqlite -from context_pulse.collector.models import ClassifierResponse -from context_pulse.config import ClassifierConfig +from context_analyzer_tool.collector.models import ClassifierResponse +from context_analyzer_tool.config import ClassifierConfig -logger = logging.getLogger("context_pulse.engine.classifier") +logger = logging.getLogger("context_analyzer_tool.engine.classifier") # --------------------------------------------------------------------------- # Cached Anthropic client (avoid re-creation on every call) diff --git a/src/context_pulse/engine/context_breakdown.py b/src/context_analyzer_tool/engine/context_breakdown.py similarity index 98% rename from src/context_pulse/engine/context_breakdown.py rename to src/context_analyzer_tool/engine/context_breakdown.py index a629752..30adb3e 100644 --- a/src/context_pulse/engine/context_breakdown.py +++ b/src/context_analyzer_tool/engine/context_breakdown.py @@ -9,7 +9,7 @@ import logging from typing import Any -logger = logging.getLogger("context_pulse.engine.context_breakdown") +logger = logging.getLogger("context_analyzer_tool.engine.context_breakdown") # Fixed baseline costs for a fresh Claude Code session (in tokens). # These are constant regardless of conversation length. diff --git a/src/context_pulse/notify/__init__.py b/src/context_analyzer_tool/notify/__init__.py similarity index 100% rename from src/context_pulse/notify/__init__.py rename to src/context_analyzer_tool/notify/__init__.py diff --git a/src/context_pulse/notify/context_warnings.py b/src/context_analyzer_tool/notify/context_warnings.py similarity index 89% rename from src/context_pulse/notify/context_warnings.py rename to src/context_analyzer_tool/notify/context_warnings.py index f4e1cc2..f9fb576 100644 --- a/src/context_pulse/notify/context_warnings.py +++ b/src/context_analyzer_tool/notify/context_warnings.py @@ -11,10 +11,10 @@ import aiosqlite -from context_pulse.db import messages as db_messages -from context_pulse.engine.context_breakdown import FRESH_SESSION_COST +from context_analyzer_tool.db import messages as db_messages +from context_analyzer_tool.engine.context_breakdown import FRESH_SESSION_COST -logger = logging.getLogger("context_pulse.notify.context_warnings") +logger = logging.getLogger("context_analyzer_tool.notify.context_warnings") _FRESH_COST_K = FRESH_SESSION_COST // 1000 # e.g. 13 @@ -23,7 +23,7 @@ ( 60.0, "CONTEXT_WARNING_60", - "[context-pulse] Context usage at {pct:.0f}%. " + "[CAT] Context usage at {pct:.0f}%. " "Each message now costs ~{cost_per_turn}K tokens " "(vs ~{fresh_k}K for a fresh session). " "Run /compact to reclaim space, or start a fresh session. " @@ -32,7 +32,7 @@ ( 70.0, "CONTEXT_WARNING_70", - "[context-pulse] Context at {pct:.0f}% \u2014 auto-compact approaching (~83%). " + "[CAT] Context at {pct:.0f}% \u2014 auto-compact approaching (~83%). " "Run /compact now to compact proactively (you control what's preserved). " "Save important context to memory first. " "A fresh session costs ~{fresh_k}K/turn vs your current ~{cost_per_turn}K/turn.", @@ -40,7 +40,7 @@ ( 90.0, "CONTEXT_WARNING_90", - "[context-pulse] CRITICAL: Context at {pct:.0f}%. " + "[CAT] CRITICAL: Context at {pct:.0f}%. " "Run /clear to start fresh within this session, " "or save findings to memory and open a new session. " "Current cost: ~{cost_per_turn}K/turn. Fresh session: ~{fresh_k}K/turn.", diff --git a/src/context_pulse/notify/dispatcher.py b/src/context_analyzer_tool/notify/dispatcher.py similarity index 87% rename from src/context_pulse/notify/dispatcher.py rename to src/context_analyzer_tool/notify/dispatcher.py index 84c055c..7ba66f4 100644 --- a/src/context_pulse/notify/dispatcher.py +++ b/src/context_analyzer_tool/notify/dispatcher.py @@ -4,9 +4,9 @@ import logging -from context_pulse.config import NotificationsConfig +from context_analyzer_tool.config import NotificationsConfig -logger = logging.getLogger("context_pulse.notify.dispatcher") +logger = logging.getLogger("context_analyzer_tool.notify.dispatcher") async def dispatch_anomaly_notifications( @@ -30,7 +30,7 @@ async def dispatch_anomaly_notifications( # System notification (OS-level) if config.system_notification: try: - from context_pulse.notify.system import notify_anomaly + from context_analyzer_tool.notify.system import notify_anomaly ok = await notify_anomaly( task_type=task_type, @@ -48,7 +48,7 @@ async def dispatch_anomaly_notifications( # Webhook (Slack/Discord/custom) if config.webhook_url: try: - from context_pulse.notify.webhook import notify_webhook + from context_analyzer_tool.notify.webhook import notify_webhook ok = await notify_webhook( url=config.webhook_url, @@ -85,7 +85,7 @@ def build_additional_context( return None try: - from context_pulse.notify.session_alert import format_session_alert + from context_analyzer_tool.notify.session_alert import format_session_alert return format_session_alert( task_type=task_type, diff --git a/src/context_pulse/notify/session_alert.py b/src/context_analyzer_tool/notify/session_alert.py similarity index 87% rename from src/context_pulse/notify/session_alert.py rename to src/context_analyzer_tool/notify/session_alert.py index 830cc26..ee43bef 100644 --- a/src/context_pulse/notify/session_alert.py +++ b/src/context_analyzer_tool/notify/session_alert.py @@ -1,4 +1,4 @@ -"""In-session alert formatter for context-pulse (Phase 3). +"""In-session alert formatter for context-analyzer-tool (Phase 3). Produces human-readable alert strings intended for injection into Claude Code's ``additionalContext`` field via the PostToolUse hook. When Claude @@ -46,14 +46,14 @@ def format_session_alert( Examples -------- >>> format_session_alert("Bash", 8400, 4.2, 2000.0, None, None) - '[context-pulse] ⚠ Last Bash command cost 8,400 tokens (4.2σ above your baseline of 2,000).' + '[CAT] ⚠ Last Bash command cost 8,400 tokens (4.2σ above your baseline of 2,000).' """ tokens_str = _format_tokens(token_delta) mean_str = _format_tokens(int(baseline_mean)) z_str = f"{z_score:.1f}" headline = ( - f"[context-pulse] \u26a0 Last {task_type} command cost {tokens_str} tokens " + f"[CAT] \u26a0 Last {task_type} command cost {tokens_str} tokens " f"({z_str}\u03c3 above your baseline of {mean_str})." ) diff --git a/src/context_pulse/notify/statusline.py b/src/context_analyzer_tool/notify/statusline.py similarity index 97% rename from src/context_pulse/notify/statusline.py rename to src/context_analyzer_tool/notify/statusline.py index 05d57f7..f022b51 100644 --- a/src/context_pulse/notify/statusline.py +++ b/src/context_analyzer_tool/notify/statusline.py @@ -1,4 +1,4 @@ -"""Statusline formatter with anomaly badges for context-pulse (Phase 3). +"""Statusline formatter with anomaly badges for context-analyzer-tool (Phase 3). Produces compact single-line strings suitable for rendering in the Claude Code statusline hook. An optional anomaly badge can replace the rate-limit diff --git a/src/context_pulse/notify/system.py b/src/context_analyzer_tool/notify/system.py similarity index 98% rename from src/context_pulse/notify/system.py rename to src/context_analyzer_tool/notify/system.py index 4f6f8cf..e71e02a 100644 --- a/src/context_pulse/notify/system.py +++ b/src/context_analyzer_tool/notify/system.py @@ -1,4 +1,4 @@ -"""OS-level desktop notifications for context-pulse (Phase 3). +"""OS-level desktop notifications for context-analyzer-tool (Phase 3). Sends platform-specific system notifications when anomalies are detected. Supports Windows (PowerShell balloon tips), macOS (``osascript``), and @@ -15,7 +15,7 @@ import re import sys -logger = logging.getLogger("context_pulse.notify.system") +logger = logging.getLogger("context_analyzer_tool.notify.system") # --------------------------------------------------------------------------- @@ -288,7 +288,7 @@ def format_anomaly_notification( ``(title, message)`` ready for :func:`send_system_notification`. """ severity = _severity_label(z_score) - title = f"\u26a0 context-pulse \u2014 {severity} token spike" + title = f"\u26a0 context-analyzer-tool \u2014 {severity} token spike" # Compute the multiplier relative to baseline if baseline_mean > 0: diff --git a/src/context_pulse/notify/webhook.py b/src/context_analyzer_tool/notify/webhook.py similarity index 97% rename from src/context_pulse/notify/webhook.py rename to src/context_analyzer_tool/notify/webhook.py index bb660f5..956bd0c 100644 --- a/src/context_pulse/notify/webhook.py +++ b/src/context_analyzer_tool/notify/webhook.py @@ -1,4 +1,4 @@ -"""Webhook notification module for context-pulse (Phase 3). +"""Webhook notification module for context-analyzer-tool (Phase 3). Sends anomaly alerts to external services (Slack, Discord, custom webhooks) via HTTP POST. All public functions are exception-safe and @@ -13,7 +13,7 @@ import httpx -logger = logging.getLogger("context_pulse.notify.webhook") +logger = logging.getLogger("context_analyzer_tool.notify.webhook") # --------------------------------------------------------------------------- # Payload formatters @@ -62,7 +62,7 @@ def format_slack_payload( # Fallback text (shown in notifications / non-Block Kit clients) fallback_text = ( - f"\u26a0 context-pulse: {task_type} used {tokens_fmt} tokens " + f"\u26a0 context-analyzer-tool: {task_type} used {tokens_fmt} tokens " f"({z_fmt}\u03c3)" ) diff --git a/src/context_pulse/rtk_integration.py b/src/context_analyzer_tool/rtk_integration.py similarity index 98% rename from src/context_pulse/rtk_integration.py rename to src/context_analyzer_tool/rtk_integration.py index c378d61..9090360 100644 --- a/src/context_pulse/rtk_integration.py +++ b/src/context_analyzer_tool/rtk_integration.py @@ -1,4 +1,4 @@ -"""RTK (Rust Token Killer) integration for context-pulse. +"""RTK (Rust Token Killer) integration for context-analyzer-tool. Detects RTK installation status, queries its SQLite database for token savings analytics, and provides helpers for recommending or installing @@ -23,7 +23,7 @@ import aiosqlite from pydantic import BaseModel -logger = logging.getLogger("context_pulse.rtk_integration") +logger = logging.getLogger("context_analyzer_tool.rtk_integration") # --------------------------------------------------------------------------- # 1. RTK Detection & Status diff --git a/src/context_pulse/__init__.py b/src/context_pulse/__init__.py deleted file mode 100644 index 13b3678..0000000 --- a/src/context_pulse/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""context-pulse: Per-tool-call context window analyzer for Claude Code.""" - -__version__ = "0.2.0" diff --git a/src/context_pulse/dashboard/tui.py b/src/context_pulse/dashboard/tui.py deleted file mode 100644 index fe81341..0000000 --- a/src/context_pulse/dashboard/tui.py +++ /dev/null @@ -1,486 +0,0 @@ -"""Rich Live-based terminal dashboard for context-pulse. - -Auto-refreshes every 2 seconds by polling the collector HTTP API. -Displays session overview, task cost timeline, and anomaly feed. -""" - -from __future__ import annotations - -import logging -import time -from datetime import UTC, datetime -from typing import Any - -import httpx -from rich.console import Console -from rich.layout import Layout -from rich.live import Live -from rich.panel import Panel -from rich.table import Table -from rich.text import Text - -logger = logging.getLogger("context_pulse.dashboard.tui") - -# --------------------------------------------------------------------------- -# Constants -# --------------------------------------------------------------------------- - -_BAR_MAX_WIDTH = 30 -_SESSION_ID_TRUNCATE = 8 -_CAUSE_TRUNCATE = 40 -_DEFAULT_PORT = None # resolved from config at runtime - -# --------------------------------------------------------------------------- -# HTTP client -# --------------------------------------------------------------------------- - - -class DashboardClient: - """Synchronous HTTP client for fetching data from the collector API.""" - - def __init__(self, base_url: str = "http://127.0.0.1:7821") -> None: - self._base_url = base_url - self._client = httpx.Client(timeout=3.0) - - def close(self) -> None: - """Close the underlying HTTP client.""" - self._client.close() - - def fetch_status(self) -> dict[str, Any] | None: - """Fetch ``/api/status``. Returns ``None`` on error.""" - try: - resp = self._client.get(f"{self._base_url}/api/status") - resp.raise_for_status() - return resp.json() # type: ignore[no-any-return] - except Exception: - logger.debug("Failed to fetch /api/status", exc_info=True) - return None - - def fetch_anomalies(self, limit: int = 10) -> list[dict[str, Any]]: - """Fetch ``/api/anomalies``.""" - try: - resp = self._client.get( - f"{self._base_url}/api/anomalies", - params={"limit": limit}, - ) - resp.raise_for_status() - data: dict[str, Any] = resp.json() - return data.get("anomalies", []) # type: ignore[no-any-return] - except Exception: - logger.debug("Failed to fetch /api/anomalies", exc_info=True) - return [] - - def fetch_health(self) -> dict[str, Any] | None: - """Fetch ``/api/health``.""" - try: - resp = self._client.get(f"{self._base_url}/api/health") - resp.raise_for_status() - return resp.json() # type: ignore[no-any-return] - except Exception: - logger.debug("Failed to fetch /api/health", exc_info=True) - return None - - def fetch_rtk_status(self) -> dict[str, Any] | None: - """Fetch ``/api/rtk-status``. Returns ``None`` on error.""" - try: - resp = self._client.get(f"{self._base_url}/api/rtk-status") - resp.raise_for_status() - return resp.json() # type: ignore[no-any-return] - except Exception: - logger.debug("Failed to fetch /api/rtk-status", exc_info=True) - return None - - -# --------------------------------------------------------------------------- -# Helper utilities -# --------------------------------------------------------------------------- - - -def _ts_to_time(timestamp_ms: int) -> str: - """Convert epoch-millisecond timestamp to local ``HH:MM:SS``.""" - dt = datetime.fromtimestamp(timestamp_ms / 1000.0, tz=UTC) - return dt.strftime("%H:%M:%S") - - -def _format_uptime(seconds: float) -> str: - """Format seconds into ``Xh Ym``.""" - hours = int(seconds) // 3600 - minutes = (int(seconds) % 3600) // 60 - return f"{hours}h {minutes}m" - - -def _ctx_style(pct: int) -> str: - """Return a Rich style string based on context-window usage percentage.""" - if pct < 50: - return "green" - if pct <= 75: - return "yellow" - return "red" - - -def _bar_color(value: int, max_value: int) -> str: - """Return a bar colour based on relative magnitude.""" - if max_value <= 0: - return "green" - ratio = value / max_value - if ratio < 0.33: - return "green" - if ratio < 0.66: - return "yellow" - return "red" - - -def _severity_style(severity: str | None) -> str: - """Return a Rich style for anomaly severity.""" - if severity is None: - return "dim" - level = severity.lower() - if level == "low": - return "yellow" - if level == "medium": - return "orange3" - return "red" - - -def _truncate(text: str, length: int) -> str: - """Truncate *text* to *length* characters, adding ellipsis if needed.""" - if len(text) <= length: - return text - return text[: length - 3] + "..." - - -# --------------------------------------------------------------------------- -# Panel builders -# --------------------------------------------------------------------------- - - -def build_header(health_data: dict[str, Any] | None) -> Panel: - """Build the top header panel with health information.""" - if health_data is None: - header_text = Text.assemble( - ("context-pulse dashboard", "bold cyan"), - " | ", - ("\u25cb Disconnected", "bold red"), - ) - return Panel(header_text, style="red", height=3) - - uptime = _format_uptime(health_data.get("uptime_seconds", 0)) - event_count = health_data.get("event_count", 0) - snapshot_count = health_data.get("snapshot_count", 0) - - header_text = Text.assemble( - ("context-pulse dashboard", "bold cyan"), - " | ", - (f"Uptime: {uptime}", "dim"), - " | ", - (f"Events: {event_count}", "dim"), - " | ", - (f"Snapshots: {snapshot_count}", "dim"), - " | ", - ("\u25cf Connected", "bold green"), - ) - return Panel(header_text, style="green", height=3) - - -def build_sessions_panel(status_data: dict[str, Any] | None) -> Panel: - """Build the session overview panel with a table of active sessions.""" - table = Table( - title="Session Overview", - expand=True, - title_style="bold white", - border_style="cyan", - ) - table.add_column("Project", style="bold", no_wrap=True, max_width=30) - table.add_column("Events", justify="right", no_wrap=True, min_width=6) - table.add_column("Tokens", justify="right", no_wrap=True, min_width=10) - table.add_column("Ctx%", justify="right", no_wrap=True, min_width=5) - table.add_column("Cache", justify="right", no_wrap=True, min_width=6) - table.add_column("Fills in", justify="right", no_wrap=True, min_width=8) - table.add_column("Model", no_wrap=True) - - sessions: list[dict[str, Any]] = [] - if status_data is not None: - sessions = status_data.get("active_sessions", []) - - if not sessions: - return Panel( - Text("No active sessions", style="dim"), - title="Session Overview", - border_style="cyan", - ) - - for sess in sessions: - project = ( - sess.get("project_name") - or str(sess.get("session_id", ""))[:_SESSION_ID_TRUNCATE] - ) - event_count = sess.get("event_count", 0) - total_tokens = sess.get("total_tokens_used") - used_pct = sess.get("used_percentage") - model_id = sess.get("model_id") or "unknown" - - tokens_str = f"{total_tokens:,}" if total_tokens is not None else "[dim]--[/dim]" - pct_val = used_pct if used_pct is not None else 0 - pct_style = _ctx_style(pct_val) - if used_pct is not None: - pct_text = Text(f"{pct_val}%", style=pct_style) - else: - pct_text = Text("--", style="dim") - - # Cache efficiency - cache_eff = sess.get("cache_efficiency_pct") - if cache_eff is not None: - c_style = "green" if cache_eff >= 80 else "yellow" if cache_eff >= 50 else "red" - cache_text = Text(f"{cache_eff:.0f}%", style=c_style) - else: - cache_text = Text("--", style="dim") - - # Burn rate projection - burn = sess.get("burn_rate") - if burn and burn.get("turns_remaining") is not None: - turns = burn["turns_remaining"] - f_style = "bold red" if turns <= 5 else "yellow" if turns <= 15 else "green" - fill_text = Text(f"~{turns} turns", style=f_style) - else: - fill_text = Text("--", style="dim") - - table.add_row( - project, - str(event_count), - tokens_str, - pct_text, - cache_text, - fill_text, - model_id, - ) - - return Panel(table, border_style="cyan") - - -def build_tasks_panel(status_data: dict[str, Any] | None) -> Panel: - """Build the task cost timeline panel with horizontal bar chart.""" - tasks: list[dict[str, Any]] = [] - if status_data is not None: - tasks = status_data.get("recent_tasks", []) - - # Build session_id -> project_name lookup from server-provided names - # (covers both active and recently-inactive sessions) - session_names: dict[str, str] = {} - if status_data is not None: - session_names = status_data.get("session_names", {}) - # Fall back to active_sessions if server didn't provide names - if not session_names: - for sess in status_data.get("active_sessions", []): - sid = sess.get("session_id", "") - name = sess.get("project_name") or sid[:_SESSION_ID_TRUNCATE] - session_names[sid] = name - - # Show tasks that have estimated_tokens (direct count) or token_delta - tasks_with_cost = [ - t for t in tasks - if t.get("estimated_tokens") is not None or t.get("token_delta") is not None - ] - tasks_with_cost = tasks_with_cost[-50:] - - if not tasks_with_cost: - return Panel( - Text("No tasks recorded yet", style="dim"), - title="Task Cost Timeline", - border_style="magenta", - ) - - def _get_cost(t: dict[str, Any]) -> int: - return t.get("estimated_tokens") or abs(t.get("token_delta") or 0) - - max_cost = max(_get_cost(t) for t in tasks_with_cost) - - table = Table( - title="Task Cost Timeline", - expand=True, - title_style="bold white", - border_style="magenta", - show_lines=False, - ) - table.add_column("Time", no_wrap=True, style="dim") - table.add_column("Project", no_wrap=True, style="cyan") - table.add_column("Type", no_wrap=True) - table.add_column("Cost", justify="left", no_wrap=True) - table.add_column("Tokens", justify="right") - - for task in tasks_with_cost: - timestamp_ms = task.get("timestamp_ms", 0) - task_type = task.get("task_type", "unknown") - session_id = task.get("session_id", "") - cost = _get_cost(task) - - # Resolve project name; show folder part only to keep column compact - full_name = session_names.get(session_id, session_id[:_SESSION_ID_TRUNCATE]) or "" - # Extract just the folder portion (before the " — " title separator) - project_label = full_name.split(" \u2014 ")[0] if " \u2014 " in full_name else full_name - project_label = _truncate(project_label, 20) - - bar_width = max(1, int(cost / max_cost * _BAR_MAX_WIDTH)) if max_cost > 0 else 1 - - color = _bar_color(cost, max_cost) - bar = Text("\u2588" * bar_width, style=color) - - table.add_row( - _ts_to_time(timestamp_ms), - project_label, - task_type, - bar, - f"{cost:,}", - ) - - return Panel(table, border_style="magenta") - - -def build_rtk_panel(client: DashboardClient) -> Panel: - """Build a compact RTK savings panel.""" - rtk_data = client.fetch_rtk_status() - - if rtk_data is None or not rtk_data.get("installed"): - rtk_text = Text.assemble( - ("RTK: ", "bold"), - ("\u25cb Not installed", "dim"), - ) - return Panel(rtk_text, border_style="dim", height=3) - - parts: list[tuple[str, str]] = [("RTK: ", "bold"), ("\u25cf Active", "bold green")] - - savings = rtk_data.get("savings_24h") - if savings: - saved = savings.get("tokens_saved", 0) - pct = savings.get("savings_percentage", 0.0) - parts.append((" | ", "dim")) - parts.append((f"Saved: {saved:,} tokens ({pct:.0f}%)", "green")) - - version: str = rtk_data.get("version") or "" - if version: - # Strip "rtk " prefix if present (e.g. "rtk 0.34.1" -> "0.34.1") - ver_num = version.replace("rtk ", "").strip() - parts.append((" | ", "dim")) - parts.append((f"v{ver_num}", "dim")) - - rtk_text = Text.assemble(*parts) - return Panel(rtk_text, border_style="cyan", height=3) - - -def build_anomaly_panel(anomalies: list[dict[str, Any]]) -> Panel: - """Build the anomaly feed panel.""" - if not anomalies: - return Panel( - Text("No anomalies detected", style="green"), - title="Anomaly Feed", - border_style="green", - ) - - table = Table( - title="Anomaly Feed", - expand=True, - title_style="bold white", - border_style="red", - show_lines=False, - ) - table.add_column("Time", no_wrap=True, style="dim") - table.add_column("Tool", no_wrap=True) - table.add_column("Tokens", justify="right") - table.add_column("Z-Score", justify="right") - table.add_column("Severity", no_wrap=True) - table.add_column("Cause") - - for anomaly in anomalies: - timestamp_ms = anomaly.get("timestamp_ms", 0) - task_type = anomaly.get("task_type", "unknown") - token_cost = anomaly.get("token_cost", 0) - z_score = anomaly.get("z_score", 0.0) - severity = anomaly.get("severity") - cause = anomaly.get("cause") or "" - - severity_display = str(severity or "unknown") - severity_text = Text(severity_display, style=_severity_style(severity)) - cause_truncated = _truncate(cause, _CAUSE_TRUNCATE) - - table.add_row( - _ts_to_time(timestamp_ms), - task_type, - f"{token_cost:,}", - f"{z_score:.1f}", - severity_text, - cause_truncated, - ) - - table.add_row("", "", "", "", "", Text("Run: context-pulse anomalies", style="dim italic")) - return Panel(table, border_style="red") - - -# --------------------------------------------------------------------------- -# Layout assembly -# --------------------------------------------------------------------------- - - -def _build_layout(client: DashboardClient) -> Layout: - """Fetch all data and assemble the full dashboard layout.""" - health = client.fetch_health() - status = client.fetch_status() - anomalies = client.fetch_anomalies(limit=10) - - layout = Layout() - layout.split_column( - Layout(build_header(health), name="header", size=3), - Layout(build_rtk_panel(client), name="rtk", size=3), - Layout(name="body"), - ) - # Body: left column (sessions + anomalies) | right column (task timeline) - layout["body"].split_row( - Layout(name="left"), - Layout(build_tasks_panel(status), name="tasks"), - ) - layout["left"].split_column( - Layout(build_sessions_panel(status), name="sessions"), - Layout(build_anomaly_panel(anomalies), name="anomalies"), - ) - return layout - - -# --------------------------------------------------------------------------- -# Entry point -# --------------------------------------------------------------------------- - - -def run_dashboard(port: int | None = _DEFAULT_PORT, refresh_rate: float = 2.0) -> None: - """Run the live TUI dashboard. Blocks until Ctrl+C. - - Parameters - ---------- - port: - Port where the context-pulse collector is listening. - refresh_rate: - Seconds between data refreshes. - """ - console = Console() - # Resolve port from config if not explicitly provided - if port is None: - try: - from context_pulse.config import load_config - cfg = load_config() - port = cfg.collector.port - except Exception: - port = 7821 - client = DashboardClient(base_url=f"http://127.0.0.1:{port}") - - try: - with Live( - _build_layout(client), - console=console, - refresh_per_second=0.5, - screen=True, - ) as live: - while True: - time.sleep(refresh_rate) - live.update(_build_layout(client)) - except KeyboardInterrupt: - pass - finally: - client.close() - console.print("[dim]Dashboard stopped.[/dim]") diff --git a/tests/conftest.py b/tests/conftest.py index 7c5ef8b..566ff5a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,4 @@ -"""Shared fixtures for context-pulse Phase 1 tests.""" +"""Shared fixtures for context-analyzer-tool Phase 1 tests.""" from __future__ import annotations @@ -11,10 +11,10 @@ from fastapi import FastAPI from httpx import ASGITransport, AsyncClient -from context_pulse.collector.models import HookEventRequest, StatuslineSnapshotRequest -from context_pulse.collector.routes import api_router, hook_router -from context_pulse.config import ContextPulseConfig -from context_pulse.db.schema import open_db, run_migrations +from context_analyzer_tool.collector.models import HookEventRequest, StatuslineSnapshotRequest +from context_analyzer_tool.collector.routes import api_router, hook_router +from context_analyzer_tool.config import CATConfig +from context_analyzer_tool.db.schema import open_db, run_migrations # --------------------------------------------------------------------------- # Database fixture @@ -120,11 +120,11 @@ async def app_client( Sets app.state directly because ASGITransport does not trigger FastAPI lifespan events. """ - app = FastAPI(title="context-pulse-test") + app = FastAPI(title="context-analyzer-tool-test") app.include_router(hook_router, prefix="/hook") app.include_router(api_router, prefix="/api") app.state.db = db_connection - app.state.config = ContextPulseConfig() + app.state.config = CATConfig() app.state.sessions = {} app.state.start_time = time.time() transport = ASGITransport(app=app) # type: ignore[arg-type] diff --git a/tests/test_anomaly.py b/tests/test_anomaly.py index 8afd193..1c2490f 100644 --- a/tests/test_anomaly.py +++ b/tests/test_anomaly.py @@ -6,9 +6,9 @@ import aiosqlite -from context_pulse.config import AnomalyConfig, ClassifierConfig -from context_pulse.engine.anomaly import detect_anomaly -from context_pulse.engine.baseline import BaselineManager +from context_analyzer_tool.config import AnomalyConfig, ClassifierConfig +from context_analyzer_tool.engine.anomaly import detect_anomaly +from context_analyzer_tool.engine.baseline import BaselineManager # --------------------------------------------------------------------------- # Helpers diff --git a/tests/test_baseline.py b/tests/test_baseline.py index 411d952..f0f0f76 100644 --- a/tests/test_baseline.py +++ b/tests/test_baseline.py @@ -6,8 +6,8 @@ import aiosqlite -from context_pulse.db import baselines as db_baselines -from context_pulse.engine.baseline import BaselineManager, RollingWelford +from context_analyzer_tool.db import baselines as db_baselines +from context_analyzer_tool.engine.baseline import BaselineManager, RollingWelford # --------------------------------------------------------------------------- # RollingWelford unit tests diff --git a/tests/test_burn_rate.py b/tests/test_burn_rate.py index 9b0a9b9..6255804 100644 --- a/tests/test_burn_rate.py +++ b/tests/test_burn_rate.py @@ -1,6 +1,6 @@ """Tests for the burn rate projection engine.""" -from context_pulse.engine.burn_rate import compute_burn_rate +from context_analyzer_tool.engine.burn_rate import compute_burn_rate def test_linear_growth_projection() -> None: diff --git a/tests/test_classifier.py b/tests/test_classifier.py index eb9835c..42de397 100644 --- a/tests/test_classifier.py +++ b/tests/test_classifier.py @@ -7,9 +7,9 @@ import aiosqlite -from context_pulse.collector.models import ClassifierResponse -from context_pulse.config import ClassifierConfig -from context_pulse.engine.classifier import ( +from context_analyzer_tool.collector.models import ClassifierResponse +from context_analyzer_tool.config import ClassifierConfig +from context_analyzer_tool.engine.classifier import ( _compute_cache_key, _parse_classifier_output, classify_anomaly, diff --git a/tests/test_config.py b/tests/test_config.py index 8e846fe..0d6c1c7 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -7,8 +7,8 @@ import pytest -from context_pulse.config import ( - ContextPulseConfig, +from context_analyzer_tool.config import ( + CATConfig, get_db_path, load_config, write_default_config, @@ -20,17 +20,17 @@ def test_default_config(monkeypatch: pytest.MonkeyPatch) -> None: - """ContextPulseConfig() with no arguments should have correct defaults.""" - # Clear any CONTEXT_PULSE_* env vars that could override defaults. + """CATConfig() with no arguments should have correct defaults.""" + # Clear any CAT_* env vars that could override defaults. for key in list(os.environ): - if key.startswith("CONTEXT_PULSE_"): + if key.startswith("CAT_"): monkeypatch.delenv(key) - cfg = ContextPulseConfig() + cfg = CATConfig() assert cfg.collector.host == "127.0.0.1" assert cfg.collector.port == 7821 - assert cfg.collector.db_path == "~/.context-pulse/context_pulse.db" + assert cfg.collector.db_path == "~/.context-analyzer-tool/context_analyzer_tool.db" assert cfg.anomaly.z_score_threshold == 2.0 assert cfg.anomaly.min_sample_count == 5 @@ -112,46 +112,46 @@ def test_missing_config_uses_defaults(tmp_path: Path) -> None: def test_env_var_override(monkeypatch: pytest.MonkeyPatch) -> None: - """CONTEXT_PULSE_COLLECTOR_PORT should override the default port.""" - monkeypatch.setenv("CONTEXT_PULSE_COLLECTOR_PORT", "1234") + """CAT_COLLECTOR_PORT should override the default port.""" + monkeypatch.setenv("CAT_COLLECTOR_PORT", "1234") - cfg = ContextPulseConfig() + cfg = CATConfig() assert cfg.collector.port == 1234 def test_env_var_override_bool(monkeypatch: pytest.MonkeyPatch) -> None: """Boolean env vars should be coerced correctly.""" - monkeypatch.setenv("CONTEXT_PULSE_CLASSIFIER_ENABLED", "false") + monkeypatch.setenv("CAT_CLASSIFIER_ENABLED", "false") - cfg = ContextPulseConfig() + cfg = CATConfig() assert cfg.classifier.enabled is False def test_env_var_override_float(monkeypatch: pytest.MonkeyPatch) -> None: """Float env vars should be coerced correctly.""" - monkeypatch.setenv("CONTEXT_PULSE_ANOMALY_Z_SCORE_THRESHOLD", "4.5") + monkeypatch.setenv("CAT_ANOMALY_Z_SCORE_THRESHOLD", "4.5") - cfg = ContextPulseConfig() + cfg = CATConfig() assert cfg.anomaly.z_score_threshold == 4.5 def test_env_var_override_list(monkeypatch: pytest.MonkeyPatch) -> None: """List env vars should be split on commas.""" - monkeypatch.setenv("CONTEXT_PULSE_ANOMALY_TASK_TYPES_IGNORED", "chat,edit,search") + monkeypatch.setenv("CAT_ANOMALY_TASK_TYPES_IGNORED", "chat,edit,search") - cfg = ContextPulseConfig() + cfg = CATConfig() assert cfg.anomaly.task_types_ignored == ["chat", "edit", "search"] def test_env_var_override_string(monkeypatch: pytest.MonkeyPatch) -> None: """String env vars should be set directly.""" - monkeypatch.setenv("CONTEXT_PULSE_COLLECTOR_HOST", "0.0.0.0") + monkeypatch.setenv("CAT_COLLECTOR_HOST", "0.0.0.0") - cfg = ContextPulseConfig() + cfg = CATConfig() assert cfg.collector.host == "0.0.0.0" @@ -163,7 +163,7 @@ def test_env_var_override_string(monkeypatch: pytest.MonkeyPatch) -> None: def test_db_path_expansion() -> None: """The tilde in db_path should be expanded to the user's home directory.""" - cfg = ContextPulseConfig() + cfg = CATConfig() resolved = get_db_path(cfg) # The resolved path should NOT contain a tilde. @@ -174,7 +174,7 @@ def test_db_path_expansion() -> None: def test_db_path_expansion_custom() -> None: """A custom db_path with ~ should also be expanded.""" - cfg = ContextPulseConfig() + cfg = CATConfig() cfg.collector.db_path = "~/my-data/pulse.db" resolved = get_db_path(cfg) diff --git a/tests/test_context_warnings.py b/tests/test_context_warnings.py index 6e3d7ba..7ec2d07 100644 --- a/tests/test_context_warnings.py +++ b/tests/test_context_warnings.py @@ -10,8 +10,8 @@ import aiosqlite import pytest -from context_pulse.db import messages as db_messages -from context_pulse.notify.context_warnings import check_context_thresholds +from context_analyzer_tool.db import messages as db_messages +from context_analyzer_tool.notify.context_warnings import check_context_thresholds SESSION_ID = "test-session-warnings" CONTEXT_WINDOW = 200_000 @@ -33,7 +33,7 @@ async def _get_queued_messages(db: aiosqlite.Connection) -> list[str]: class TestContextWarnings: - """Tests for context_pulse.notify.context_warnings.""" + """Tests for context_analyzer_tool.notify.context_warnings.""" @pytest.mark.asyncio async def test_60_percent_message_suggests_compact( diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 901059c..7a66b08 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -8,7 +8,7 @@ from rich.console import Console from rich.panel import Panel -from context_pulse.dashboard.tui import ( +from context_analyzer_tool.dashboard.tui import ( DashboardClient, build_anomaly_panel, build_header, diff --git a/tests/test_db.py b/tests/test_db.py index 5d1c934..fcf8a43 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -7,9 +7,9 @@ import aiosqlite -from context_pulse.db import events as db_events -from context_pulse.db import tasks as db_tasks -from context_pulse.db.schema import get_schema_version, open_db +from context_analyzer_tool.db import events as db_events +from context_analyzer_tool.db import tasks as db_tasks +from context_analyzer_tool.db.schema import get_schema_version, open_db # --------------------------------------------------------------------------- # Schema / migration tests diff --git a/tests/test_delta_engine.py b/tests/test_delta_engine.py index 3f78f1d..a0893d2 100644 --- a/tests/test_delta_engine.py +++ b/tests/test_delta_engine.py @@ -7,7 +7,7 @@ import aiosqlite -from context_pulse.collector.delta_engine import ( +from context_analyzer_tool.collector.delta_engine import ( SessionState, cleanup_stale_sessions, on_session_stop, @@ -15,8 +15,8 @@ on_tool_use, restore_sessions_from_db, ) -from context_pulse.collector.models import HookEventRequest, StatuslineSnapshotRequest -from context_pulse.db import events as db_events +from context_analyzer_tool.collector.models import HookEventRequest, StatuslineSnapshotRequest +from context_analyzer_tool.db import events as db_events # --------------------------------------------------------------------------- # Helpers diff --git a/tests/test_notify.py b/tests/test_notify.py index d0d82c1..997f810 100644 --- a/tests/test_notify.py +++ b/tests/test_notify.py @@ -11,21 +11,21 @@ import httpx import pytest -from context_pulse.config import NotificationsConfig -from context_pulse.notify.dispatcher import ( +from context_analyzer_tool.config import NotificationsConfig +from context_analyzer_tool.notify.dispatcher import ( build_additional_context, dispatch_anomaly_notifications, ) -from context_pulse.notify.session_alert import format_session_alert -from context_pulse.notify.statusline import ( +from context_analyzer_tool.notify.session_alert import format_session_alert +from context_analyzer_tool.notify.statusline import ( format_anomaly_badge, format_statusline_with_anomaly, ) -from context_pulse.notify.system import ( +from context_analyzer_tool.notify.system import ( format_anomaly_notification, send_system_notification, ) -from context_pulse.notify.webhook import ( +from context_analyzer_tool.notify.webhook import ( format_generic_payload, format_slack_payload, send_webhook, @@ -37,7 +37,7 @@ class TestSystemNotifications: - """Tests for context_pulse.notify.system.""" + """Tests for context_analyzer_tool.notify.system.""" def test_format_anomaly_notification(self) -> None: """Title contains severity; body contains task type, tokens, and ratio.""" @@ -50,8 +50,8 @@ def test_format_anomaly_notification(self) -> None: suggestion="Use --max-depth to limit output", ) - # Title should contain severity label and the "context-pulse" prefix - assert "context-pulse" in title + # Title should contain severity label and the "context-analyzer-tool" prefix + assert "context-analyzer-tool" in title assert "High" in title # z >= 4.0 -> High # Message body checks @@ -109,7 +109,7 @@ async def test_send_system_notification_mocked(self) -> None: class TestWebhook: - """Tests for context_pulse.notify.webhook.""" + """Tests for context_analyzer_tool.notify.webhook.""" def test_format_slack_payload(self) -> None: """Slack Block Kit payload has correct structure and field values.""" @@ -187,7 +187,7 @@ async def test_send_webhook_success(self) -> None: mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) - with patch("context_pulse.notify.webhook.httpx.AsyncClient", return_value=mock_client): + with patch("context_analyzer_tool.notify.webhook.httpx.AsyncClient", return_value=mock_client): result = await send_webhook("https://example.com/hook", {"test": True}) assert result is True @@ -204,7 +204,7 @@ async def test_send_webhook_failure(self) -> None: mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) - with patch("context_pulse.notify.webhook.httpx.AsyncClient", return_value=mock_client): + with patch("context_analyzer_tool.notify.webhook.httpx.AsyncClient", return_value=mock_client): result = await send_webhook("https://example.com/hook", {"test": True}) assert result is False @@ -216,7 +216,7 @@ async def test_send_webhook_failure(self) -> None: class TestSessionAlert: - """Tests for context_pulse.notify.session_alert.""" + """Tests for context_analyzer_tool.notify.session_alert.""" def test_format_session_alert_full(self) -> None: """Alert with cause and suggestion includes all three lines.""" @@ -233,7 +233,7 @@ def test_format_session_alert_full(self) -> None: assert len(lines) == 3 # Headline - assert "[context-pulse]" in lines[0] + assert "[CAT]" in lines[0] assert "8,400" in lines[0] assert "4.2\u03c3" in lines[0] assert "2,000" in lines[0] @@ -257,7 +257,7 @@ def test_format_session_alert_no_cause(self) -> None: assert "Cause:" not in alert assert "Consider:" not in alert assert "\n" not in alert - assert "[context-pulse]" in alert + assert "[CAT]" in alert assert "5,000" in alert @@ -267,7 +267,7 @@ def test_format_session_alert_no_cause(self) -> None: class TestStatusline: - """Tests for context_pulse.notify.statusline.""" + """Tests for context_analyzer_tool.notify.statusline.""" def test_format_anomaly_badge(self) -> None: """Badge follows the pattern: warning sign, task, compact tokens, z-score.""" @@ -325,7 +325,7 @@ def test_format_statusline_normal(self) -> None: class TestDispatcher: - """Tests for context_pulse.notify.dispatcher.""" + """Tests for context_analyzer_tool.notify.dispatcher.""" @pytest.mark.asyncio async def test_dispatch_all_disabled(self) -> None: @@ -360,7 +360,7 @@ async def test_dispatch_system_enabled(self) -> None: ) with patch( - "context_pulse.notify.system.notify_anomaly", + "context_analyzer_tool.notify.system.notify_anomaly", new_callable=AsyncMock, return_value=True, ) as mock_notify: @@ -396,7 +396,7 @@ async def test_dispatch_webhook_enabled(self) -> None: ) with patch( - "context_pulse.notify.webhook.notify_webhook", + "context_analyzer_tool.notify.webhook.notify_webhook", new_callable=AsyncMock, return_value=True, ) as mock_wh: @@ -451,6 +451,6 @@ def test_build_additional_context_enabled(self) -> None: suggestion="Use --max-depth", ) assert result is not None - assert "[context-pulse]" in result + assert "[CAT]" in result assert "8,400" in result assert "Cause: Large output" in result diff --git a/tests/test_rtk_integration.py b/tests/test_rtk_integration.py index b5c19db..3d20d68 100644 --- a/tests/test_rtk_integration.py +++ b/tests/test_rtk_integration.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch -from context_pulse.rtk_integration import ( +from context_analyzer_tool.rtk_integration import ( enhance_suggestion_with_rtk, get_rtk_db_path, get_rtk_version, @@ -17,14 +17,14 @@ # --------------------------------------------------------------------------- -@patch("context_pulse.rtk_integration.shutil.which", return_value=None) +@patch("context_analyzer_tool.rtk_integration.shutil.which", return_value=None) def test_is_rtk_installed_not_found(mock_which: MagicMock) -> None: """When shutil.which returns None, is_rtk_installed should be False.""" assert is_rtk_installed() is False mock_which.assert_called_once_with("rtk") -@patch("context_pulse.rtk_integration.shutil.which", return_value="/usr/local/bin/rtk") +@patch("context_analyzer_tool.rtk_integration.shutil.which", return_value="/usr/local/bin/rtk") def test_is_rtk_installed_found(mock_which: MagicMock) -> None: """When shutil.which returns a path, is_rtk_installed should be True.""" assert is_rtk_installed() is True @@ -36,15 +36,15 @@ def test_is_rtk_installed_found(mock_which: MagicMock) -> None: # --------------------------------------------------------------------------- -@patch("context_pulse.rtk_integration.shutil.which", return_value=None) +@patch("context_analyzer_tool.rtk_integration.shutil.which", return_value=None) def test_get_rtk_version_not_installed(mock_which: MagicMock) -> None: """When rtk is not on PATH, get_rtk_version should return None.""" assert get_rtk_version() is None -@patch("context_pulse.rtk_integration.subprocess.run") +@patch("context_analyzer_tool.rtk_integration.subprocess.run") @patch( - "context_pulse.rtk_integration.shutil.which", + "context_analyzer_tool.rtk_integration.shutil.which", return_value="/usr/local/bin/rtk", ) def test_get_rtk_version_success( @@ -65,7 +65,7 @@ def test_get_rtk_version_success( @patch.dict("os.environ", {}, clear=False) -@patch("context_pulse.rtk_integration.Path.exists", return_value=False) +@patch("context_analyzer_tool.rtk_integration.Path.exists", return_value=False) def test_get_rtk_db_path_not_found(mock_exists: MagicMock) -> None: """When no candidate path exists, get_rtk_db_path returns None.""" # Also ensure RTK_DB_PATH env var is not set @@ -80,7 +80,7 @@ def test_get_rtk_db_path_not_found(mock_exists: MagicMock) -> None: @patch( - "context_pulse.rtk_integration.is_rtk_installed", + "context_analyzer_tool.rtk_integration.is_rtk_installed", return_value=False, ) def test_enhance_suggestion_with_rtk_not_installed( @@ -95,11 +95,11 @@ def test_enhance_suggestion_with_rtk_not_installed( @patch( - "context_pulse.rtk_integration.is_rtk_hooks_installed", + "context_analyzer_tool.rtk_integration.is_rtk_hooks_installed", return_value=False, ) @patch( - "context_pulse.rtk_integration.is_rtk_installed", + "context_analyzer_tool.rtk_integration.is_rtk_installed", return_value=True, ) def test_enhance_suggestion_with_rtk_installed( diff --git a/uv.lock b/uv.lock index 855854b..b5bce13 100644 --- a/uv.lock +++ b/uv.lock @@ -92,7 +92,7 @@ wheels = [ ] [[package]] -name = "context-pulse" +name = "context-analyzer-tool" version = "0.2.0" source = { editable = "." } dependencies = [