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 = [